Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a8907953 | ||
|
|
105a72700f | ||
|
|
f84cb4e3b6 | ||
|
|
06508cc7d6 | ||
|
|
9647003c3f | ||
|
|
2b943c5c22 | ||
|
|
437b229c82 | ||
|
|
3b7b9182d7 | ||
|
|
20dabecd67 | ||
|
|
838b247649 | ||
|
|
9166d8abeb | ||
|
|
9cedf4a038 | ||
|
|
dd8b9671c6 | ||
|
|
ad3dea6a15 | ||
|
|
8d7de75e99 | ||
|
|
06d6ad7eaa | ||
|
|
e76cd7bb23 | ||
|
|
bda23dbb67 | ||
|
|
66f2838402 | ||
|
|
546f28571b | ||
|
|
7a1dab21cd | ||
|
|
709e8a5d51 | ||
|
|
70db8a7491 | ||
|
|
7b9eee4805 | ||
|
|
3882b6035f | ||
|
|
39808dd04e | ||
|
|
506a061405 | ||
|
|
c5e5cfa014 | ||
|
|
5865df400f | ||
|
|
0308eb9eef | ||
|
|
d8541678a6 | ||
|
|
d0a9e75fe6 | ||
|
|
47ff544848 | ||
|
|
9744e0b8c3 | ||
|
|
d007900f6c | ||
|
|
ab0b975211 | ||
|
|
aebc28fb8a | ||
|
|
e5c29d8113 | ||
|
|
2e10bbbd62 | ||
|
|
9d0456c2fd | ||
|
|
0cd9a186ee | ||
|
|
5c87064a17 | ||
|
|
8e3865f557 | ||
|
|
7cec18c139 | ||
|
|
2f40290f00 | ||
|
|
51877b46a0 | ||
|
|
20158a9e7a | ||
|
|
835a394d3f | ||
|
|
d85da32ef5 | ||
|
|
92d72e4d48 | ||
|
|
37f70c89e3 | ||
|
|
80bc1413f1 | ||
|
|
fcb58e8a17 | ||
|
|
09453199b8 | ||
|
|
15bfebf4fc | ||
|
|
d15a3de2a1 | ||
|
|
a92600c305 | ||
|
|
afab7070fb | ||
|
|
7bf428f9a0 | ||
|
|
ca45be80db | ||
|
|
ab44258d72 | ||
|
|
983b7a1f49 | ||
|
|
d05e9cde32 | ||
|
|
32f193c008 | ||
|
|
5ecea55f55 | ||
|
|
a793b7ed0d | ||
|
|
b277c453f9 | ||
|
|
1b990a84d9 | ||
|
|
de9448a9aa | ||
|
|
2c37c05c8f | ||
|
|
56aee7f3f3 | ||
|
|
90866f830e | ||
|
|
ed586233f6 | ||
|
|
fa0a378827 | ||
|
|
bb0bc1c192 | ||
|
|
f89e4deb30 | ||
|
|
fcf10bcdff | ||
|
|
68e3da6601 | ||
|
|
d25b35c928 | ||
|
|
aeba4d78cf | ||
|
|
7bb80229db | ||
|
|
0dd4ecdb4c | ||
|
|
0f7788e973 | ||
|
|
47d5ec47e7 | ||
|
|
10af8c0d22 | ||
|
|
7d30aff986 | ||
|
|
409741642a | ||
|
|
056cea0635 | ||
|
|
d3b5700977 | ||
|
|
fd258de8c9 | ||
|
|
d6fe505628 | ||
|
|
35c07c263d | ||
|
|
251060ce58 | ||
|
|
51e6b81ac7 | ||
|
|
7c375f45dd | ||
|
|
2d3721b201 | ||
|
|
e629067434 | ||
|
|
19c6740edb | ||
|
|
962e621fee | ||
|
|
5e7d7324de | ||
|
|
ad7b932c0a | ||
|
|
da38f90641 | ||
|
|
1702139250 | ||
|
|
b02316ed7c | ||
|
|
3bfe9a99a5 | ||
|
|
a5bdbefa89 | ||
|
|
80ff12f51a | ||
|
|
1337d8e244 | ||
|
|
f1b2641718 | ||
|
|
98eecc5e87 | ||
|
|
b80c5b7aa2 | ||
|
|
fc1bdc57c4 | ||
|
|
b69eaabbba | ||
|
|
3feae80767 | ||
|
|
90ac3eb804 | ||
|
|
a0f18a886c | ||
|
|
9b05c7e770 | ||
|
|
4e9d93d8c8 | ||
|
|
b982cd0318 | ||
|
|
1c97f3a181 | ||
|
|
f48e1104e7 | ||
|
|
b0b898f1e8 | ||
|
|
de99643dd9 |
@@ -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
|
||||
@@ -0,0 +1,152 @@
|
||||
name: Push to SFTP
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# branches:
|
||||
# - main # Optional: Run automatically on pushes to the main branch
|
||||
|
||||
# OPS-011 — what this workflow now guarantees, and what it still does not.
|
||||
#
|
||||
# Guaranteed:
|
||||
# - nothing is uploaded unless the test suite and the linters pass;
|
||||
# - only files on an explicit allowlist are uploaded, so a new file at the
|
||||
# repository root does not reach production by default. That is how
|
||||
# clear_db.py — a script that DROPs every table and recreates
|
||||
# admin/password — got there in the first place;
|
||||
# - after the upload, /health is polled until it answers healthy, and the
|
||||
# job fails loudly if it does not. Before, a half-uploaded tree was a
|
||||
# green deployment.
|
||||
#
|
||||
# NOT guaranteed — the switch is not atomic. Files are mirrored in place, so
|
||||
# for the length of the transfer production runs a mixture of two versions.
|
||||
# Closing that needs a release-directory layout, which has three
|
||||
# prerequisites, two of which cannot be done from here:
|
||||
#
|
||||
# 1. the Pterodactyl startup command must run the app from `current/`
|
||||
# rather than from the server root, and the server must be restarted on
|
||||
# switch — a panel change;
|
||||
# 2. `documents/`, `logs/` and `.env` must live beside the releases, not
|
||||
# inside one. DOCUMENTS_ROOT exists for this (app/storage.py);
|
||||
# 3. contract paths must be relative to that root, so the switch does not
|
||||
# strand them. Done: new rows are relative, old absolute ones still
|
||||
# resolve.
|
||||
#
|
||||
# docs/deployment.md carries the design and the rollback procedure.
|
||||
|
||||
# Least privilege (CI-003). This job never writes back to the repository; it
|
||||
# holds the SSH key to the production node, which makes it the most valuable
|
||||
# job in either forge to compromise.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Actions pinned to a commit, version in the comment. A tag is a moving
|
||||
# pointer, and moving `v4` here means running arbitrary code in the job that
|
||||
# holds that key. If the Gitea runner ever fails to resolve a commit ref, it
|
||||
# fails on the checkout step — loudly, like the `@v7` that did not exist.
|
||||
|
||||
jobs:
|
||||
deploy-to-sftp:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Was @v7, which does not exist (latest major is v5): the workflow
|
||||
# failed on its very first step.
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# The gate. This runner is not the GitHub one, so a green CI over
|
||||
# there proves nothing about what is about to be shipped from here:
|
||||
# the deploy is triggered by hand, on whatever the branch holds.
|
||||
- name: Refuse to deploy a broken tree
|
||||
run: |
|
||||
python -m pytest -q
|
||||
python -m ruff check .
|
||||
python -m ruff format --check .
|
||||
|
||||
# An allowlist, not a list of exclusions. The previous form mirrored
|
||||
# the whole working tree minus nine globs, so every file added to the
|
||||
# repository shipped to production unless someone remembered to
|
||||
# exclude it. This inverts the default: a new top-level file has to be
|
||||
# named here to reach the server.
|
||||
- name: Assemble the release payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p payload
|
||||
cp -r app payload/
|
||||
cp requirements.txt wsgi.py payload/
|
||||
# Compiled catalogues are versioned deliberately: the deployment is
|
||||
# a file mirror with no build step (docs/translations.md).
|
||||
find payload -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||
find payload -name '*.pyc' -delete
|
||||
echo "Shipping $(find payload -type f | wc -l) files:"
|
||||
find payload -maxdepth 2 -type d | sort
|
||||
|
||||
- name: Set up SSH Private Key
|
||||
env:
|
||||
# Binds the secret to a secure environment variable
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
# Uses the environment variable, so the raw key is never printed in the execution log
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
|
||||
- name: Push files via SFTP with progress
|
||||
run: |
|
||||
# --delete is deliberately NOT used. Uploaded contracts, logs and the
|
||||
# server's own .env live under the deployment root and are absent
|
||||
# from the repository; deleting anything not present locally would
|
||||
# destroy them. Stale files therefore still accumulate — that is the
|
||||
# other half of what the release-directory layout would fix.
|
||||
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
|
||||
set sftp:auto-confirm yes; \
|
||||
set net:max-retries 5; \
|
||||
set net:timeout 30; \
|
||||
set cmd:fail-exit yes; \
|
||||
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
|
||||
mirror -R --verbose --parallel=4 ./payload/ ./; \
|
||||
quit"
|
||||
|
||||
# Without this a deployment that left the site 500ing reported success,
|
||||
# and the first person to hear about it was a user. /health checks the
|
||||
# database connection and reports whether the Discord bot thread is
|
||||
# alive (OPS-012).
|
||||
# HEALTH_URL is carried as a secret rather than as a variable. It is
|
||||
# not secret — it is the public site — but `secrets` is the context
|
||||
# this runner is already known to support, and a smoke test that fails
|
||||
# to run because of an unsupported expression is worse than none.
|
||||
- name: Smoke test
|
||||
if: ${{ secrets.HEALTH_URL != '' }}
|
||||
env:
|
||||
HEALTH_URL: ${{ secrets.HEALTH_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The app is restarted by the panel, not by this workflow, so the
|
||||
# first few probes are expected to fail or answer from the old
|
||||
# process. Two minutes, then give up.
|
||||
for attempt in $(seq 1 24); do
|
||||
body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body=''
|
||||
if echo "$body" | grep -q '"status": *"healthy"'; then
|
||||
echo "Healthy after ${attempt} attempt(s):"
|
||||
echo "$body"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt ${attempt}: not healthy yet"
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md."
|
||||
exit 1
|
||||
|
||||
- name: Warn when no health check is configured
|
||||
if: ${{ secrets.HEALTH_URL == '' }}
|
||||
run: |
|
||||
echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https://<host>/health in the repository secrets."
|
||||
+54
-26
@@ -2,7 +2,7 @@ name: CI - Security & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main, master, 'audit/**']
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch: # Allow manual triggers
|
||||
@@ -11,84 +11,112 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least privilege: nothing here writes back to the repository.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Third-party actions are pinned to a commit, with the version in a comment
|
||||
# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a
|
||||
# job that holds this repository's token. The comment is what makes the pin
|
||||
# maintainable — a bare 40-character hash tells a reader nothing about
|
||||
# whether it is current. Dependabot updates both together.
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.12'
|
||||
|
||||
jobs:
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pip-audit
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit==2.9.0
|
||||
|
||||
- name: Scan for vulnerable dependencies
|
||||
run: pip-audit --require-hashes --no-deps || pip-audit
|
||||
# Previously: `pip-audit --require-hashes --no-deps || pip-audit`.
|
||||
# Neither form named the requirements file, so the fallback audited the
|
||||
# runner's environment — which contained pip-audit and nothing else.
|
||||
# The job passed green while checking none of the application's
|
||||
# dependencies. -r makes it audit what the application actually pins.
|
||||
- name: Scan declared dependencies for known vulnerabilities
|
||||
run: pip-audit -r requirements.txt
|
||||
|
||||
lint:
|
||||
name: Lint with Ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- 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
|
||||
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 .
|
||||
|
||||
security-scan:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- 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
|
||||
env:
|
||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||
DATABASE_URL: 'sqlite:///:memory:'
|
||||
FLASK_DEBUG: 'false'
|
||||
run: python security_scan.py --skip-http
|
||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [security-audit, lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- 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
|
||||
run: |
|
||||
echo "No tests configured yet. Add tests to the project."
|
||||
# python -m pytest tests/ --cov=. --cov-report=xml
|
||||
continue-on-error: true
|
||||
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||
|
||||
+143
-13
@@ -1,21 +1,151 @@
|
||||
.env
|
||||
# =============================================================================
|
||||
# Secrets et configuration locale
|
||||
# =============================================================================
|
||||
# `.env*` couvre .env, .env.local, .env.production, .env.bak…
|
||||
# L'exception laisse passer le seul fichier modèle, qui ne doit contenir
|
||||
# que des valeurs factices.
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.instance/
|
||||
*.key
|
||||
*.pem
|
||||
*.pfx
|
||||
*.p12
|
||||
.certs/
|
||||
id_rsa
|
||||
id_ed25519
|
||||
known_hosts
|
||||
credentials.json
|
||||
service-account*.json
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — Claude
|
||||
# =============================================================================
|
||||
.claude/
|
||||
.claude.json
|
||||
.claude/settings.local.json
|
||||
CLAUDE.md
|
||||
CLAUDE.local.md
|
||||
.anthropic/
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — ChatGPT / OpenAI
|
||||
# =============================================================================
|
||||
.chatgpt/
|
||||
.openai/
|
||||
.codex/
|
||||
AGENTS.md
|
||||
chatgpt-*.md
|
||||
openai-*.md
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — autres outils
|
||||
# =============================================================================
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.cursorignore
|
||||
.windsurf/
|
||||
.windsurfrules
|
||||
.aider*
|
||||
.continue/
|
||||
.clinerules
|
||||
.roo/
|
||||
.gemini/
|
||||
GEMINI.md
|
||||
.github/copilot-instructions.md
|
||||
.copilot/
|
||||
|
||||
# =============================================================================
|
||||
# Notes de travail et suivi générés par assistant IA
|
||||
# =============================================================================
|
||||
# Rapports d'audit, plans, brouillons et notes de session : documents de
|
||||
# travail, non destinés à être versionnés dans le dépôt applicatif.
|
||||
.ai/
|
||||
audit/
|
||||
*.audit.md
|
||||
NOTES-IA.md
|
||||
TODO-IA.md
|
||||
|
||||
# =============================================================================
|
||||
# Python
|
||||
# =============================================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environnements virtuels
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Outils
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
coverage_html_report/
|
||||
coverage.xml
|
||||
|
||||
# =============================================================================
|
||||
# Données applicatives — ne jamais versionner
|
||||
# =============================================================================
|
||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||
# voir l'historique du commit `4fd27ba`).
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Contrats téléversés (données personnelles)
|
||||
documents/
|
||||
|
||||
__pycache__/
|
||||
*.cpython-313.pyc
|
||||
*.cpython-312.pyc
|
||||
*.pyc
|
||||
# Sauvegardes produites par app/supporting_scripts/backup.py
|
||||
backups/
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
||||
htmlcov/
|
||||
.DS_Store
|
||||
# Journaux applicatifs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
.certs/
|
||||
*.pem
|
||||
# É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
|
||||
|
||||
@@ -1,79 +1,153 @@
|
||||
### Plateforme centralisée de tryouts
|
||||
# Plateforme centralisée de tryouts
|
||||
|
||||
## Security Configuration
|
||||
Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
|
||||
évaluations, gestion des équipes, disponibilités, contrats, et notifications
|
||||
Discord.
|
||||
|
||||
### Required Environment Variables
|
||||
Le site est servi **en français**, l'anglais reste accessible par le sélecteur
|
||||
de la barre latérale (voir `docs/translations.md`).
|
||||
|
||||
Before deploying, create a `.env` file with the following:
|
||||
---
|
||||
|
||||
```
|
||||
# Flask Configuration (REQUIRED)
|
||||
SECRET_KEY=your-secure-random-secret-key-here
|
||||
## Démarrer
|
||||
|
||||
# Production Settings
|
||||
FLASK_DEBUG=false
|
||||
FORCE_HTTPS=true
|
||||
SESSION_COOKIE_SECURE=true
|
||||
```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
|
||||
```
|
||||
|
||||
### Security Features Implemented
|
||||
`SECRET_KEY` et `DATABASE_URL` sont **obligatoires** : `create_app()` refuse
|
||||
de démarrer sans eux. `DATABASE_URL` doit pointer sur PostgreSQL ; le pilote
|
||||
psycopg 3 est nommé automatiquement si l'URL n'en nomme pas.
|
||||
|
||||
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
|
||||
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled
|
||||
- **CSRF Protection**: Enabled by default on all forms
|
||||
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
|
||||
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
|
||||
- **Open Redirect Prevention**: URL validation on login redirect
|
||||
- **Authorization Checks**: Proper ownership validation on all sensitive operations
|
||||
Production : `python wsgi.py` (Waitress derrière nginx). Voir
|
||||
`docs/deployment.md`.
|
||||
|
||||
## Discord Integration for One on One Requests
|
||||
Ce sont les **deux seuls** points d'entrée.
|
||||
|
||||
The application supports sending Discord direct messages to coaches when players request One on One sessions.
|
||||
## Vérifier
|
||||
|
||||
### Setup Instructions
|
||||
|
||||
#### 1. Create a Discord Bot
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Create a new application
|
||||
3. Go to the "Bot" tab and create a bot user
|
||||
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
|
||||
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
|
||||
|
||||
#### 2. Configure Environment Variables
|
||||
|
||||
Add the following to your `.env` file (create one if it doesn't exist):
|
||||
|
||||
```
|
||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
DISCORD_WEBHOOK_URL=optional_webhook_url_for_backup
|
||||
```bash
|
||||
.venv/Scripts/python -m pytest # suite complète
|
||||
.venv/Scripts/python -m ruff check . # lint
|
||||
.venv/Scripts/python -m ruff format --check .
|
||||
```
|
||||
|
||||
- `DISCORD_BOT_TOKEN`: Required for sending direct messages to coaches
|
||||
- `DISCORD_WEBHOOK_URL`: Optional fallback for webhook-based notifications
|
||||
Les trois tournent en CI et y sont bloquants.
|
||||
|
||||
#### 3. Add Coaches to the Bot
|
||||
---
|
||||
|
||||
For the bot to send DMs to coaches:
|
||||
1. Each coach must have the bot added to their Discord server OR be friends with the bot
|
||||
2. Coaches need to add their Discord User ID to their profile:
|
||||
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
|
||||
- Right-click on their profile → Copy ID
|
||||
- Enter this numeric ID in the "Discord User ID" field in their profile settings
|
||||
## Ce que fait l'application
|
||||
|
||||
### How It Works
|
||||
- **Comptes et rôles** — cinq rôles : président (`admin`), gérant
|
||||
(`manager`), coach, joueur (`player`), recruteur (`scout`). Le président
|
||||
attribue les rôles.
|
||||
- **Sélections** — organisation des tryouts, trois formats de match
|
||||
(équipe contre équipe, joueur contre joueur, scrim), évaluation des
|
||||
joueurs sur dix critères.
|
||||
- **Équipes** — effectifs de la saison, matchs et entraînements. Le
|
||||
formulaire d'entraînement affiche les disponibilités des joueurs.
|
||||
- **Disponibilités** — créneaux hebdomadaires des joueurs, créneaux
|
||||
réservables des coachs.
|
||||
- **Notes** — un coach écrit des notes d'équipe (visibles par l'équipe) et
|
||||
des notes nominatives (visibles par le joueur concerné).
|
||||
- **Un-à-un** — un joueur demande une séance à son coach ; le coach répond
|
||||
depuis le site ou par une réaction sur le message privé Discord.
|
||||
- **Contrats** — dépôt d'un contrat par le staff, signature par le joueur.
|
||||
|
||||
When a player submits a One on One request:
|
||||
1. The system checks if the coach has a Discord User ID configured
|
||||
2. If configured, a direct message is sent to the coach via the Discord bot
|
||||
3. If the bot fails or no Discord User ID is set, the system falls back to the webhook URL (if configured)
|
||||
4. The message includes player name, team, requested date/time, and discussion points
|
||||
## Comment c'est construit
|
||||
|
||||
### Message Format
|
||||
Backend Python 3.12 / Flask, rendu serveur en Jinja2, CSS et JavaScript
|
||||
maison, sans framework front. Base PostgreSQL via SQLAlchemy. Bot Discord
|
||||
(`discord.py`) dans un fil du même processus que le serveur web.
|
||||
|
||||
The Discord DM includes:
|
||||
- Player name
|
||||
- Team name
|
||||
- Requested date and time slot
|
||||
- Discussion points (if provided)
|
||||
- Link to the application for approval/rejection
|
||||
`docs/architecture.md` contient les diagrammes (classes, paquets, flux
|
||||
d'une requête).
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
En place et vérifié par des tests :
|
||||
|
||||
- **Limitation de débit** sur la connexion (10 requêtes/minute par IP).
|
||||
- **Cookies de session** `HttpOnly`, `SameSite=Lax`, `Secure`, avec
|
||||
expiration effective.
|
||||
- **CSRF** sur tous les formulaires, y compris la déconnexion (en POST).
|
||||
- **HTTPS** forcé en production, **HSTS**.
|
||||
- **CSP sans `unsafe-inline`** sur `script-src` : aucun gestionnaire
|
||||
d'événement en ligne, chaque bloc `<script>` porte un nonce par requête.
|
||||
- **Redirections** validées (rien ne sort du site).
|
||||
- **Validation** par schémas marshmallow sur les formulaires de compte, avec
|
||||
politique de mot de passe.
|
||||
- **Autorisation** centralisée dans `app/permissions.py`.
|
||||
- **Journal d'authentification** (`logs/auth.log`) : connexions, échecs,
|
||||
changements de rôle, suppressions de compte.
|
||||
- **Téléversements** vérifiés par extension *et* par signature de fichier.
|
||||
|
||||
Ce qui **n'est pas** fait, pour que personne ne s'y fie :
|
||||
|
||||
- **Aucune migration de schéma.** `db.create_all()` crée les tables
|
||||
manquantes et ne modifie jamais une table existante : une colonne ajoutée
|
||||
à un modèle est absente de la production.
|
||||
- **Les secrets de l'historique git ne sont pas révoqués** — jeton du bot,
|
||||
mot de passe PostgreSQL, `SECRET_KEY`.
|
||||
- **`trusted_proxy='*'`** reste le défaut : l'en-tête `X-Forwarded-For` est
|
||||
accepté de n'importe quelle source, donc la limitation par IP est
|
||||
contournable. C'est désormais la variable `TRUSTED_PROXY` plutôt qu'une
|
||||
constante — `docs/deployment.md` donne la valeur pour chaque topologie.
|
||||
- **Les comptes créés par le formulaire d'inscription sont actifs
|
||||
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
|
||||
de produit en attente, voir `docs/roles-and-permissions.md`.
|
||||
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
|
||||
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
|
||||
snowflake et l'application refuse les nouvelles collisions. Les doublons
|
||||
historiques doivent être relevés puis corrigés avant la contrainte
|
||||
`UNIQUE` (`schema_report.py --check-discord-identities`).
|
||||
|
||||
`docs/security-checklist.md` détaille la liste avant mise en production.
|
||||
|
||||
### Documentation
|
||||
|
||||
| Document | Pour |
|
||||
|---|---|
|
||||
| `docs/deployment.md` | Installer, déployer, revenir en arrière |
|
||||
| `docs/roles-and-permissions.md` | Qui peut faire quoi, et où c'est décidé |
|
||||
| `docs/database-restore.md` | Sauvegarder et restaurer |
|
||||
| `docs/database-schema.md` | Sortir de `create_all()` : relevé, Alembic, migrations |
|
||||
| `docs/incident-runbook.md` | Quand quelque chose ne va pas |
|
||||
| `docs/architecture.md` | Diagrammes |
|
||||
| `docs/translations.md` | Ajouter ou corriger une traduction |
|
||||
| `docs/security-checklist.md` | Avant une mise en production |
|
||||
|
||||
---
|
||||
|
||||
## Intégration Discord
|
||||
|
||||
Messages privés au coach lors d'une demande d'un-à-un, aux joueurs à la
|
||||
création d'un match ou d'un entraînement les concernant, et rappel 24 h
|
||||
avant un match. Les réponses se font par réaction sur le message ou depuis
|
||||
le site.
|
||||
|
||||
L'état des messages en attente de réponse est dans `discord_pending.json`,
|
||||
**non versionné** : c'est de l'état d'exécution, propre à chaque serveur.
|
||||
|
||||
### Mise en place
|
||||
|
||||
Le bot du club existe déjà ; ce qui suit ne concerne qu'une nouvelle
|
||||
installation.
|
||||
|
||||
1. Créer une application sur le [portail développeur
|
||||
Discord](https://discord.com/developers/applications), puis un bot.
|
||||
2. Copier le jeton dans `DISCORD_BOT_TOKEN`.
|
||||
3. Activer **Message Content Intent** dans les *Privileged Gateway Intents*.
|
||||
C'est le seul intent privilégié demandé : il sert à lire le motif d'un
|
||||
refus écrit en réponse au message.
|
||||
4. Chaque personne doit partager un serveur avec le bot (ou l'avoir en ami)
|
||||
pour recevoir un message privé, et renseigner son identifiant Discord
|
||||
dans son profil (Discord → Paramètres → Avancés → Mode développeur, puis
|
||||
clic droit sur son profil → Copier l'identifiant).
|
||||
|
||||
`/health` indique si le bot tourne et s'il est connecté.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,379 +0,0 @@
|
||||
"""Team Tryouts Application - Flask Application Factory.
|
||||
|
||||
This module provides the application factory for creating and configuring
|
||||
the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||
from flask_cors import CORS
|
||||
from extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
import markupsafe
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def nl2br(value):
|
||||
"""Convert newlines to HTML line breaks.
|
||||
|
||||
Args:
|
||||
value: String value to convert.
|
||||
|
||||
Returns:
|
||||
Markup: HTML-safe string with line breaks.
|
||||
"""
|
||||
if value:
|
||||
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
|
||||
return ''
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application.
|
||||
|
||||
Initializes Flask with:
|
||||
- Secret key for session security
|
||||
- Database configuration
|
||||
- CSRF protection
|
||||
- CORS with restricted origins
|
||||
- Login manager
|
||||
- Rate limiting
|
||||
- All route blueprints
|
||||
- Security headers and HTTPS redirects
|
||||
- Custom error handlers
|
||||
- Health check endpoint
|
||||
- Structured logging
|
||||
|
||||
Handles database initialization and seeding with sample data if empty.
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||
if not app.config['SECRET_KEY']:
|
||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
|
||||
# File upload size limit (16 MB)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||
|
||||
# Secure session cookie settings
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||
|
||||
# Configure CORS - restrict to specific origins in production
|
||||
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
||||
|
||||
if allowed_origins:
|
||||
CORS(
|
||||
app,
|
||||
origins=allowed_origins,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600, # Cache preflight for 1 hour
|
||||
)
|
||||
else:
|
||||
# When no origins specified, allow all (development) or none (production)
|
||||
# In production with a reverse proxy, CORS is handled at the Nginx level
|
||||
CORS(
|
||||
app,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600,
|
||||
)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
# Configure structured logging
|
||||
from logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
|
||||
from routes.auth import auth_bp
|
||||
from routes.tryouts import tryouts_bp
|
||||
from routes.evaluations import evaluations_bp
|
||||
from routes.users import users_bp
|
||||
from routes.main import main_bp
|
||||
from routes.teams import teams_bp
|
||||
from routes.matches import matches_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
app.register_blueprint(evaluations_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
|
||||
# =========================================================================
|
||||
# Security Headers
|
||||
# =========================================================================
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses.
|
||||
|
||||
Implements defense-in-depth with comprehensive HTTP security headers.
|
||||
These complement the headers set by Nginx in production.
|
||||
|
||||
HSTS is only sent in production (non-debug) to avoid breaking
|
||||
local development over plain HTTP.
|
||||
"""
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), '
|
||||
'interest-cohort=(), payment=(), usb=()'
|
||||
)
|
||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' https://cdnjs.cloudflare.com; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
# Only enable HSTS when HTTPS is actually being used
|
||||
# (either direct TLS or behind a proxy that terminates TLS)
|
||||
is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
if is_https:
|
||||
response.headers['Strict-Transport-Security'] = (
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS Redirect (Production only)
|
||||
# =========================================================================
|
||||
@app.before_request
|
||||
def force_https():
|
||||
"""Redirect all HTTP requests to HTTPS in production.
|
||||
|
||||
Respects the X-Forwarded-Proto header from reverse proxies.
|
||||
Can be disabled via FORCE_HTTPS environment variable.
|
||||
"""
|
||||
if not app.debug:
|
||||
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
|
||||
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true':
|
||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||
|
||||
# =========================================================================
|
||||
# Health Check Endpoint
|
||||
# =========================================================================
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring and load balancers.
|
||||
|
||||
Verifies database connectivity and application health.
|
||||
Returns 200 with basic status info or 503 if unhealthy.
|
||||
|
||||
Returns:
|
||||
Response: JSON health status.
|
||||
"""
|
||||
health_data = {
|
||||
'status': 'healthy',
|
||||
'app': 'team-tryouts',
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
health_data['database'] = 'connected'
|
||||
except Exception as e:
|
||||
health_data['status'] = 'unhealthy'
|
||||
health_data['database'] = f'error: {str(e)}'
|
||||
return jsonify(health_data), 503
|
||||
|
||||
return jsonify(health_data), 200
|
||||
|
||||
# =========================================================================
|
||||
# Custom Error Handlers
|
||||
# =========================================================================
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
"""Handle 400 Bad Request errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/coach-availability') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
"""Handle 401 Unauthorized errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login for pages, JSON for API.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
from flask import flash as _flash
|
||||
_flash('Please log in to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
"""Handle 403 Forbidden errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 Not Found errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@app.errorhandler(429)
|
||||
def too_many_requests(error):
|
||||
"""Handle 429 Too Many Requests errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API or rendered page.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Too many requests',
|
||||
'message': 'Please try again later.'
|
||||
}), 429
|
||||
return render_template('errors/429.html', error=error), 429
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 Internal Server Error.
|
||||
|
||||
Never exposes stack traces to users. Logs the full error internally.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Generic error page or JSON.
|
||||
"""
|
||||
# Log the full error for debugging
|
||||
app.logger.error('Internal Server Error: %s', str(error), exc_info=True)
|
||||
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.'
|
||||
}), 500
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
"""Catch-all handler for any unhandled HTTP exceptions.
|
||||
|
||||
Args:
|
||||
error: The HTTPException object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API, re-raises for others.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': error.name,
|
||||
'message': error.description,
|
||||
'code': error.code
|
||||
}), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
# Database Initialization
|
||||
# =========================================================================
|
||||
with app.app_context():
|
||||
import models
|
||||
from models import User
|
||||
try:
|
||||
# Check if the database schema is up to date by testing a query
|
||||
db.session.execute(text('SELECT games, team_side FROM match_participants LIMIT 1'))
|
||||
db.create_all()
|
||||
except Exception:
|
||||
# If there's a schema mismatch, drop and recreate all tables
|
||||
db.session.rollback()
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
|
||||
# Seed database if empty
|
||||
if User.query.count() == 0:
|
||||
from seed import seed_database
|
||||
seed_database()
|
||||
|
||||
# Start the Discord bot for notifications
|
||||
try:
|
||||
from discord_bot import start_bot
|
||||
start_bot()
|
||||
except Exception as e:
|
||||
app.logger.warning('Could not start Discord bot: %s', e)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only used for development - production uses wsgi.py (Waitress)
|
||||
app = create_app()
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
if debug_mode:
|
||||
app.logger.warning(
|
||||
'Running in DEBUG mode with Flask built-in server. '
|
||||
'This is NOT suitable for production. Use wsgi.py instead.'
|
||||
)
|
||||
app.run(debug=debug_mode, host='127.0.0.1', port=5000)
|
||||
@@ -0,0 +1,144 @@
|
||||
# Team Tryouts — environment variables
|
||||
#
|
||||
# Copy to .env and fill in. Every value here is a PRODUCTION-SAFE default:
|
||||
# copying this file and changing nothing gives a locked-down configuration
|
||||
# that refuses to start until the two required secrets are set, rather than
|
||||
# a working one that happens to be wide open (OPS-003).
|
||||
#
|
||||
# The previous version shipped FLASK_DEBUG=true under a heading that said
|
||||
# "fill in the values for production". The Werkzeug debugger executes code
|
||||
# submitted through the browser, so that one line turned a copy-paste into a
|
||||
# remote shell.
|
||||
#
|
||||
# For local development, see the DEVELOPMENT block at the bottom.
|
||||
|
||||
# =============================================================================
|
||||
# Required — the application refuses to start without these
|
||||
# =============================================================================
|
||||
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
# Never reuse one between environments: this key signs session cookies, so
|
||||
# whoever holds it can forge a session for any account.
|
||||
SECRET_KEY=
|
||||
|
||||
# Expected form: postgresql://user:password@host:5432/database
|
||||
# The psycopg 3 driver is named for you by create_app(); postgresql:// alone
|
||||
# would send SQLAlchemy looking for psycopg2, which is not installed.
|
||||
DATABASE_URL=
|
||||
|
||||
# =============================================================================
|
||||
# Security — these defaults assume HTTPS in front. Do not relax them on a
|
||||
# deployed instance.
|
||||
# =============================================================================
|
||||
|
||||
# Session cookies are only sent over HTTPS.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# Plain HTTP is redirected to HTTPS.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# The Werkzeug debugger is a remote code execution primitive by design.
|
||||
# Never true on anything reachable from a network you do not control.
|
||||
FLASK_DEBUG=false
|
||||
|
||||
# Inline <script> without a nonce. Off: every block carries one, and turning
|
||||
# this on gives up the protection that would have blocked the stored XSS
|
||||
# (SEC-WEB-001). It exists as an escape hatch, not as a setting to tune.
|
||||
CSP_ALLOW_INLINE_SCRIPT=false
|
||||
|
||||
# Comma-separated origins allowed to call this API cross-site. Empty means
|
||||
# no CORS policy at all, which is correct: the site renders its own HTML on
|
||||
# one origin and needs none.
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# =============================================================================
|
||||
# Networking
|
||||
# =============================================================================
|
||||
|
||||
# Interface Waitress binds. 127.0.0.1 keeps it reachable only through the
|
||||
# local reverse proxy; 0.0.0.0 exposes it directly and is only correct if
|
||||
# something else in front is doing the filtering.
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
# Whether to believe X-Forwarded-For, and from whom. This decides which IP
|
||||
# the rate limiter and the audit log record.
|
||||
#
|
||||
# (empty) — trust nobody. Correct when nothing proxies the app.
|
||||
# 127.0.0.1 — trust a reverse proxy on this same machine. The usual case.
|
||||
# * — trust everyone. Only ever correct if the app cannot be reached
|
||||
# except through the proxy, at the network level. Otherwise any
|
||||
# caller can claim any IP and walk around the rate limit.
|
||||
#
|
||||
# See docs/deployment.md before changing this (OPS-002).
|
||||
TRUSTED_PROXY=127.0.0.1
|
||||
|
||||
# =============================================================================
|
||||
# Optional — Discord
|
||||
# =============================================================================
|
||||
|
||||
# Leave ENABLE_DISCORD_BOT=false and the token empty to run without Discord.
|
||||
ENABLE_DISCORD_BOT=false
|
||||
DISCORD_BOT_TOKEN=
|
||||
|
||||
# OAuth2, for "Connect Discord" on the sign-up page.
|
||||
# Create an application at https://discord.com/developers/applications
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||
|
||||
# =============================================================================
|
||||
# Optional — storage
|
||||
# =============================================================================
|
||||
|
||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
||||
# to a release-directory layout, or a deployment will take the documents with
|
||||
# it (OPS-011, app/storage.py).
|
||||
#
|
||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
||||
# did not, and archived `./documents` regardless — so setting this here and
|
||||
# nowhere else produced empty contract backups that still exited 0.
|
||||
DOCUMENTS_ROOT=
|
||||
|
||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
||||
# defaults are anchored on the application, not on the directory the process
|
||||
# was started from, which is what they used to be (OBS-006).
|
||||
LOG_DIR=
|
||||
|
||||
# Where the backup script writes its archives. Empty means `backups/` beside
|
||||
# the application.
|
||||
BACKUP_DIR=
|
||||
|
||||
# =============================================================================
|
||||
# Optional — rate limiting
|
||||
# =============================================================================
|
||||
|
||||
# Where the rate limiter keeps its counters. Empty means `memory://`, which
|
||||
# is correct for a single Waitress process and is what this deployment runs.
|
||||
#
|
||||
# Set it to a shared backend (redis://…) BEFORE running more than one worker:
|
||||
# in-memory counters are per-process, so N workers let through N times every
|
||||
# configured limit, with nothing to show for it in the logs.
|
||||
#
|
||||
# Note that shared storage does not by itself make the limits sound: they are
|
||||
# keyed on the client IP, which is forgeable until TRUSTED_PROXY is set
|
||||
# correctly (SEC-WEB-002 / OPS-002 — see above).
|
||||
RATELIMIT_STORAGE_URI=
|
||||
|
||||
# Tables are created at startup when missing. Set to false once Alembic owns
|
||||
# the schema (DB-002/DB-004): create_all() never ALTERs, so a column added to
|
||||
# a model is silently absent from an existing database.
|
||||
AUTO_CREATE_TABLES=true
|
||||
|
||||
# =============================================================================
|
||||
# DEVELOPMENT ONLY — the values to change on a laptop, and nowhere else
|
||||
# =============================================================================
|
||||
#
|
||||
# FLASK_DEBUG=true reloader and interactive debugger
|
||||
# SESSION_COOKIE_SECURE=false cookies over plain HTTP
|
||||
# FORCE_HTTPS=false no redirect to HTTPS
|
||||
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||
#
|
||||
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||
# development session cannot accidentally inherit a production binding.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
+47
@@ -0,0 +1,47 @@
|
||||
"""Marking the views whose answers — including their failures — are JSON.
|
||||
|
||||
STD-09. Deciding "JSON or HTML page" from the URL path could not work here,
|
||||
and the audit's own recommendation ("gestion d'erreurs API par préfixe d'URL
|
||||
codé en dur", fix the prefixes) would not have fixed it either. Three of the
|
||||
sixteen JSON views sit at paths no prefix can single out:
|
||||
|
||||
/matches/<int:match_id>/toggle-presence/<int:participant_id>
|
||||
/team-matches/<int:match_id>/toggle-presence/<int:participant_id>
|
||||
/teams/<int:team_id>/toggle_status/<int:player_id>
|
||||
|
||||
They are interleaved with the HTML routes of the same blueprints, and the
|
||||
templates fetch them. Any prefix wide enough to catch them catches every
|
||||
page of the section with them.
|
||||
|
||||
So the view says so itself. `wants_json_response()` in app.py reads the mark
|
||||
off the registered view function, and `tests/test_api_error_format.py` walks
|
||||
the URL map to prove that every view calling `jsonify` carries it — the
|
||||
mechanism that was missing before was not a better list, it was anything at
|
||||
all that checked the list.
|
||||
|
||||
Usage — directly under the route decorator, above `login_required`, so the
|
||||
mark lands on the object the route registers::
|
||||
|
||||
@matches_bp.route('/api/events')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_events():
|
||||
...
|
||||
"""
|
||||
|
||||
|
||||
def json_endpoint(view):
|
||||
"""Mark a view as answering in JSON, errors included.
|
||||
|
||||
Args:
|
||||
view: The view function, already wrapped by any decorator below this
|
||||
one (`login_required` in every current case).
|
||||
|
||||
Returns:
|
||||
The same object, with the mark set. Nothing is wrapped: an extra
|
||||
wrapper here would be one more thing between Flask and the view for
|
||||
no gain, and `functools.wraps` copying `__dict__` is exactly the
|
||||
detail that would make this fragile.
|
||||
"""
|
||||
view.returns_json = True
|
||||
return view
|
||||
+726
@@ -0,0 +1,726 @@
|
||||
"""Team Tryouts Application - Flask Application Factory.
|
||||
|
||||
This module provides the application factory for creating and configuring
|
||||
the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
import markupsafe
|
||||
from dotenv import load_dotenv
|
||||
from flask import (
|
||||
Flask,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from app import i18n
|
||||
from app.extensions import babel, csrf, db, limiter, login_manager
|
||||
from app.pagination import page_url
|
||||
|
||||
load_dotenv()
|
||||
|
||||
#: Fallback for requests that never reached a view: a 404 on an unrouted
|
||||
#: path has no endpoint to read a mark off, and `/users/api/typo` should
|
||||
#: still answer a fetch() in JSON.
|
||||
#:
|
||||
#: Not the primary mechanism. Seven error handlers each carried their own
|
||||
#: copy of a list like this (STD-09); the copies had drifted, and all seven
|
||||
#: were missing the same endpoints. Views now mark themselves — see
|
||||
#: `app/api.py` for why a prefix list could not have been made correct.
|
||||
JSON_URL_PREFIXES = (
|
||||
'/users/disponibilities',
|
||||
'/users/coach-availability',
|
||||
'/users/api/',
|
||||
'/matches/api/',
|
||||
'/team-matches/api/',
|
||||
)
|
||||
|
||||
|
||||
def wants_json_response():
|
||||
"""Whether this request must be answered with JSON rather than an HTML page.
|
||||
|
||||
Three signals, in order of authority: the view said so (`@json_endpoint`),
|
||||
the path is under a JSON prefix (for requests that matched no view at
|
||||
all), or the caller asked for JSON and nothing else.
|
||||
"""
|
||||
view = current_app.view_functions.get(request.endpoint) if request.endpoint else None
|
||||
if getattr(view, 'returns_json', False):
|
||||
return True
|
||||
if request.path.startswith(JSON_URL_PREFIXES):
|
||||
return True
|
||||
accept = request.accept_mimetypes
|
||||
return accept.best == 'application/json' and not accept.accept_html
|
||||
|
||||
|
||||
def nl2br(value):
|
||||
"""Convert newlines to HTML line breaks.
|
||||
|
||||
Args:
|
||||
value: String value to convert.
|
||||
|
||||
Returns:
|
||||
Markup: HTML-safe string with line breaks.
|
||||
"""
|
||||
if value:
|
||||
# Markup('<br>').join() escapes each segment before joining.
|
||||
# Markup('<br>'.join(...)) would mark attacker-controlled text as safe.
|
||||
return markupsafe.Markup('<br>').join(str(value).splitlines())
|
||||
return ''
|
||||
|
||||
|
||||
def normalise_database_url(url):
|
||||
"""Name the PostgreSQL driver explicitly in a connection URL.
|
||||
|
||||
`postgresql://…` does not mean "whichever driver is installed": it means
|
||||
psycopg2, which SQLAlchemy imports at create_engine() time. requirements
|
||||
.txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean
|
||||
install starting against the URL Render hands out — and the one this
|
||||
project's own documentation shows — raises
|
||||
|
||||
ModuleNotFoundError: No module named 'psycopg2'
|
||||
|
||||
before the first request. Anything with a driver already spelled out
|
||||
(`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so
|
||||
naming psycopg2 stays possible for an environment that has it.
|
||||
|
||||
`postgres://` is the legacy alias several hosts still emit; SQLAlchemy
|
||||
dropped it in 1.4.
|
||||
|
||||
Args:
|
||||
url: Value of DATABASE_URL, or None.
|
||||
|
||||
Returns:
|
||||
str | None: The URL, with a driver named when it was PostgreSQL.
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
scheme, separator, rest = url.partition('://')
|
||||
if not separator or '+' in scheme:
|
||||
return url
|
||||
if scheme in ('postgres', 'postgresql'):
|
||||
return f'postgresql+psycopg://{rest}'
|
||||
return url
|
||||
|
||||
|
||||
def build_csp(*, allow_inline_script, nonce=None):
|
||||
"""Assemble the Content-Security-Policy header.
|
||||
|
||||
Two mutually exclusive modes, and they really are exclusive.
|
||||
|
||||
Under CSP level 3, a browser that understands nonces **ignores
|
||||
'unsafe-inline' entirely as soon as a nonce is present**. Emitting both
|
||||
would therefore not be a gentle transition: it would drop every inline
|
||||
script and every onclick attribute at once, in modern browsers only.
|
||||
The switch has to be atomic, which is why one flag drives it.
|
||||
|
||||
While allow_inline_script is true no nonce is emitted at all, so adding
|
||||
nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless.
|
||||
|
||||
Flipping the flag requires every inline event handler to be gone first.
|
||||
A nonce cannot authorise an onclick attribute — nonces apply to script
|
||||
elements, never to handler attributes. See tests/test_csp.py, which
|
||||
tracks how many are left.
|
||||
|
||||
Args:
|
||||
allow_inline_script: Keep 'unsafe-inline' in script-src.
|
||||
nonce: Per-request nonce, used only when inline script is not allowed.
|
||||
|
||||
Returns:
|
||||
str: The header value.
|
||||
"""
|
||||
if allow_inline_script:
|
||||
script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
else:
|
||||
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
|
||||
|
||||
return '; '.join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
f'script-src {script_src}',
|
||||
# style-src is a separate migration: inline style="" attributes are
|
||||
# spread across the templates and are not an XSS vector on their own.
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
|
||||
"font-src 'self' https://cdnjs.cloudflare.com",
|
||||
"img-src 'self' data: https://cdn.discordapp.com",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
"""Create and configure the Flask application.
|
||||
|
||||
Args:
|
||||
config: Optional mapping of configuration overrides, applied after the
|
||||
environment defaults and before validation. This is what makes the
|
||||
factory usable from tests: pass a throwaway database URI, a dummy
|
||||
secret, and turn off the Discord bot, without touching os.environ.
|
||||
|
||||
Initializes Flask with:
|
||||
- Secret key for session security
|
||||
- Database configuration
|
||||
- CSRF protection
|
||||
- CORS with restricted origins
|
||||
- Login manager
|
||||
- Rate limiting
|
||||
- All route blueprints
|
||||
- Security headers and HTTPS redirects
|
||||
- Custom error handlers
|
||||
- Health check endpoint
|
||||
- Structured logging
|
||||
|
||||
Handles database initialization and seeding with sample data if empty.
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Cache-busting stamps for static files, filled lazily by the url_defaults
|
||||
# hook below. Per application instance, so the test suite does not carry
|
||||
# one app's mtimes into the next.
|
||||
_static_stamps = {}
|
||||
|
||||
# --- defaults from the environment ------------------------------------
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '')
|
||||
app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true'
|
||||
|
||||
# Now false: every inline event handler has been replaced by a
|
||||
# data-action attribute dispatched from main.js, so script-src no longer
|
||||
# needs 'unsafe-inline'. Inline <script> blocks carry a per-request
|
||||
# nonce. The escape hatch remains for a deployment that hits an
|
||||
# overlooked handler — but leaving it on gives up the protection that
|
||||
# would have blocked SEC-XSS-001.
|
||||
app.config['CSP_ALLOW_INLINE_SCRIPT'] = (
|
||||
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Internationalisation. French is the site's primary language.
|
||||
app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE
|
||||
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'translations'
|
||||
)
|
||||
|
||||
# Side effects of create_app(), both on by default so that production and
|
||||
# development behave exactly as before. Tests turn them off.
|
||||
app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
|
||||
app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
|
||||
|
||||
# Where the rate limiter keeps its counters (SEC-WEB-004).
|
||||
#
|
||||
# `memory://` is what Flask-Limiter falls back to when nothing is set,
|
||||
# and it is the correct choice here: Waitress serves this application
|
||||
# from one process, so one set of counters in that process is all there
|
||||
# is to share. Naming it changes nothing at runtime and two things
|
||||
# otherwise — it stops being an accident, and it becomes settable to a
|
||||
# Redis URI on the day the deployment gains a second process, which is
|
||||
# the day in-memory counters would start letting through N times the
|
||||
# configured limit without anyone noticing.
|
||||
#
|
||||
# What this does not fix: the counters are keyed on an IP address that
|
||||
# is forgeable while TRUSTED_PROXY is unresolved (OPS-002). Shared
|
||||
# storage for a forgeable key buys nothing, which is why that one is
|
||||
# the prerequisite and not this.
|
||||
app.config['RATELIMIT_STORAGE_URI'] = os.getenv('RATELIMIT_STORAGE_URI', 'memory://')
|
||||
|
||||
# --- caller overrides win ---------------------------------------------
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
# --- validation, after overrides so tests can supply their own ---------
|
||||
if not app.config['SECRET_KEY']:
|
||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||
if not app.config['SQLALCHEMY_DATABASE_URI']:
|
||||
raise RuntimeError(
|
||||
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
|
||||
)
|
||||
|
||||
# After the overrides, so a caller-supplied URL is normalised too.
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = normalise_database_url(
|
||||
app.config['SQLALCHEMY_DATABASE_URI']
|
||||
)
|
||||
|
||||
# File upload size limit (16 MB)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||
|
||||
# Secure session cookie settings
|
||||
app.config['SESSION_COOKIE_SECURE'] = (
|
||||
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
)
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||
|
||||
# Configure CORS - restrict to specific origins in production
|
||||
allowed_origins = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',')
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
||||
|
||||
# CORS is only configured when origins are named explicitly.
|
||||
#
|
||||
# The previous else-branch called CORS(app, supports_credentials=True)
|
||||
# with no origins argument. flask-cors then defaults to '*' and, because
|
||||
# credentials are allowed, echoes back whatever Origin the caller sent
|
||||
# together with Access-Control-Allow-Credentials: true — the opposite of
|
||||
# the "allow all (development) or none (production)" the comment claimed.
|
||||
#
|
||||
# Exploitation was blocked by SESSION_COOKIE_SAMESITE = 'Lax', which stops
|
||||
# the browser attaching the session cookie to a cross-site fetch. That is
|
||||
# a single setting standing between a misconfiguration and a cross-origin
|
||||
# data leak. This application renders server-side HTML on one origin and
|
||||
# needs no CORS policy at all.
|
||||
if allowed_origins:
|
||||
CORS(
|
||||
app,
|
||||
origins=allowed_origins,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600, # Cache preflight for 1 hour
|
||||
)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
# Said once, at startup, because the failure mode is silent: counters in
|
||||
# process memory are lost on every restart and are not shared, so a
|
||||
# second worker would double every limit and nothing would report it.
|
||||
if app.config['RATELIMIT_STORAGE_URI'].startswith('memory://'):
|
||||
app.logger.info(
|
||||
'Rate limiting counters are held in process memory. Correct for a '
|
||||
'single-process deployment; set RATELIMIT_STORAGE_URI to a shared '
|
||||
'backend before running more than one worker (SEC-WEB-004).'
|
||||
)
|
||||
else:
|
||||
app.logger.info(
|
||||
'Rate limiting counters are held in a shared backend (%s).',
|
||||
app.config['RATELIMIT_STORAGE_URI'].split('://', 1)[0],
|
||||
)
|
||||
|
||||
babel.init_app(app, locale_selector=i18n.select_locale)
|
||||
|
||||
# Exposed to every template so the language switcher can render itself
|
||||
# without each view having to pass the list along.
|
||||
@app.before_request
|
||||
def generate_csp_nonce():
|
||||
# Only meaningful once inline script is disallowed; generated
|
||||
# unconditionally so templates can carry nonce="" beforehand.
|
||||
g.csp_nonce = secrets.token_urlsafe(16)
|
||||
|
||||
@app.before_request
|
||||
def assign_request_id():
|
||||
"""Give this request a name, so its log lines can be found (OBS-005).
|
||||
|
||||
Every record emitted while handling it carries this id — see
|
||||
RequestIdFilter — which is what turns "an error happened around
|
||||
14:32" into the six lines that led to it. It goes back in
|
||||
X-Request-Id and onto the 500 page, so that a report of "it broke
|
||||
when I clicked save" is enough to find the trace.
|
||||
|
||||
Generated here, never taken from an inbound header: with no trusted
|
||||
proxy settled (OPS-002), an accepted header lets any caller write
|
||||
arbitrary text — newlines included — into the log file.
|
||||
"""
|
||||
g.request_id = secrets.token_hex(8)
|
||||
|
||||
@app.after_request
|
||||
def expose_request_id(response):
|
||||
response.headers['X-Request-Id'] = g.get('request_id', '-')
|
||||
return response
|
||||
|
||||
@app.url_defaults
|
||||
def version_static_urls(endpoint, values):
|
||||
"""Stamp every static URL with the file's modification time.
|
||||
|
||||
Without this, nginx cannot be allowed to cache style.css and main.js:
|
||||
their URLs never change, so a 30-day expiry means a 30-day-old stylesheet
|
||||
with no way to invalidate it short of telling people to hard-refresh.
|
||||
With it, a deployed file gets a new URL and the old entry simply stops
|
||||
being asked for — which is what makes the `immutable` in nginx.conf
|
||||
true rather than merely fast (PERF-006).
|
||||
|
||||
The stamp is computed once per file per process. The process restarts
|
||||
on deploy, which is exactly when a file can have changed.
|
||||
"""
|
||||
if endpoint != 'static' or 'filename' not in values:
|
||||
return
|
||||
filename = values['filename']
|
||||
stamp = _static_stamps.get(filename)
|
||||
if stamp is None:
|
||||
try:
|
||||
stamp = str(int(os.stat(os.path.join(app.static_folder, filename)).st_mtime))
|
||||
except OSError:
|
||||
# A missing file is the template's problem, not this hook's:
|
||||
# let the URL build and let the 404 say so.
|
||||
stamp = ''
|
||||
_static_stamps[filename] = stamp
|
||||
if stamp:
|
||||
values['v'] = stamp
|
||||
|
||||
@app.context_processor
|
||||
def inject_csp_nonce():
|
||||
return {
|
||||
'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_locales():
|
||||
from flask_babel import get_locale
|
||||
|
||||
return {
|
||||
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
|
||||
'supported_locales': i18n.SUPPORTED_LOCALES,
|
||||
'locale_names': i18n.LOCALE_NAMES,
|
||||
}
|
||||
|
||||
# Used by layouts/_pagination.html. A global rather than something each
|
||||
# listing passes, because the thing that goes wrong with pagination links
|
||||
# is dropping the rest of the query string — `sort`, `order`, `team_id` —
|
||||
# and that is easier to get right once than in four templates (MNT-14).
|
||||
app.jinja_env.globals['page_url'] = page_url
|
||||
|
||||
# Configure structured logging
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
configure_logging(app)
|
||||
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.evaluations import evaluations_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.matches import matches_bp
|
||||
from app.routes.team_matches import team_matches_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.users import users_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
app.register_blueprint(evaluations_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
app.register_blueprint(team_matches_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
|
||||
# =========================================================================
|
||||
# Security Headers
|
||||
# =========================================================================
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses.
|
||||
|
||||
Implements defense-in-depth with comprehensive HTTP security headers.
|
||||
These complement the headers set by Nginx in production.
|
||||
|
||||
HSTS is only sent in production (non-debug) to avoid breaking
|
||||
local development over plain HTTP.
|
||||
"""
|
||||
# X-XSS-Protection is deliberately not set: the auditor it addressed
|
||||
# has been removed from every current browser, and its last versions
|
||||
# introduced vulnerabilities of their own. CSP frame-ancestors and
|
||||
# X-Frame-Options cover the remaining ground.
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
|
||||
)
|
||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||
response.headers['Content-Security-Policy'] = build_csp(
|
||||
allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'],
|
||||
nonce=g.get('csp_nonce'),
|
||||
)
|
||||
|
||||
# Only enable HSTS when HTTPS is actually being used
|
||||
# (either direct TLS or behind a proxy that terminates TLS)
|
||||
is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
if is_https:
|
||||
response.headers['Strict-Transport-Security'] = (
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS Redirect (Production only)
|
||||
# =========================================================================
|
||||
@app.before_request
|
||||
def force_https():
|
||||
"""Redirect all HTTP requests to HTTPS in production.
|
||||
|
||||
Respects the X-Forwarded-Proto header from reverse proxies.
|
||||
Can be disabled via FORCE_HTTPS environment variable.
|
||||
|
||||
Returns:
|
||||
Response | None: A redirect, or None to let the request through.
|
||||
"""
|
||||
if not app.debug and app.config['FORCE_HTTPS']:
|
||||
already_secure = (
|
||||
request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
)
|
||||
if not already_secure:
|
||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Health Check Endpoint
|
||||
# =========================================================================
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring and load balancers.
|
||||
|
||||
Verifies database connectivity and application health.
|
||||
Returns 200 with basic status info or 503 if unhealthy.
|
||||
|
||||
Returns:
|
||||
Response: JSON health status.
|
||||
"""
|
||||
health_data = {
|
||||
'status': 'healthy',
|
||||
'app': 'team-tryouts',
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
# The bot runs in a daemon thread inside this process. When it dies
|
||||
# the site keeps serving pages and every notification stops, with
|
||||
# nothing to see from outside — which is how it stayed unnoticed.
|
||||
# Reported, not fatal: a club without Discord reminders is degraded,
|
||||
# not down, and a 503 here would take the site out of the load
|
||||
# balancer for it (OPS-012).
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
from app.discord_bot import bot_status
|
||||
|
||||
health_data['discord_bot'] = bot_status()
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
health_data['database'] = 'connected'
|
||||
except Exception:
|
||||
# Never echo the driver error: it routinely carries the host,
|
||||
# database name and user of the connection string, and /health
|
||||
# is unauthenticated.
|
||||
app.logger.error('Health check: database unreachable', exc_info=True)
|
||||
health_data['status'] = 'unhealthy'
|
||||
health_data['database'] = 'error'
|
||||
return jsonify(health_data), 503
|
||||
|
||||
return jsonify(health_data), 200
|
||||
|
||||
# =========================================================================
|
||||
# Custom Error Handlers
|
||||
# =========================================================================
|
||||
#
|
||||
# STD-09. The seven handlers below each carried their own copy of a list
|
||||
# of URL prefixes, and the copies had drifted: three of them checked
|
||||
# `/users/coach-availability`, four did not. Worse, every copy was
|
||||
# missing the same five endpoints — `/matches/api/…` and
|
||||
# `/team-matches/api/…` — so an error on any of those answered a
|
||||
# `fetch()` with an HTML error page. The browser then failed to parse it
|
||||
# as JSON and the page simply did nothing: on the calendar, the tryout
|
||||
# and team selects stayed empty with no message anywhere. A session that
|
||||
# expired mid-page produced exactly that, because the 401 handler
|
||||
# redirects to an HTML login form.
|
||||
#
|
||||
# The mechanism now lives in wants_json_response() / app/api.py, and a
|
||||
# test walks the URL map to prove no jsonify-returning view is missed.
|
||||
#
|
||||
# The handler below is the one that actually mattered. `@login_required`
|
||||
# never reaches the 401 handler: Flask-Login intercepts first and calls
|
||||
# its own unauthorized callback, which redirects. So every one of the
|
||||
# sixteen JSON endpoints answered an expired session with a 302 to an
|
||||
# HTML login form, whatever the prefix list said — and the page's
|
||||
# `fetch()` threw parsing it. Rewriting the prefix list alone would have
|
||||
# left this untouched and looked like a fix.
|
||||
@login_manager.unauthorized_handler
|
||||
def handle_unauthorized():
|
||||
"""What an unauthenticated request gets: a redirect, or a 401 in JSON."""
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Your session has expired.'}), 401
|
||||
flash(_('Please log in to access this page.'), 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
"""Handle 400 Bad Request errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
"""Handle an explicit abort(401).
|
||||
|
||||
Rarely reached: `@login_required` is intercepted by Flask-Login
|
||||
before Flask's error handling, and answered by handle_unauthorized
|
||||
above. This covers code that aborts with 401 itself, and gives the
|
||||
same answer — the two used to differ, and the flash message here was
|
||||
the one string in the application that had never been translated.
|
||||
"""
|
||||
return handle_unauthorized()
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
"""Handle 403 Forbidden errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 Not Found errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@app.errorhandler(429)
|
||||
def too_many_requests(error):
|
||||
"""Handle 429 Too Many Requests errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API or rendered page.
|
||||
"""
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{'error': 'Too many requests', 'message': 'Please try again later.'}
|
||||
), 429
|
||||
return render_template('errors/429.html', error=error), 429
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 Internal Server Error.
|
||||
|
||||
Never exposes stack traces to users. Logs the full error internally.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Generic error page or JSON.
|
||||
"""
|
||||
# Log the full error for debugging
|
||||
app.logger.error('Internal Server Error: %s', str(error), exc_info=True)
|
||||
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
# The id is the only thing that connects a user saying "it broke when
|
||||
# I clicked save" to the stack trace in errors.log. It identifies one
|
||||
# request and nothing else — no session, no account, nothing an
|
||||
# attacker can use — so showing it costs nothing (OBS-005).
|
||||
request_id = g.get('request_id', '-')
|
||||
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.',
|
||||
'request_id': request_id,
|
||||
}
|
||||
), 500
|
||||
return render_template('errors/500.html', request_id=request_id), 500
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
"""Catch-all handler for any unhandled HTTP exceptions.
|
||||
|
||||
Args:
|
||||
error: The HTTPException object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API, re-raises for others.
|
||||
"""
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{'error': error.name, 'message': error.description, 'code': error.code}
|
||||
), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
# Database Initialization
|
||||
# =========================================================================
|
||||
with app.app_context():
|
||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
||||
|
||||
# NOTE: create_all() only ever creates missing tables. It never adds a
|
||||
# column to an existing one, so a model change is silently absent from
|
||||
# any database that already has the table. Replacing this with Alembic
|
||||
# is tracked as DB-002/DB-004; until then the behaviour is preserved.
|
||||
if app.config['AUTO_CREATE_TABLES']:
|
||||
db.create_all()
|
||||
|
||||
# Start the Discord bot for notifications
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
|
||||
start_bot(flask_app=app)
|
||||
except Exception: # the site must come up even if the bot cannot
|
||||
# With the message alone, the two ways this fails — a bad token
|
||||
# and a broken import in discord_bot — read identically, and
|
||||
# neither is diagnosable from one line. Notifications are down
|
||||
# either way, so the traceback is the whole value of the log.
|
||||
# Error, not warning: a club that receives no reminders has lost
|
||||
# a feature, and the old level put that next to the deprecation
|
||||
# notices.
|
||||
app.logger.error(
|
||||
'Could not start the Discord bot. The site is up; no notification '
|
||||
'will be sent until this is fixed.',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# No __main__ block here on purpose. There used to be one, and with run.py
|
||||
# and wsgi.py that made three ways to start the application, each with its
|
||||
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
|
||||
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
|
||||
# module defines the factory; run.py starts it for development, wsgi.py for
|
||||
# production (ARCH-007).
|
||||
+1584
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_babel import Babel
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
# Database and extension initialization
|
||||
db = SQLAlchemy()
|
||||
@@ -12,11 +13,16 @@ login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
csrf = CSRFProtect()
|
||||
|
||||
# Rate limiter for brute-force protection
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
# Internationalisation. French is the primary language of the site; English
|
||||
# stays available. See app/i18n.py for how a locale is chosen.
|
||||
babel = Babel()
|
||||
|
||||
# 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):
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Structured logging configuration for the Team Tryouts application.
|
||||
|
||||
This module configures rotating file handlers for application logs,
|
||||
with separate files for errors, authentication events, and general logs.
|
||||
Sensitive data (passwords, tokens) is automatically filtered out.
|
||||
|
||||
Usage:
|
||||
from logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from app.storage import logs_root
|
||||
|
||||
#: Value used when a record is emitted outside a request — startup, the
|
||||
#: Discord bot thread, the scheduler. Short and obviously not an id, so a
|
||||
#: grep for one never matches it by accident.
|
||||
NO_REQUEST = '-'
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Stamp every record with the id of the request that produced it.
|
||||
|
||||
Without this, a 500 in errors.log and the six lines in app.log that led
|
||||
to it are related only by their timestamps, which is not a relation when
|
||||
the server is handling more than one request at a time (OBS-005).
|
||||
|
||||
The id is generated per request and never read from an inbound header.
|
||||
Accepting one would be convenient for tracing across nginx, and it would
|
||||
also let any caller write arbitrary text — newlines included — into the
|
||||
log file, which is how a log gets forged rather than read. There is no
|
||||
trusted proxy to take it from while OPS-002 is open.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
record.request_id = NO_REQUEST
|
||||
try:
|
||||
from flask import g, has_request_context
|
||||
|
||||
if has_request_context():
|
||||
record.request_id = g.get('request_id', NO_REQUEST)
|
||||
except Exception: # noqa: BLE001 — logging must never be the thing that fails
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""Logging filter that redacts sensitive information from log messages.
|
||||
|
||||
Filters out: passwords, API keys, session tokens, and other secrets
|
||||
that might accidentally be logged.
|
||||
"""
|
||||
|
||||
# Patterns to redact
|
||||
SENSITIVE_PATTERNS = [
|
||||
(
|
||||
re.compile(
|
||||
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
|
||||
),
|
||||
'[REDACTED]',
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
|
||||
),
|
||||
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
|
||||
),
|
||||
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
||||
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
||||
]
|
||||
|
||||
def filter(self, record):
|
||||
"""Apply redaction to the log record's fully rendered message.
|
||||
|
||||
The record is rendered first (msg % args) and the result stored back
|
||||
as msg with args cleared. Redacting record.msg alone would miss almost
|
||||
everything: this codebase logs with %s placeholders, so the sensitive
|
||||
value lives in record.args while record.msg holds only the format
|
||||
string.
|
||||
|
||||
Args:
|
||||
record: The log record to filter.
|
||||
|
||||
Returns:
|
||||
bool: Always True (never drops records, only redacts).
|
||||
"""
|
||||
try:
|
||||
rendered = record.getMessage()
|
||||
except Exception: # noqa: BLE001 — see below; this one cannot log its own failure
|
||||
# A malformed format string must not lose the record entirely.
|
||||
# Nor can it be logged: this runs inside a filter, and logging
|
||||
# from here re-enters the same filter on the new record. The
|
||||
# traceback BLE001 normally asks for is the one thing this
|
||||
# handler must not produce, hence the waiver.
|
||||
return True
|
||||
|
||||
for pattern, replacement in self.SENSITIVE_PATTERNS:
|
||||
rendered = pattern.sub(replacement, rendered)
|
||||
|
||||
record.msg = rendered
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure structured logging for the Flask application.
|
||||
|
||||
Sets up three rotating file handlers:
|
||||
- errors.log: ERROR and CRITICAL level messages
|
||||
- auth.log: Authentication-related events (INFO and above)
|
||||
- app.log: All application logs (DEBUG and above, configurable)
|
||||
|
||||
Also configures console output for development.
|
||||
|
||||
Args:
|
||||
app: The Flask application instance to configure logging for.
|
||||
"""
|
||||
# Anchored on the project, not on the working directory (OBS-006). The
|
||||
# old form put the logs wherever the process happened to be started
|
||||
# from, so a service restarted by hand from another directory quietly
|
||||
# began writing somewhere else — and the file you go looking at when
|
||||
# something is wrong is the one that must not move.
|
||||
log_dir = logs_root()
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Remove default Flask handlers to avoid duplicate logging
|
||||
app.logger.handlers.clear()
|
||||
|
||||
# Set base log level from environment (default: INFO)
|
||||
log_level_name = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
app.logger.setLevel(log_level)
|
||||
|
||||
# Create the sensitive data filter
|
||||
sensitive_filter = SensitiveDataFilter()
|
||||
request_id_filter = RequestIdFilter()
|
||||
|
||||
# Formatter with timestamp, level, module, request id, and message.
|
||||
#
|
||||
# request_id comes from RequestIdFilter, which is attached to every
|
||||
# handler below. A handler that formats with this string and does not
|
||||
# carry the filter raises on its first record — so if one is ever added,
|
||||
# add the filter with it.
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. Error Log Handler
|
||||
# -------------------------------------------------------------------------
|
||||
error_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10,
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
error_handler.addFilter(sensitive_filter)
|
||||
error_handler.addFilter(request_id_filter)
|
||||
app.logger.addHandler(error_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. Authentication Log Handler
|
||||
# -------------------------------------------------------------------------
|
||||
auth_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'auth.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5,
|
||||
)
|
||||
auth_handler.setLevel(logging.INFO)
|
||||
auth_handler.setFormatter(formatter)
|
||||
auth_handler.addFilter(sensitive_filter)
|
||||
auth_handler.addFilter(request_id_filter)
|
||||
|
||||
# Create a named logger specifically for auth events
|
||||
auth_logger = logging.getLogger('team_tryouts.auth')
|
||||
auth_logger.setLevel(logging.INFO)
|
||||
auth_logger.addHandler(auth_handler)
|
||||
auth_logger.propagate = False # Don't double-log to root
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. Application Log Handler (general)
|
||||
# -------------------------------------------------------------------------
|
||||
app_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10,
|
||||
)
|
||||
app_handler.setLevel(log_level)
|
||||
app_handler.setFormatter(formatter)
|
||||
app_handler.addFilter(sensitive_filter)
|
||||
app_handler.addFilter(request_id_filter)
|
||||
app.logger.addHandler(app_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 4. Console Handler (always on)
|
||||
# -------------------------------------------------------------------------
|
||||
# Previously gated on FLASK_DEBUG, which meant production emitted nothing
|
||||
# on stdout — precisely where the Pterodactyl console looks. Keep the
|
||||
# handler unconditional and vary the level instead.
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG if debug_mode else log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(sensitive_filter)
|
||||
console_handler.addFilter(request_id_filter)
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 5. Package logger ('app.*') — notably app.discord_bot
|
||||
# -------------------------------------------------------------------------
|
||||
# Modules using logging.getLogger(__name__) resolve to 'app.<module>'.
|
||||
# Without handlers here their INFO records were dropped entirely and
|
||||
# WARNING+ fell through to Python's lastResort handler, unformatted.
|
||||
package_logger = logging.getLogger('app')
|
||||
package_logger.setLevel(log_level)
|
||||
package_logger.propagate = False
|
||||
for handler in (error_handler, app_handler, console_handler):
|
||||
if handler not in package_logger.handlers:
|
||||
package_logger.addHandler(handler)
|
||||
|
||||
# Log startup information
|
||||
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
||||
app.logger.info('Application startup')
|
||||
|
||||
return app.logger
|
||||
|
||||
|
||||
# Module-level auth logger factory
|
||||
def get_auth_logger():
|
||||
"""Get the authentication event logger.
|
||||
|
||||
Returns:
|
||||
logging.Logger: Logger for authentication events.
|
||||
"""
|
||||
return logging.getLogger('team_tryouts.auth')
|
||||
|
||||
|
||||
def log_auth_event(event, **fields):
|
||||
"""Record a security-relevant event to auth.log.
|
||||
|
||||
The handler, its rotation and its redaction filter were configured from
|
||||
the start, but get_auth_logger was never imported anywhere: auth.log was
|
||||
created and stayed empty. No login, failure, lockout, role change or
|
||||
account deletion left any trace.
|
||||
|
||||
Fields are emitted as `key=value` pairs, ordered, so the file stays
|
||||
greppable without pulling in a JSON logging dependency.
|
||||
|
||||
Note on `ip`: it is taken from request.remote_addr, which reflects
|
||||
X-Forwarded-For. As long as Waitress runs with trusted_proxy='*'
|
||||
(SEC-WEB-002), that value is attacker-controlled and must be read as an
|
||||
indication rather than as evidence.
|
||||
|
||||
Args:
|
||||
event: Dotted event name, e.g. 'login.success'.
|
||||
**fields: Additional context. Never pass a secret: values are
|
||||
recorded verbatim apart from the redaction filter's patterns.
|
||||
"""
|
||||
from flask import has_request_context, request
|
||||
|
||||
parts = [f'event={event}']
|
||||
if has_request_context():
|
||||
parts.append(f'ip={request.remote_addr}')
|
||||
parts.append(f'path={request.path}')
|
||||
parts.extend(f'{key}={value}' for key, value in fields.items())
|
||||
|
||||
get_auth_logger().info(' '.join(parts))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""All models — split into individual files for maintainability.
|
||||
|
||||
Import this module to register all models with SQLAlchemy and expose every
|
||||
class, constant, and helper for use throughout the application.
|
||||
|
||||
Usage::
|
||||
|
||||
from app.models import User, Admin, Evaluation, ESPORT_GAMES, ...
|
||||
|
||||
Backward-compatible — no consumer changes needed.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Layer 0: constants (no app deps)
|
||||
# =========================================================================
|
||||
from app.models._constants import (
|
||||
USER_TYPES,
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
GAME_PLATFORMS,
|
||||
PLATFORM_CODES,
|
||||
TRN_URLS,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Layer 1: loaders & associations
|
||||
# =========================================================================
|
||||
from app.models._loaders import load_user # noqa: F401 — registers Flask-Login callback
|
||||
|
||||
# =========================================================================
|
||||
# Layer 2: abstract base classes
|
||||
# =========================================================================
|
||||
from app.models.availability.base import BaseAvailability
|
||||
from app.models.match_model.base import BaseMatch
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
# =========================================================================
|
||||
# Layer 3: user hierarchy (polymorphic)
|
||||
# =========================================================================
|
||||
from app.models.user_model.user import User
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.player import Player
|
||||
from app.models.user_model.scout import Scout
|
||||
|
||||
# =========================================================================
|
||||
# Layer 4: org_team + junction
|
||||
# =========================================================================
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
# =========================================================================
|
||||
# Layer 5: concrete availability models
|
||||
# =========================================================================
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
from app.models.availability.coach_availability import CoachAvailability
|
||||
|
||||
# =========================================================================
|
||||
# Layer 6: tryout + registration
|
||||
# =========================================================================
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
# =========================================================================
|
||||
# Layer 7: evaluation
|
||||
# =========================================================================
|
||||
from app.models.evaluation import Evaluation
|
||||
|
||||
# =========================================================================
|
||||
# Layer 8: tryout-specific teams
|
||||
# =========================================================================
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
# =========================================================================
|
||||
# Layer 9: matches (tryout-scoped + regular-season)
|
||||
# =========================================================================
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.match_model.team_match import TeamMatch
|
||||
|
||||
# =========================================================================
|
||||
# Layer 10: participants
|
||||
# =========================================================================
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||
|
||||
# =========================================================================
|
||||
# Layer 11: remaining standalone models
|
||||
# =========================================================================
|
||||
from app.models.user_gamertag import UserGamertag
|
||||
from app.models.contract import Contract
|
||||
from app.models.team_note import TeamNote
|
||||
from app.models.personal_note import PersonalNote
|
||||
from app.models.one_on_one_request import OneOnOneRequest
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Many-to-many association tables for OrgTeam ↔ User relationships."""
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
org_team_coaches = db.Table(
|
||||
'org_team_coaches',
|
||||
db.Column(
|
||||
'org_team_id',
|
||||
db.Integer,
|
||||
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
org_team_managers = db.Table(
|
||||
'org_team_managers',
|
||||
db.Column(
|
||||
'org_team_id',
|
||||
db.Integer,
|
||||
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
tryout_coaches = db.Table(
|
||||
'tryout_coaches',
|
||||
db.Column(
|
||||
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Global constants shared by all model files.
|
||||
|
||||
Contains game lists, position mappings, platform codes, and TRN URL templates.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||
|
||||
ESPORT_GAMES = [
|
||||
'Valorant',
|
||||
'League of Legends',
|
||||
'Counter-Strike 2',
|
||||
'Apex Legends',
|
||||
'Overwatch 2',
|
||||
'Rainbow Six Siege',
|
||||
'Rocket League',
|
||||
'Super Smash Bros.',
|
||||
]
|
||||
|
||||
GAME_POSITIONS = {
|
||||
'League of Legends': ['Top Lane', 'Jungle', 'Mid Lane', 'ADC', 'Support'],
|
||||
'Valorant': ['Controller', 'Initiator', 'Duelist', 'Sentinel', 'Flex'],
|
||||
'Counter-Strike 2': ['AWPer', 'Entry Fragger', 'Lurker', 'In-Game Leader', 'Support'],
|
||||
'Rainbow Six Siege': ['Entry', 'Support', 'Breacher', 'Anchor', 'Flex'],
|
||||
'Overwatch 2': ['Tank', 'Damage', 'Support'],
|
||||
'Apex Legends': [],
|
||||
'Rocket League': [],
|
||||
'Super Smash Bros.': [],
|
||||
}
|
||||
|
||||
GAME_PLATFORMS = {
|
||||
'Valorant': [],
|
||||
'League of Legends': [],
|
||||
'Counter-Strike 2': [],
|
||||
'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'],
|
||||
'Overwatch 2': [],
|
||||
'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'],
|
||||
'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'],
|
||||
'Super Smash Bros.': ['Nintendo Switch'],
|
||||
}
|
||||
|
||||
PLATFORM_CODES = {
|
||||
'Ubisoft': 'ubi',
|
||||
'PlayStation': 'psn',
|
||||
'Xbox': 'xbl',
|
||||
'Nintendo Switch': 'switch',
|
||||
'PC': 'pc',
|
||||
'Steam': 'steam',
|
||||
'Epic': 'epic',
|
||||
}
|
||||
|
||||
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
|
||||
|
||||
TRN_URLS = {
|
||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
|
||||
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
|
||||
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
||||
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
||||
'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Flask-Login user loader — registered with login_manager in models.py."""
|
||||
|
||||
from app.extensions import login_manager
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load a user by ID for Flask-Login session management.
|
||||
|
||||
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
|
||||
automatically because SQLAlchemy resolves the identity column.
|
||||
|
||||
Returns None for deactivated accounts so that disabling a user also
|
||||
invalidates the sessions they already hold. Flask-Login only consults
|
||||
is_active when login_user() is called, never when restoring a session
|
||||
from the cookie, so the check has to happen here.
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.user_model.user import User
|
||||
|
||||
try:
|
||||
pk = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
user = db.session.get(User, pk)
|
||||
if user is None or not user.is_active_account:
|
||||
return None
|
||||
return user
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Availability models — BaseAvailability and its concrete subclasses."""
|
||||
|
||||
from app.models.availability.base import BaseAvailability
|
||||
from app.models.availability.coach_availability import CoachAvailability
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
|
||||
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BaseAvailability(db.Model):
|
||||
"""Shared schema for player disponibilities and coach availabilities."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
day_of_week = db.Column(db.Integer, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Coach availability in 30-minute time blocks for One on One sessions."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class CoachAvailability(BaseAvailability):
|
||||
"""Coach availability in 30-minute blocks for One on One sessions."""
|
||||
|
||||
__tablename__ = 'coach_availabilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
coach = db.relationship('User', backref='coach_availabilities')
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Player availability in 30-minute time blocks."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class PlayerDisponibility(BaseAvailability):
|
||||
"""Player availability in 30-minute blocks."""
|
||||
|
||||
__tablename__ = 'player_disponibilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
player = db.relationship('User', backref='disponibilities')
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Contract(db.Model):
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
__tablename__ = 'contracts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
uploaded_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
original_filename = db.Column(db.String(255), nullable=False)
|
||||
stored_filename = db.Column(db.String(255), nullable=False)
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
|
||||
signed_filename = db.Column(db.String(255), nullable=True)
|
||||
signed_file_path = db.Column(db.String(500), nullable=True)
|
||||
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
signed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
||||
team = db.relationship('OrgTeam', foreign_keys=[team_id])
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
|
||||
|
||||
def can_view(self, user):
|
||||
"""Whether this user may read the contract and download its files.
|
||||
|
||||
Two defects used to sit in the coach branch:
|
||||
|
||||
- `not self.team_id` acted as a wildcard, so any coach listed in the
|
||||
legacy OrgTeam.coach_id column could read every contract with no
|
||||
team attached — and upload_contract leaves team_id null whenever
|
||||
the player belongs to no team.
|
||||
- the lookup went through OrgTeam.coach_id only, so a coach attached
|
||||
through the many-to-many relationship saw nothing at all.
|
||||
|
||||
Access now follows the same rule as everywhere else: the coach and
|
||||
the player must actually work together.
|
||||
"""
|
||||
if user.id == self.player_id:
|
||||
return True
|
||||
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.user import User
|
||||
from app.permissions import coach_can_access_player
|
||||
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
player = db.session.get(User, self.player_id)
|
||||
if player and player.get_org_teams():
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
return coach_can_access_player(user, self.player_id)
|
||||
return False
|
||||
|
||||
def can_upload_signed(self, user):
|
||||
return user.id == self.player_id
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Player evaluation record."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Player evaluation record."""
|
||||
|
||||
__tablename__ = 'evaluations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
evaluator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
mecanics_score = db.Column(db.Integer, nullable=True)
|
||||
cohesion_score = db.Column(db.Integer, nullable=True)
|
||||
communication_score = db.Column(db.Integer, nullable=True)
|
||||
gamesense_score = db.Column(db.Integer, nullable=True)
|
||||
versatility_score = db.Column(db.Integer, nullable=True)
|
||||
discipline_score = db.Column(db.Integer, nullable=True)
|
||||
analysis_score = db.Column(db.Integer, nullable=True)
|
||||
sport_ethics_score = db.Column(db.Integer, nullable=True)
|
||||
mental_score = db.Column(db.Integer, nullable=True)
|
||||
overall_score = db.Column(db.Float, nullable=True)
|
||||
comments = db.Column(db.Text, nullable=True)
|
||||
position_recommendation = db.Column(db.String(50), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||
)
|
||||
|
||||
#: The nine criteria, in the order the form shows them. The overall score
|
||||
#: is their mean; a criterion left blank is left out of the mean rather
|
||||
#: than counted as a zero, which is why this list exists rather than the
|
||||
#: route summing nine named variables (ARCH-005, QUA-003).
|
||||
CRITERIA = (
|
||||
'mecanics_score',
|
||||
'cohesion_score',
|
||||
'communication_score',
|
||||
'gamesense_score',
|
||||
'versatility_score',
|
||||
'discipline_score',
|
||||
'analysis_score',
|
||||
'sport_ethics_score',
|
||||
'mental_score',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def overall_from(cls, scores):
|
||||
"""Mean of the criteria that were actually filled in.
|
||||
|
||||
Args:
|
||||
scores: Mapping of criterion name to score or None.
|
||||
|
||||
Returns:
|
||||
float | None: None when nothing was scored — which is not the
|
||||
same as zero, and must not become one. A player nobody could
|
||||
assess has no overall score; a player who scored zero on
|
||||
everything cannot exist, the scale starts at one.
|
||||
"""
|
||||
given = [scores.get(name) for name in cls.CRITERIA]
|
||||
given = [score for score in given if score is not None]
|
||||
if not given:
|
||||
return None
|
||||
return sum(given) / len(given)
|
||||
|
||||
def apply_scores(self, scores):
|
||||
"""Write these criteria onto the record and recompute the overall.
|
||||
|
||||
Every criterion is assigned, including the ones left blank: an edit
|
||||
that clears a score has to clear it, and the mean has to be the mean
|
||||
of what is on the record afterwards.
|
||||
"""
|
||||
for name in self.CRITERIA:
|
||||
setattr(self, name, scores.get(name))
|
||||
self.overall_score = self.overall_from(scores)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Match models — BaseMatch and its concrete subclasses."""
|
||||
|
||||
from app.models.match_model.base import BaseMatch
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.match_model.team_match import TeamMatch
|
||||
|
||||
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BaseMatch(db.Model):
|
||||
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=True)
|
||||
end_time = db.Column(db.Time, nullable=True)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='scheduled')
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Match / scrimmage within a tryout."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class Match(BaseMatch):
|
||||
"""Match / scrimmage within a tryout."""
|
||||
|
||||
__tablename__ = 'matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
match_type = db.Column(db.String(20), nullable=False)
|
||||
team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
|
||||
creator = db.relationship('User', backref='created_matches')
|
||||
tryout = db.relationship('Tryout', backref='matches')
|
||||
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
|
||||
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
|
||||
# delete-orphan: without it, SQLAlchemy tries to detach participants by
|
||||
# setting match_id to NULL, which the NOT NULL column refuses — so
|
||||
# deleting any match that had participants raised IntegrityError.
|
||||
# TeamMatch.participants already declared this; Match did not.
|
||||
participants = db.relationship(
|
||||
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_participating_players(self):
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class TeamMatch(BaseMatch):
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
|
||||
__tablename__ = 'team_matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
opponent = db.Column(db.String(200), nullable=True)
|
||||
|
||||
org_team = db.relationship('OrgTeam', backref='team_matches')
|
||||
creator = db.relationship('User', backref='created_team_matches')
|
||||
participants = db.relationship(
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_confirmed_count(self):
|
||||
all_p = self.participants.all()
|
||||
confirmed = sum(1 for p in all_p if p.is_confirmed)
|
||||
return confirmed, len(all_p)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class OneOnOneRequest(db.Model):
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
__tablename__ = 'one_on_one_requests'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
points = db.Column(db.Text, nullable=True)
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
responded_at = db.Column(db.DateTime, nullable=True)
|
||||
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
||||
coach_rejection_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Organisation team models."""
|
||||
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
__all__ = ['OrgTeam', 'TeamPlayer']
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import org_team_coaches, org_team_managers
|
||||
|
||||
|
||||
class OrgTeam(db.Model):
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
__tablename__ = 'org_teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
coaches = db.relationship(
|
||||
'User',
|
||||
secondary=org_team_coaches,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'),
|
||||
)
|
||||
managers = db.relationship(
|
||||
'User',
|
||||
secondary=org_team_managers,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'),
|
||||
)
|
||||
|
||||
coach = db.relationship(
|
||||
'User',
|
||||
foreign_keys=[coach_id],
|
||||
backref=db.backref('coached_org_team_legacy', uselist=False),
|
||||
viewonly=True,
|
||||
)
|
||||
manager = db.relationship(
|
||||
'User',
|
||||
foreign_keys=[manager_id],
|
||||
backref=db.backref('managed_org_team_legacy', uselist=False),
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
def get_coaches(self):
|
||||
coach_list = self.coaches.all()
|
||||
if not coach_list and self.coach:
|
||||
return [self.coach]
|
||||
return coach_list
|
||||
|
||||
def get_managers(self):
|
||||
manager_list = self.managers.all()
|
||||
if not manager_list and self.manager:
|
||||
return [self.manager]
|
||||
return manager_list
|
||||
|
||||
@property
|
||||
def players(self):
|
||||
return [tp.player for tp in self.team_players]
|
||||
|
||||
def get_players_with_status(self):
|
||||
return [
|
||||
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
||||
for tp in self.team_players
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Many-to-many junction: player to org-team."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TeamPlayer(db.Model):
|
||||
"""Many-to-many: player to org-team."""
|
||||
|
||||
__tablename__ = 'team_players'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
||||
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Participant models — BaseParticipant and its concrete subclasses."""
|
||||
|
||||
from app.models.participant.base import BaseParticipant
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||
|
||||
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Abstract base class for match participant models."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BaseParticipant(db.Model):
|
||||
"""Shared schema for match participants."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Participant in a tryout-scoped match."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class MatchParticipant(BaseParticipant):
|
||||
"""Participant in a tryout-scoped match."""
|
||||
|
||||
__tablename__ = 'match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
||||
team_side = db.Column(db.Integer, nullable=True)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
attendance_confirmed = db.Column(db.Boolean, default=False)
|
||||
|
||||
player = db.relationship('User')
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Participant in a regular-season team match."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class TeamMatchParticipant(BaseParticipant):
|
||||
"""Participant in a regular-season team match."""
|
||||
|
||||
__tablename__ = 'team_match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
|
||||
is_confirmed = db.Column(db.Boolean, default=False)
|
||||
|
||||
player = db.relationship('User')
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class PersonalNote(db.Model):
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
__tablename__ = 'personal_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='personal_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
match = db.relationship('Match', foreign_keys=[match_id])
|
||||
team = db.relationship('Team', foreign_keys=[team_id])
|
||||
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Tryout-specific temporary team models."""
|
||||
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
__all__ = ['Team', 'TeamMember']
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
__tablename__ = 'teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = db.relationship('User', backref='created_teams')
|
||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TeamMember(db.Model):
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
__tablename__ = 'team_members'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TeamNote(db.Model):
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
__tablename__ = 'team_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
team = db.relationship('OrgTeam', backref='team_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Tryout models."""
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
__all__ = ['Tryout', 'TryoutRegistration']
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import tryout_coaches
|
||||
|
||||
|
||||
class Tryout(db.Model):
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
__tablename__ = 'tryouts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
game = db.Column(db.String(50), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='upcoming')
|
||||
max_players = db.Column(db.Integer, nullable=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
coach_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
target_org_team = db.relationship(
|
||||
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_ended(self):
|
||||
"""Tryout is considered ended after its end_date passes.
|
||||
Falls back to date if end_date is not set."""
|
||||
from datetime import date as date_type
|
||||
|
||||
today = date_type.today()
|
||||
if self.end_date is not None:
|
||||
return self.end_date < today
|
||||
return self.date < today
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TryoutRegistration(db.Model):
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
__tablename__ = 'tryout_registrations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
status = db.Column(db.String(20), default='registered')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Store gamertag per game for each user."""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._constants import PLATFORM_CODES, PLATFORM_DEFAULTS, TRN_URLS
|
||||
|
||||
|
||||
class UserGamertag(db.Model):
|
||||
"""Store gamertag per game for each user."""
|
||||
|
||||
__tablename__ = 'user_gamertags'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
game = db.Column(db.String(50), nullable=False)
|
||||
gamertag = db.Column(db.String(120), nullable=False)
|
||||
platform = db.Column(db.String(30), nullable=True)
|
||||
|
||||
user = db.relationship('User', backref='gamertags')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
|
||||
|
||||
def get_trn_url(self):
|
||||
if self.game not in TRN_URLS:
|
||||
return None
|
||||
url = TRN_URLS[self.game]
|
||||
encoded_gamertag = quote(self.gamertag, safe='')
|
||||
# Resolve platform: use user's selection, or fall back to game default
|
||||
platform = self.platform
|
||||
if not platform:
|
||||
platform = PLATFORM_DEFAULTS.get(self.game, '')
|
||||
if '{platform_code}' in url and '{username}' in url:
|
||||
platform_code = PLATFORM_CODES.get(
|
||||
platform,
|
||||
platform.lower().replace(' ', '-') if platform else '',
|
||||
)
|
||||
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
||||
if '{platform}' in url and '{username}' in url:
|
||||
return url.format(
|
||||
platform=platform.lower().replace(' ', '-') if platform else '',
|
||||
username=encoded_gamertag,
|
||||
)
|
||||
if '{username}' in url:
|
||||
return url.format(username=encoded_gamertag)
|
||||
return url
|
||||
@@ -0,0 +1,10 @@
|
||||
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
||||
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.player import Player
|
||||
from app.models.user_model.scout import Scout
|
||||
from app.models.user_model.user import User
|
||||
|
||||
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Admin / President — full access to everything."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Admin(User):
|
||||
"""President / super-admin — full access to everything."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'admin'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def can_manage_users(self):
|
||||
return True
|
||||
|
||||
def can_manage_teams(self):
|
||||
return True
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return True
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return True
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Coach(User):
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams.
|
||||
|
||||
Every question about *which* teams or tryouts belong to this coach is
|
||||
delegated to ``app.permissions``. Two of the three methods below used to
|
||||
answer it themselves, each considering a different subset of the two
|
||||
ways a coach can be attached to a team: ``can_manage_this_tryout``
|
||||
ignored the legacy ``coach_id`` column when checking the target team,
|
||||
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
|
||||
by that column therefore saw an empty calendar (ARCH-002).
|
||||
"""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return True
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
from app.permissions import coach_manages_tryout
|
||||
|
||||
return coach_manages_tryout(self, tryout)
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
if org_team.coaches.filter_by(id=self.id).first():
|
||||
return True
|
||||
return org_team.coach_id == self.id
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.permissions import coach_tryouts
|
||||
|
||||
return coach_tryouts(self)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Manager(User):
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'manager'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def can_manage_teams(self):
|
||||
return True
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return True
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return (
|
||||
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Player(User):
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'player'}
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
# tryouts they registered for
|
||||
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
||||
tryouts = (
|
||||
Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
|
||||
if player_tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
# plus tryouts where they participate in a match
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == self.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
extra_ids = {m.tryout_id for m in player_matches}
|
||||
extra = (
|
||||
Tryout.query.filter(
|
||||
Tryout.id.in_(extra_ids),
|
||||
)
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
if extra_ids
|
||||
else []
|
||||
)
|
||||
|
||||
all_ids = {t.id for t in tryouts}
|
||||
return tryouts + [t for t in extra if t.id not in all_ids]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Scout(User):
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'scout'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Base User model — shared fields and polymorphic configuration."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_login import UserMixin
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""Base user model — shared fields for every role.
|
||||
|
||||
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
||||
or Scout so that `polymorphic_identity` is set correctly.
|
||||
"""
|
||||
|
||||
__tablename__ = 'users'
|
||||
|
||||
# --- columns -----------------------------------------------------------
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default='player') # polymorphic discriminator
|
||||
full_name = db.Column(db.String(100), nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
is_active_account = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# E-Sports fields
|
||||
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
|
||||
discord_username = db.Column(db.String(128), nullable=True)
|
||||
discord_user_id = db.Column(db.String(64), nullable=True)
|
||||
league_os_profile = db.Column(db.String(256), nullable=True)
|
||||
|
||||
# --- polymorphic configuration -----------------------------------------
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'user',
|
||||
'polymorphic_on': role,
|
||||
}
|
||||
|
||||
# --- relationships (defined once on the base) --------------------------
|
||||
evaluations_given = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
|
||||
)
|
||||
evaluations_received = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
|
||||
)
|
||||
tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
|
||||
team_assignments = db.relationship(
|
||||
'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
|
||||
)
|
||||
|
||||
# --- Flask-Login integration -------------------------------------------
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Whether Flask-Login should accept this account.
|
||||
|
||||
UserMixin returns True unconditionally, which meant a deactivated
|
||||
account kept any session it already held. Binding this to
|
||||
is_active_account makes deactivation take effect on the next request.
|
||||
"""
|
||||
return bool(self.is_active_account)
|
||||
|
||||
# --- shared helper methods ---------------------------------------------
|
||||
def get_games_list(self):
|
||||
"""Return the user's games as a list."""
|
||||
if self.games:
|
||||
return [g.strip() for g in self.games.split(',') if g.strip()]
|
||||
return []
|
||||
|
||||
def get_gamertags(self):
|
||||
"""Return gamertags as a dict keyed by game."""
|
||||
return {
|
||||
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
|
||||
}
|
||||
|
||||
def get_org_teams(self):
|
||||
"""Return all OrgTeams this player belongs to."""
|
||||
return [tp.org_team for tp in self.team_placements]
|
||||
|
||||
# --- stubs (overridden in subclasses) ----------------------------------
|
||||
def can_evaluate(self):
|
||||
return False
|
||||
|
||||
def can_manage_users(self):
|
||||
return False
|
||||
|
||||
def can_manage_teams(self):
|
||||
return False
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return False
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return False
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return False
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return False
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
return []
|
||||
+47
-11
@@ -110,7 +110,8 @@ http {
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
# X-XSS-Protection intentionally omitted: deprecated, removed from
|
||||
# current browsers, and harmful in its last implementations.
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
@@ -118,8 +119,17 @@ http {
|
||||
# ---------------------------------------------------------------------
|
||||
# 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 / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_pass http://127.0.0.1:10000;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host $host;
|
||||
@@ -142,15 +152,41 @@ http {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Static Files (served directly by Nginx for performance)
|
||||
# Uncomment and adjust path if you want Nginx to serve static files
|
||||
# Static Files (PERF-006)
|
||||
#
|
||||
# 59 KB of CSS and JS on every page load, previously proxied through
|
||||
# Waitress. Nginx serves them from disk instead.
|
||||
#
|
||||
# ADJUST THIS ONE PATH to the deployment's checkout, absolute, forward
|
||||
# slashes even on Windows. Nginx resolves a relative path against its
|
||||
# own install prefix, not against this file. The trailing slash on both
|
||||
# the location and the alias is required: without it /static/css/x.css
|
||||
# resolves one directory too high.
|
||||
#
|
||||
# `immutable` is safe here and only here: url_for('static', …) appends
|
||||
# ?v=<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/ {
|
||||
# alias C:/path/to/team-tryouts/static/;
|
||||
# expires 30d;
|
||||
# add_header Cache-Control "public, immutable";
|
||||
# access_log off;
|
||||
# }
|
||||
location /static/ {
|
||||
alias C:/team-tryouts/app/static/;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
|
||||
# These three are repeated on purpose. In nginx, add_header is
|
||||
# inherited from the enclosing block ONLY when the current block
|
||||
# declares none of its own — one add_header here silently drops
|
||||
# every security header set at server level. Dropping nosniff on
|
||||
# the JavaScript is the one that matters.
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
# A missing static file must 404, not fall through to Flask: the
|
||||
# fallthrough would hide a broken deploy behind a working page.
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
@@ -161,7 +197,7 @@ http {
|
||||
|
||||
# location /auth/login {
|
||||
# limit_req zone=login burst=5 nodelay;
|
||||
# proxy_pass http://127.0.0.1:5000;
|
||||
# proxy_pass http://127.0.0.1:10000;
|
||||
# }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,710 @@
|
||||
"""Authentication routes for user login, logout, and registration.
|
||||
|
||||
This module handles user authentication including login with account lockout
|
||||
protection, logout with session clearing, and new user registration with
|
||||
password policy enforcement and sign-up screening.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import check_password, db, hash_password, limiter
|
||||
from app.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.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
||||
|
||||
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||
DISCORD_STATE_KEY = 'discord_oauth_state'
|
||||
#: Whether the OAuth result should create a registration draft or relink the
|
||||
#: signed-in account. Kept server-side and covered by the same signed session
|
||||
#: as the anti-forgery state.
|
||||
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
|
||||
|
||||
# Failed-attempt tracking. The tally is kept for the audit trail and for the
|
||||
# cool-off marker below; it no longer refuses a correct password (SEC-018).
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
#: Ceiling on the doubling cool-off window.
|
||||
MAX_LOCKOUT_MINUTES = 240
|
||||
|
||||
#: Hash of a value nobody can submit. Verifying against it when the username
|
||||
#: is unknown makes that path cost the same scrypt work as a real one, so the
|
||||
#: response time stops telling a caller which usernames exist (SEC-017).
|
||||
_ABSENT_USER_HASH = None
|
||||
|
||||
#: Session key recording when the registration form was handed out.
|
||||
REGISTRATION_ISSUED_KEY = 'registration_form_issued_at'
|
||||
|
||||
#: Name of the honeypot input. Plausible enough that a form-filler wants it,
|
||||
#: absent from the visible form. Hidden by .honeypot in style.css — not by an
|
||||
#: inline style, so that the rule survives a tightening of style-src.
|
||||
REGISTRATION_HONEYPOT_FIELD = 'website'
|
||||
|
||||
#: Floor on how long a genuine registration takes. Eleven fields and a
|
||||
#: password typed twice; three seconds is generous.
|
||||
MIN_REGISTRATION_SECONDS = 3
|
||||
|
||||
# Discord OAuth2 configuration
|
||||
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
|
||||
DISCORD_CLIENT_SECRET = os.getenv('DISCORD_CLIENT_SECRET')
|
||||
DISCORD_REDIRECT_URI = os.getenv('DISCORD_REDIRECT_URI')
|
||||
DISCORD_API_BASE = 'https://discord.com/api/v10'
|
||||
|
||||
# Mapping from Discord connection platform to E-Sports games
|
||||
DISCORD_PLATFORM_TO_GAMES = {
|
||||
'steam': ['Counter-Strike 2'],
|
||||
'battlenet': ['Overwatch 2'],
|
||||
'epicgames': ['Rocket League'],
|
||||
'xbox': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'],
|
||||
'playstation': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'],
|
||||
}
|
||||
|
||||
|
||||
def is_safe_url(url):
|
||||
"""Validate that a URL is safe for redirection (same origin).
|
||||
|
||||
Accepts an absolute URL on this host, or a path beginning with exactly
|
||||
one slash. Everything else is refused, including the two forms that
|
||||
read differently to urlparse and to a browser:
|
||||
|
||||
/\\evil.com several browsers normalise the backslash to a slash,
|
||||
turning this into the protocol-relative //evil.com.
|
||||
urlparse reports no netloc at all, so the old check
|
||||
let it through and the redirect left the site.
|
||||
/\\n//evil.com control characters are stripped before parsing.
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the URL is safe.
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
|
||||
return False
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.netloc:
|
||||
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
|
||||
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
|
||||
# not paths on this site.
|
||||
return url.startswith('/')
|
||||
|
||||
|
||||
def _absent_user_hash():
|
||||
"""A hash to verify against when the submitted username does not exist.
|
||||
|
||||
check_password() used to be reached only when a user row was found, so
|
||||
an unknown username answered as fast as the database lookup, and a known
|
||||
one as slowly as scrypt. The gap is measurable and enumerates accounts.
|
||||
Computed once per process, from a random secret, so no submitted password
|
||||
can ever match it.
|
||||
"""
|
||||
global _ABSENT_USER_HASH
|
||||
if _ABSENT_USER_HASH is None:
|
||||
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
|
||||
return _ABSENT_USER_HASH
|
||||
|
||||
|
||||
def cooloff_minutes(failed_attempts):
|
||||
"""Length of the cool-off window earned by this many failed attempts.
|
||||
|
||||
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
|
||||
|
||||
Args:
|
||||
failed_attempts: Consecutive failures recorded on the account.
|
||||
|
||||
Returns:
|
||||
int: Minutes.
|
||||
"""
|
||||
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
|
||||
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
|
||||
|
||||
|
||||
def issue_registration_challenge():
|
||||
"""Mark that the registration form has been handed out, and when.
|
||||
|
||||
Kept in the signed session rather than in a form field, so that the
|
||||
timestamp is not something the submitter can choose. Left in place across
|
||||
a failed submission: someone correcting a typo should not be told to slow
|
||||
down, and a robot has already paid for the round trip by then.
|
||||
"""
|
||||
session.setdefault(REGISTRATION_ISSUED_KEY, time.time())
|
||||
|
||||
|
||||
def check_registration_challenge(form):
|
||||
"""Say why this registration should be refused, or None to accept.
|
||||
|
||||
What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008).
|
||||
|
||||
`a + b = ?` with both operands between 1 and 10 has nineteen possible
|
||||
answers and is solvable by reading the string. It stopped no automated
|
||||
registration whatsoever. What it did do was add a step for every human,
|
||||
including anyone using a screen reader, in exchange for an appearance of
|
||||
protection — which is worse than no protection, because it gets counted
|
||||
as one.
|
||||
|
||||
The audit's alternative was a real CAPTCHA service. That means a third
|
||||
party, an API key, a request on every page load, and putting a foreign
|
||||
script back into script-src — undoing the CSP work that closed
|
||||
SEC-WEB-001. Disproportionate for a club site.
|
||||
|
||||
So: two checks that cost the visitor nothing.
|
||||
|
||||
- a honeypot field, hidden in the stylesheet, that a form-filling
|
||||
robot completes and a person never sees;
|
||||
- a minimum dwell time between being handed the form and sending it
|
||||
back. Eleven fields and a password typed twice do not get filled in
|
||||
under three seconds, and a POST with no issued form at all never
|
||||
fetched the page.
|
||||
|
||||
Be clear about the ceiling: this stops commodity spam, not somebody who
|
||||
looks at the form for five minutes. The thing that would actually gate
|
||||
registration is staff activation of new accounts, which does not exist —
|
||||
`is_active_account` defaults to True. That is a product decision, not one
|
||||
to slip in here.
|
||||
|
||||
The session-forgery angle in the constat is moot: with SECRET_KEY
|
||||
compromised (SEC-001) an attacker forges a logged-in session for any
|
||||
account and has no reason to register at all.
|
||||
|
||||
Returns:
|
||||
str | None: a short reason for the log, or None to let it through.
|
||||
"""
|
||||
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
|
||||
return 'honeypot'
|
||||
|
||||
issued_at = session.get(REGISTRATION_ISSUED_KEY)
|
||||
if issued_at is None:
|
||||
return 'no-form-issued'
|
||||
if time.time() - issued_at < MIN_REGISTRATION_SECONDS:
|
||||
return 'too-fast'
|
||||
return None
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
"""Handle user login authentication.
|
||||
|
||||
GET: Render the login form.
|
||||
POST: Authenticate user credentials, with audit logging.
|
||||
|
||||
Failed attempts are counted and open a cool-off window, recorded in
|
||||
``locked_until`` and in the authentication log. The window does not
|
||||
refuse correct credentials: when it did, five wrong guesses against a
|
||||
known username took that account out of service for fifteen minutes,
|
||||
repeatably, and on a president's account that meant no administration
|
||||
at all. Guess rate is bounded by the rate limit on this view.
|
||||
|
||||
Returns:
|
||||
Response: Login form or redirect to dashboard/next page.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Validate input with marshmallow schema
|
||||
login_schema = LoginSchema()
|
||||
try:
|
||||
validated = login_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
username = validated['username']
|
||||
password = validated['password']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Verified before anything else is decided, and on both branches.
|
||||
# Reaching this only when a row exists made the response time a
|
||||
# reliable oracle for which usernames are registered (SEC-017).
|
||||
credentials_ok = check_password(
|
||||
user.password_hash if user else _absent_user_hash(), password
|
||||
)
|
||||
|
||||
if user and credentials_ok:
|
||||
if not user.is_active_account:
|
||||
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
|
||||
flash(_('This account has been deactivated.'), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
# Correct credentials clear the tally, cool-off window included.
|
||||
# The window used to refuse them too, which is what turned it
|
||||
# into a way to lock a known account out at will (SEC-018).
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.session.commit()
|
||||
|
||||
# Clear old session data and preserve CSRF token to prevent
|
||||
# session fixation attacks (Flask-Login rotates the session ID).
|
||||
#
|
||||
# The language choice is carried across too. Someone who reads the
|
||||
# login page in English and signs in would otherwise be dropped
|
||||
# back into French — the preference lives in the session, and
|
||||
# clearing it discards a decision the user just made.
|
||||
_preserved = {
|
||||
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
|
||||
}
|
||||
session.clear()
|
||||
session.update(_preserved)
|
||||
|
||||
# Mark the session permanent so PERMANENT_SESSION_LIFETIME applies.
|
||||
# Without this, Flask emits a browser-session cookie with no expiry
|
||||
# and the configured lifetime is silently ignored.
|
||||
session.permanent = True
|
||||
|
||||
login_user(user)
|
||||
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
|
||||
|
||||
# Validate redirect URL to prevent open redirect vulnerability
|
||||
next_page = request.args.get('next')
|
||||
if next_page and not is_safe_url(next_page):
|
||||
next_page = None
|
||||
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||
# One message for every failure. The old code said "N attempts
|
||||
# remaining" to a real account and "check username and password"
|
||||
# to an unknown one, which listed the club's accounts to anyone
|
||||
# who asked (SEC-017).
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
log_auth_event(
|
||||
'login.failure',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
log_auth_event(
|
||||
'account.throttled',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
minutes=minutes,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
log_auth_event('login.failure.unknown_user', username=username)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'Login unsuccessful. Please check your username and '
|
||||
'password, or ask a president for help.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
|
||||
return render_template('pages/login.html')
|
||||
|
||||
|
||||
def _rerender_registration(form_data):
|
||||
"""Re-render the registration form after a refusal.
|
||||
|
||||
Was copied out four times, near-identically (ARCH-005). Dropping the two
|
||||
password fields is the part that must not be forgotten in the fifth copy:
|
||||
echoing a password back into the HTML puts it in the browser's cache and
|
||||
in any proxy log along the way.
|
||||
"""
|
||||
form_data = dict(form_data)
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
honeypot_field=REGISTRATION_HONEYPOT_FIELD,
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit("20 per hour")
|
||||
def register():
|
||||
"""Handle new player registration.
|
||||
|
||||
GET: Render the registration form with the E-Sports games list.
|
||||
POST: Screen the submission (see check_registration_challenge), validate
|
||||
every input against RegisterSchema, and create a new player account.
|
||||
|
||||
Only players can register through this form. Validates username/email
|
||||
uniqueness and password confirmation.
|
||||
|
||||
Returns:
|
||||
Response: Registration form or redirect to login.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Build form data from request to preserve state across re-renders
|
||||
form_data = dict(request.form)
|
||||
form_data['games'] = request.form.getlist('games')
|
||||
|
||||
# Once Discord has authenticated the identity, neither its display
|
||||
# name nor its snowflake is input data anymore. Remove any client
|
||||
# copies before validation as well as before persistence: otherwise a
|
||||
# forged, malformed hidden value can still make the verified flow fail.
|
||||
discord_oauth = session.get('discord_oauth') or {}
|
||||
if discord_oauth.get('id'):
|
||||
form_data.pop('discord_username', None)
|
||||
form_data.pop('discord_user_id', None)
|
||||
|
||||
refusal = check_registration_challenge(request.form)
|
||||
if refusal is not None:
|
||||
# Logged, because this is the only place abuse of the sign-up
|
||||
# form becomes visible at all. Deliberately vague to the sender:
|
||||
# naming the honeypot tells whoever tripped it how to avoid it.
|
||||
log_auth_event('account.registration_refused', reason=refusal)
|
||||
flash(_('Your registration could not be processed. Please try again.'), 'danger')
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
# Validate input with marshmallow schema
|
||||
register_schema = RegisterSchema()
|
||||
try:
|
||||
validated = register_schema.load(form_data)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
username = validated['username']
|
||||
email = validated['email']
|
||||
password = validated['password']
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
try:
|
||||
submitted_gamertags = form_gamertags(selected_games)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
# The OAuth identity is server-side state. It used to be copied into
|
||||
# hidden inputs and read back from request.form, which let anyone
|
||||
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||
# A manual registration may still provide a display name, but never a
|
||||
# Discord snowflake: that identifier is an authentication factor for
|
||||
# bot reactions and must come from Discord itself.
|
||||
discord_user_id = discord_oauth.get('id')
|
||||
if discord_user_id:
|
||||
discord_user_id = str(discord_user_id)
|
||||
discord_username = (
|
||||
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
|
||||
)
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash(_('Email already registered.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
# The database constraint belongs to DB-002, after production has
|
||||
# been backed up and deduplicated. Refuse new duplicates now instead
|
||||
# of leaving the critical impersonation path open until then.
|
||||
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user = Player(
|
||||
username=username,
|
||||
password_hash=hashed_password,
|
||||
role='player',
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
games=','.join(selected_games) if selected_games else None,
|
||||
discord_username=discord_username,
|
||||
discord_user_id=discord_user_id,
|
||||
league_os_profile=league_os_profile,
|
||||
)
|
||||
db.session.add(user)
|
||||
# flush, not commit: the id is needed for the gamertag rows below,
|
||||
# and signing up is one operation. Committing here made it two, so a
|
||||
# failure while writing the gamertags left an account whose declared
|
||||
# games were silently absent (ARCH-006).
|
||||
db.session.flush()
|
||||
|
||||
# Create UserGamertag records for each selected game
|
||||
from app.models import UserGamertag
|
||||
|
||||
for game, gamertag_data in submitted_gamertags.items():
|
||||
gamertag = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=gamertag_data['gamertag'],
|
||||
platform=gamertag_data['platform'],
|
||||
)
|
||||
db.session.add(gamertag)
|
||||
db.session.commit()
|
||||
|
||||
# Clear Discord OAuth data from session after successful registration
|
||||
session.pop('discord_oauth', None)
|
||||
session.pop(REGISTRATION_ISSUED_KEY, None)
|
||||
|
||||
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||
|
||||
flash(_('Your account has been created! You can now log in.'), 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# GET request — render empty form
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration({})
|
||||
|
||||
|
||||
@auth_bp.route('/discord/login')
|
||||
def discord_login():
|
||||
"""Redirect the user to Discord's OAuth2 authorization page.
|
||||
|
||||
Requests the 'identify' and 'connections' scopes so we can retrieve
|
||||
the user's Discord username, ID, and linked gaming accounts.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to Discord authorization URL.
|
||||
"""
|
||||
purpose = 'profile' if current_user.is_authenticated else 'registration'
|
||||
session[DISCORD_PURPOSE_KEY] = purpose
|
||||
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
|
||||
|
||||
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||
# raise inside the query builder rather than report a configuration error.
|
||||
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||
# attacker could have the victim's browser consume an authorization code
|
||||
# obtained for the attacker's own Discord account, silently binding that
|
||||
# identity to the victim's registration form.
|
||||
state = secrets.token_urlsafe(32)
|
||||
session[DISCORD_STATE_KEY] = state
|
||||
|
||||
params = {
|
||||
'client_id': DISCORD_CLIENT_ID,
|
||||
'redirect_uri': DISCORD_REDIRECT_URI,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify connections',
|
||||
'state': state,
|
||||
}
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
@auth_bp.route('/discord/callback')
|
||||
def discord_callback():
|
||||
"""Handle the OAuth2 callback from Discord.
|
||||
|
||||
Exchanges the authorization code for an access token, then fetches the
|
||||
user's profile. During registration, connected game accounts are also
|
||||
loaded into server-side draft state. For a signed-in profile relink, the
|
||||
verified identity is written directly without passing through a form.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to the registration form or profile editor.
|
||||
"""
|
||||
# The state is consumed whatever happens next: a token is single-use, and
|
||||
# leaving it in the session would allow a replay.
|
||||
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
|
||||
if purpose == 'profile' and current_user.is_authenticated:
|
||||
return_endpoint = 'users.edit_profile'
|
||||
elif purpose == 'profile':
|
||||
return_endpoint = 'auth.login'
|
||||
else:
|
||||
return_endpoint = 'auth.register'
|
||||
|
||||
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||
received_state = request.args.get('state', '')
|
||||
|
||||
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||
flash(
|
||||
_(
|
||||
'Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Exchange the authorization code for an access token
|
||||
token_data = {
|
||||
'client_id': DISCORD_CLIENT_ID,
|
||||
'client_secret': DISCORD_CLIENT_SECRET,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': DISCORD_REDIRECT_URI,
|
||||
}
|
||||
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
|
||||
try:
|
||||
token_response = requests.post(
|
||||
f'{DISCORD_API_BASE}/oauth2/token',
|
||||
data=token_data,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
token_json = token_response.json()
|
||||
access_token = token_json.get('access_token')
|
||||
except requests.RequestException:
|
||||
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
if not access_token:
|
||||
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
# Fetch the user's Discord profile
|
||||
try:
|
||||
user_response = requests.get(
|
||||
f'{DISCORD_API_BASE}/users/@me',
|
||||
headers=auth_headers,
|
||||
timeout=10,
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
user_data = user_response.json()
|
||||
except requests.RequestException:
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
discord_user_id = user_data.get('id')
|
||||
try:
|
||||
if not discord_user_id:
|
||||
raise ValidationError('missing Discord user id')
|
||||
discord_user_id = str(discord_user_id)
|
||||
validate_discord_user_id(discord_user_id)
|
||||
except ValidationError:
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
if purpose == 'profile':
|
||||
# If the session expired while Discord was open, do not turn a profile
|
||||
# relink into registration state for an anonymous browser.
|
||||
if not current_user.is_authenticated:
|
||||
flash(_('Please log in to connect your Discord account.'), 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
clash = User.query.filter(
|
||||
User.discord_user_id == discord_user_id,
|
||||
User.id != current_user.id,
|
||||
).first()
|
||||
if clash:
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return redirect(url_for('users.edit_profile'))
|
||||
|
||||
current_user.discord_user_id = discord_user_id
|
||||
current_user.discord_username = user_data.get('username') or None
|
||||
db.session.commit()
|
||||
log_auth_event(
|
||||
'account.discord_linked',
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
flash(_('Discord account connected!'), 'success')
|
||||
return redirect(url_for('users.edit_profile'))
|
||||
|
||||
# Fetch the user's connected gaming accounts
|
||||
connections = []
|
||||
try:
|
||||
conn_response = requests.get(
|
||||
f'{DISCORD_API_BASE}/users/@me/connections',
|
||||
headers=auth_headers,
|
||||
timeout=10,
|
||||
)
|
||||
conn_response.raise_for_status()
|
||||
connections = conn_response.json()
|
||||
except requests.RequestException:
|
||||
# Non-critical: we can still proceed without connections
|
||||
pass
|
||||
|
||||
# Build gamertag suggestions from Discord connections
|
||||
gamertag_suggestions = {}
|
||||
for conn in connections:
|
||||
platform = conn.get('type', '')
|
||||
name = conn.get('name', '').strip()
|
||||
if not name or platform not in DISCORD_PLATFORM_TO_GAMES:
|
||||
continue
|
||||
for game in DISCORD_PLATFORM_TO_GAMES[platform]:
|
||||
# Only set if not already set (first connection wins)
|
||||
if game not in gamertag_suggestions:
|
||||
gamertag_suggestions[game] = name
|
||||
|
||||
# Build a list of games to auto-select (unambiguous platform mappings)
|
||||
auto_select_games = []
|
||||
for conn in connections:
|
||||
platform = conn.get('type', '')
|
||||
if platform in ('steam', 'battlenet', 'epicgames'):
|
||||
for game in DISCORD_PLATFORM_TO_GAMES[platform]:
|
||||
if game not in auto_select_games:
|
||||
auto_select_games.append(game)
|
||||
|
||||
# Store in session for the registration form to use
|
||||
session['discord_oauth'] = {
|
||||
'id': discord_user_id,
|
||||
'username': user_data.get('username'),
|
||||
'avatar': user_data.get('avatar'),
|
||||
'gamertag_suggestions': gamertag_suggestions,
|
||||
'auto_select_games': auto_select_games,
|
||||
}
|
||||
|
||||
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
@login_required
|
||||
def logout():
|
||||
"""Log out the current user and clear the session.
|
||||
|
||||
POST, not GET: a GET route is not covered by CSRF protection, so any
|
||||
page on the internet could sign a user out with an <img> tag pointing
|
||||
here. A nuisance rather than a compromise, but it costs one form to
|
||||
close (SEC-019).
|
||||
|
||||
Clears the user session and regenerates session ID to prevent
|
||||
session fixation/replay after logout.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login page with logout message.
|
||||
"""
|
||||
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
|
||||
logout_user()
|
||||
# Same reasoning as at login: the language is a display preference, not
|
||||
# session state belonging to the account being signed out.
|
||||
_locale = session.get(LOCALE_SESSION_KEY)
|
||||
session.clear()
|
||||
if _locale:
|
||||
session[LOCALE_SESSION_KEY] = _locale
|
||||
flash(_('You have been logged out.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Evaluation routes for assessing player performance during tryouts.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Evaluation,
|
||||
Player,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.validators import EvaluationSchema
|
||||
|
||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load a set of users once for aggregate/list views."""
|
||||
wanted = {user_id for user_id in user_ids if user_id}
|
||||
if not wanted:
|
||||
return {}
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
@evaluations_bp.route('')
|
||||
@login_required
|
||||
def list_evaluations():
|
||||
"""List all evaluations accessible to the current user."""
|
||||
user = current_user
|
||||
|
||||
if isinstance(user, Player):
|
||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
sort_column = request.args.get('sort', 'created_at')
|
||||
sort_order = request.args.get('order', 'desc')
|
||||
if sort_order not in ('asc', 'desc'):
|
||||
sort_order = 'desc'
|
||||
|
||||
player_alias = aliased(User, name='eval_player')
|
||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
||||
|
||||
sort_map = {
|
||||
'tryout': Tryout.title,
|
||||
'player': player_alias.username,
|
||||
'evaluator': evaluator_alias.username,
|
||||
'mecanics_score': Evaluation.mecanics_score,
|
||||
'cohesion_score': Evaluation.cohesion_score,
|
||||
'communication_score': Evaluation.communication_score,
|
||||
'gamesense_score': Evaluation.gamesense_score,
|
||||
'versatility_score': Evaluation.versatility_score,
|
||||
'discipline_score': Evaluation.discipline_score,
|
||||
'analysis_score': Evaluation.analysis_score,
|
||||
'sport_ethics_score': Evaluation.sport_ethics_score,
|
||||
'mental_score': Evaluation.mental_score,
|
||||
'overall_score': Evaluation.overall_score,
|
||||
'position_recommendation': Evaluation.position_recommendation,
|
||||
'created_at': Evaluation.created_at,
|
||||
}
|
||||
|
||||
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
|
||||
if sort_order == 'asc':
|
||||
sort_expr = sort_expr.asc()
|
||||
else:
|
||||
sort_expr = sort_expr.desc()
|
||||
|
||||
if isinstance(user, Admin):
|
||||
evaluations_page = paginate(
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.order_by(sort_expr, Evaluation.id)
|
||||
)
|
||||
avg_scores = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = players_by_id.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p,
|
||||
'count': row.eval_count,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
||||
}
|
||||
else:
|
||||
# Everyone still here evaluates: players were redirected above, and
|
||||
# can_evaluate() is true for the four remaining roles. The former
|
||||
# `else` branch listed evaluations *received* — a player's view,
|
||||
# unreachable from this point (ARCH-007).
|
||||
evaluations_page = paginate(
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.filter(Evaluation.evaluator_id == user.id)
|
||||
.order_by(sort_expr, Evaluation.id)
|
||||
)
|
||||
player_scores = {}
|
||||
|
||||
return render_template(
|
||||
'pages/evaluations.html',
|
||||
evaluations=evaluations_page.items,
|
||||
pagination=evaluations_page,
|
||||
player_scores=player_scores,
|
||||
sort_column=sort_column,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def evaluate_player(tryout_id, player_id):
|
||||
"""Evaluate a specific player in a tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash(_('You do not have permission to evaluate players.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only evaluate players.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing_eval = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
|
||||
def render_evaluation_form():
|
||||
evaluators = None
|
||||
if isinstance(current_user, Admin):
|
||||
all_evaluations = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).all()
|
||||
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
|
||||
evaluators = [
|
||||
{'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e}
|
||||
for e in all_evaluations
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'pages/evaluate_player.html',
|
||||
tryout=tryout,
|
||||
player=player,
|
||||
existing_eval=existing_eval,
|
||||
evaluators=evaluators,
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = EvaluationSchema().load(form_payload(list_fields=(), optional_blank=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_evaluation_form()
|
||||
|
||||
evaluation = existing_eval
|
||||
if evaluation is None:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
evaluator_id=current_user.id,
|
||||
)
|
||||
db.session.add(evaluation)
|
||||
flash(_('Evaluation submitted successfully!'), 'success')
|
||||
else:
|
||||
flash(_('Evaluation updated!'), 'success')
|
||||
|
||||
evaluation.apply_scores(data)
|
||||
evaluation.comments = data['comments']
|
||||
evaluation.position_recommendation = data['position_recommendation']
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_evaluation_form()
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||
@login_required
|
||||
def players_to_evaluate(tryout_id):
|
||||
"""List players that need evaluation in a specific tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
players_by_id = _users_by_id(reg.player_id for reg in registrations)
|
||||
evaluated_player_ids = {
|
||||
player_id
|
||||
for (player_id,) in db.session.query(Evaluation.player_id)
|
||||
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
|
||||
.all()
|
||||
}
|
||||
players = [
|
||||
{
|
||||
'player': player,
|
||||
'evaluated': player.id in evaluated_player_ids,
|
||||
'registration': registration,
|
||||
}
|
||||
for registration in registrations
|
||||
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
|
||||
]
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Main dashboard routes for the Team Tryouts application.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
Player,
|
||||
Scout,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.permissions import coach_tryout_ids
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Redirect root URL to login page."""
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/lang/<locale>')
|
||||
def set_language(locale):
|
||||
"""Switch the interface language and return where the user came from.
|
||||
|
||||
Available to anonymous visitors too: the login page has to be readable
|
||||
before anyone can sign in.
|
||||
|
||||
A GET link rather than a form: the only thing a forged request could
|
||||
achieve is changing the visitor's own display language, which carries
|
||||
no consequence worth a token. The redirect target is still validated —
|
||||
an unchecked `Referer` would make this an open redirect.
|
||||
"""
|
||||
from app.i18n import set_locale
|
||||
from app.routes.auth import is_safe_url
|
||||
|
||||
if not set_locale(locale):
|
||||
flash(_('That language is not available.'), 'warning')
|
||||
|
||||
target = request.referrer
|
||||
if target and is_safe_url(target):
|
||||
return redirect(target)
|
||||
return redirect(
|
||||
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the main dashboard with role-specific statistics.
|
||||
|
||||
Each User subclass provides its own stats view.
|
||||
"""
|
||||
user = current_user
|
||||
stats = {}
|
||||
|
||||
if isinstance(user, Admin):
|
||||
stats['total_users'] = User.query.count()
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_tryouts'] = Tryout.query.count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['active_tryouts'] = Tryout.query.filter_by(status='in_progress').count()
|
||||
stats['completed_tryouts'] = Tryout.query.filter_by(status='completed').count()
|
||||
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
||||
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
||||
today = date.today()
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
elif isinstance(user, Manager):
|
||||
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
||||
stats['active_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id, status='in_progress'
|
||||
).count()
|
||||
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
stats['my_tryouts'] = (
|
||||
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||
)
|
||||
today = date.today()
|
||||
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(manager_tryout_ids),
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
if manager_tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
elif isinstance(user, Coach):
|
||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
|
||||
# A count, computed as a count. This used to load every registration
|
||||
# row in the club and every evaluation this coach had written, build
|
||||
# two Python sets and subtract them — two full table reads to produce
|
||||
# one integer (PERF-004).
|
||||
already_evaluated = (
|
||||
db.session.query(Evaluation.player_id)
|
||||
.filter(
|
||||
Evaluation.evaluator_id == user.id,
|
||||
Evaluation.player_id == TryoutRegistration.player_id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
stats['pending_evaluations'] = (
|
||||
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
|
||||
.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended']),
|
||||
~already_evaluated,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
stats['my_recent_evaluations'] = (
|
||||
Evaluation.query.filter_by(evaluator_id=user.id)
|
||||
.order_by(Evaluation.created_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
today = date.today()
|
||||
# Was: the first team matching the legacy coach_id column, and only
|
||||
# the tryouts targeting it. A coach attached by the many-to-many
|
||||
# relationship, or coaching a second team, saw no upcoming match.
|
||||
tryout_ids = coach_tryout_ids(user)
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(tryout_ids),
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
elif isinstance(user, Player):
|
||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
||||
stats['my_registrations'] = (
|
||||
TryoutRegistration.query.filter_by(player_id=user.id)
|
||||
.order_by(TryoutRegistration.registered_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
next_matches = []
|
||||
all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
|
||||
registered_tryout_ids = [r.tryout_id for r in all_registrations]
|
||||
player_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
|
||||
player_match_ids = [p.match_id for p in player_participant_matches]
|
||||
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
||||
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
||||
|
||||
upcoming_matches = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(registered_tryout_ids),
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
for match in upcoming_matches:
|
||||
is_participant = False
|
||||
team = None
|
||||
if match.match_type == 'team_vs_team':
|
||||
if match.team1_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next(
|
||||
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
|
||||
)
|
||||
elif match.team2_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next(
|
||||
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
|
||||
)
|
||||
else:
|
||||
if match.id in player_match_ids:
|
||||
is_participant = True
|
||||
|
||||
if is_participant:
|
||||
next_matches.append(
|
||||
{
|
||||
'tryout': match.tryout,
|
||||
'match': match,
|
||||
'team': team.team if team else None,
|
||||
}
|
||||
)
|
||||
|
||||
stats['next_matches'] = next_matches
|
||||
|
||||
elif isinstance(user, Scout):
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
top_rows = (
|
||||
db.session.query(
|
||||
User,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.join(Evaluation, Evaluation.player_id == User.id)
|
||||
.filter(User.role == 'player')
|
||||
.group_by(User.id)
|
||||
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
|
||||
|
||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Match scheduling routes for managing scrimmages and matches within tryouts.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin,
|
||||
Coach,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
PersonalNote,
|
||||
Player,
|
||||
PlayerDisponibility,
|
||||
Scout,
|
||||
Team,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.validators import MatchEditSchema, MatchSchema
|
||||
|
||||
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||
|
||||
|
||||
def match_form_payload():
|
||||
"""The match form, shaped for marshmallow.
|
||||
|
||||
`player_ids` is a repeated checkbox, so it needs getlist(); `games` — the
|
||||
default list field — has nothing to do with this form.
|
||||
"""
|
||||
return form_payload(list_fields=('player_ids',), optional_blank=())
|
||||
|
||||
|
||||
def registered_players(tryout_id):
|
||||
"""Players registered for one tryout, loaded in a single query.
|
||||
|
||||
The create form previously called ``User.query.get`` twice per
|
||||
registration (once in the filter and once in the result expression),
|
||||
and the edit form called it once per row. Besides scaling linearly, both
|
||||
paths could return duplicates while DB-006 is still pending. The join is
|
||||
bounded and ``distinct`` preserves the form's intended one-option-per-
|
||||
player contract until the database constraint lands.
|
||||
"""
|
||||
return (
|
||||
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
|
||||
.filter(TryoutRegistration.tryout_id == tryout_id)
|
||||
.order_by(User.username)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
#: How long a match lasts when the form gives a start and no end.
|
||||
DEFAULT_MATCH_MINUTES = 30
|
||||
|
||||
|
||||
def default_end_time(date, start_time):
|
||||
"""End time for a match whose form left it blank."""
|
||||
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
|
||||
|
||||
|
||||
def create_participants(match, data):
|
||||
"""Attach participants to a match, per its type.
|
||||
|
||||
Was written out twice, in create_match and in edit_match, and had already
|
||||
drifted: the copy in edit_match kept its player ids as strings and called
|
||||
int() on them one line later, the one in create_match did not (ARCH-005).
|
||||
|
||||
Returns:
|
||||
tuple: (player ids to notify, the participant rows created).
|
||||
"""
|
||||
sides = []
|
||||
if match.match_type == 'team_vs_team':
|
||||
for side, team_id in ((1, match.team1_id), (2, match.team2_id)):
|
||||
if team_id:
|
||||
members = TeamMember.query.filter_by(team_id=team_id).all()
|
||||
sides.append((side, [m.player_id for m in members]))
|
||||
elif match.match_type == 'player_vs_player':
|
||||
sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])]
|
||||
elif match.match_type == 'player_scrim':
|
||||
sides = [(None, data['player_ids'])]
|
||||
|
||||
player_ids = []
|
||||
participant_ids = []
|
||||
for side, ids in sides:
|
||||
for player_id in ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
participant_ids.append(participant.id)
|
||||
player_ids.append(player_id)
|
||||
return player_ids, participant_ids
|
||||
|
||||
|
||||
def can_schedule_match():
|
||||
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
|
||||
return isinstance(current_user, (Admin, Manager, Coach, Scout))
|
||||
|
||||
|
||||
def get_visible_tryouts_for_user():
|
||||
"""Get tryouts that the current user can see based on their role.
|
||||
|
||||
Delegates to the polymorphic User subclass.
|
||||
"""
|
||||
return current_user.get_visible_tryouts()
|
||||
|
||||
|
||||
@matches_bp.route('/calendar')
|
||||
@login_required
|
||||
def calendar():
|
||||
"""Render the calendar view."""
|
||||
return render_template('pages/calendar.html')
|
||||
|
||||
|
||||
def calendar_window(args):
|
||||
"""The date range FullCalendar is asking about, if it said.
|
||||
|
||||
A URL event source appends `start` and `end` automatically, in ISO 8601
|
||||
with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
|
||||
needed here, and a value that does not parse is treated as absent
|
||||
rather than as an error: a calendar that shows too much is a
|
||||
performance problem, one that 400s is a broken page.
|
||||
|
||||
Args:
|
||||
args: request.args.
|
||||
|
||||
Returns:
|
||||
tuple[date | None, date | None]: Inclusive bounds.
|
||||
"""
|
||||
|
||||
def _parse(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
return _parse(args.get('start')), _parse(args.get('end'))
|
||||
|
||||
|
||||
@matches_bp.route('/api/events')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_events():
|
||||
"""Calendar events for FullCalendar.
|
||||
|
||||
Bounded and batched (PERF-002). This used to walk `tryout.matches` for
|
||||
every visible tryout — every tryout the club has ever run, for a
|
||||
president — and then issue one MatchParticipant query per match to find
|
||||
out whether the viewer was in it. The calendar's cost grew with the
|
||||
whole history, on every navigation.
|
||||
"""
|
||||
events = []
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||
|
||||
if tryouts_by_id:
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||
if window_start:
|
||||
query = query.filter(Match.date >= window_start)
|
||||
if window_end:
|
||||
query = query.filter(Match.date <= window_end)
|
||||
matches = query.all()
|
||||
|
||||
# Participants for every match in the window, in one query rather
|
||||
# than one per match. `participants` is a dynamic relationship, so
|
||||
# eager loading options do not apply to it.
|
||||
match_ids = [match.id for match in matches]
|
||||
participants_by_match = {}
|
||||
mine_by_match = {}
|
||||
if match_ids:
|
||||
rows = (
|
||||
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
|
||||
.options(joinedload(MatchParticipant.player))
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
participants_by_match.setdefault(row.match_id, []).append(row)
|
||||
if row.player_id == current_user.id:
|
||||
mine_by_match[row.match_id] = row
|
||||
|
||||
for match in matches:
|
||||
tryout = tryouts_by_id[match.tryout_id]
|
||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||
# 'description' used to be participants_str + '<br>' + description.
|
||||
# Building presentation markup inside a JSON field is what carried
|
||||
# the stored XSS: the browser dropped it straight into innerHTML,
|
||||
# and player usernames travelled through it unescaped. The two
|
||||
# values are already separate keys, so the concatenation also made
|
||||
# the modal show the participants twice.
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
if match.team1:
|
||||
teams.append(match.team1.name)
|
||||
if match.team2:
|
||||
teams.append(match.team2.name)
|
||||
participants_str = ' vs '.join(teams)
|
||||
else:
|
||||
player_names = [
|
||||
p.player.username if p.player else 'Unknown Player'
|
||||
for p in participants_by_match.get(match.id, [])
|
||||
]
|
||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
user_participant = mine_by_match.get(match.id)
|
||||
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'description': match.description or '',
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
if user_participant
|
||||
else False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Add approved One on One sessions for the current user (player or coach)
|
||||
if isinstance(current_user, Player):
|
||||
one_on_ones = OneOnOneRequest.query.filter_by(
|
||||
player_id=current_user.id, status='approved'
|
||||
).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
one_on_ones = OneOnOneRequest.query.filter_by(
|
||||
coach_id=current_user.id, status='approved'
|
||||
).all()
|
||||
else:
|
||||
one_on_ones = []
|
||||
|
||||
for ooo in one_on_ones:
|
||||
events.append(
|
||||
{
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@matches_bp.route('/api/events/<int:tryout_id>')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_events_for_tryout(tryout_id):
|
||||
"""API endpoint returning calendar events for a specific tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
|
||||
is_registered = False
|
||||
player_in_match = False
|
||||
if isinstance(current_user, Player):
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
player_in_match = len(player_matches) > 0
|
||||
|
||||
if not can_view and not is_registered and not player_in_match:
|
||||
return jsonify([])
|
||||
|
||||
events = []
|
||||
|
||||
for match in tryout.matches:
|
||||
match_color = (
|
||||
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
||||
)
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
if match.team1:
|
||||
teams.append(match.team1.name)
|
||||
if match.team2:
|
||||
teams.append(match.team2.name)
|
||||
participants_str = f"{' vs '.join(teams)}"
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [
|
||||
p.player.username
|
||||
for p in match.participants.filter_by(team_side=1).all()
|
||||
if p.player
|
||||
]
|
||||
team2_players = [
|
||||
p.player.username
|
||||
for p in match.participants.filter_by(team_side=2).all()
|
||||
if p.player
|
||||
]
|
||||
if team1_players and team2_players:
|
||||
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
|
||||
else:
|
||||
participants_str = 'TBD vs TBD'
|
||||
else:
|
||||
player_names = [p.player.username for p in match.participants.all() if p.player]
|
||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_match(tryout_id):
|
||||
"""Create a new match / scrimmage within a tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.is_ended:
|
||||
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
all_players = registered_players(tryout_id)
|
||||
prefill_date = request.args.get('date', '')
|
||||
|
||||
def rerender():
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
payload = match_form_payload()
|
||||
# A tryout match with no date of its own happens on the tryout's day.
|
||||
payload.setdefault('date', tryout.date.isoformat())
|
||||
|
||||
try:
|
||||
data = MatchSchema().load(payload)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout_id,
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
date=data['date'],
|
||||
start_time=data['start_time'],
|
||||
end_time=data['end_time'] or default_end_time(data['date'], data['start_time']),
|
||||
location=data['location'],
|
||||
match_type=data['match_type'],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
|
||||
if data['match_type'] == 'team_vs_team':
|
||||
match.team1_id = data['team1_id']
|
||||
match.team2_id = data['team2_id']
|
||||
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
notify_participants(
|
||||
title=match.title,
|
||||
date=match.date,
|
||||
start_time=match.start_time,
|
||||
end_time=match.end_time,
|
||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
||||
fallback_id=match.id,
|
||||
)
|
||||
|
||||
flash(_('Match scheduled successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return rerender()
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing match."""
|
||||
match = db.get_or_404(Match, match_id)
|
||||
tryout = match.tryout
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||
return redirect(url_for('matches.calendar'))
|
||||
|
||||
if tryout.is_ended:
|
||||
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||
all_players = registered_players(tryout.id)
|
||||
current_player_ids = [p.player_id for p in match.participants.all()]
|
||||
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
|
||||
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
|
||||
|
||||
def rerender():
|
||||
"""The form, with everything the template needs.
|
||||
|
||||
One context, used by the GET and by a rejected POST alike. The
|
||||
rejection paths used to pass a shorter list, and match_form.html
|
||||
serialises participants_map into a <script> block — so a rejected
|
||||
edit died in `tojson` on an Undefined, turning a validation message
|
||||
into a 500.
|
||||
"""
|
||||
participants_map = {
|
||||
p.player_id: {
|
||||
'participant_id': p.id,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
'team_side': p.team_side,
|
||||
}
|
||||
for p in match.participants.all()
|
||||
}
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
match=match,
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
team1_player_ids=team1_player_ids,
|
||||
team2_player_ids=team2_player_ids,
|
||||
participants_map=participants_map,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = MatchEditSchema().load(match_form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
# Assigned only once the whole form has been accepted. Assigning as
|
||||
# each field was read meant a form rejected halfway had already
|
||||
# changed the record in the session.
|
||||
match.title = data['title']
|
||||
match.description = data['description']
|
||||
match.date = data['date']
|
||||
match.start_time = data['start_time']
|
||||
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||
match.location = data['location']
|
||||
match.status = data['status']
|
||||
|
||||
notified_player_ids = []
|
||||
notified_participant_ids = []
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
||||
if teams_changed:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
match.team1_id = data['team1_id']
|
||||
match.team2_id = data['team2_id']
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
else:
|
||||
# Same teams: the roster stands, but everyone is told again,
|
||||
# because the date or the time may have moved.
|
||||
for team_id in (match.team1_id, match.team2_id):
|
||||
if team_id:
|
||||
notified_player_ids.extend(
|
||||
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
||||
)
|
||||
else:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
notify_participants(
|
||||
title=match.title,
|
||||
date=match.date,
|
||||
start_time=match.start_time,
|
||||
end_time=match.end_time,
|
||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
||||
fallback_id=match.id,
|
||||
)
|
||||
|
||||
flash(_('Match updated successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return rerender()
|
||||
|
||||
|
||||
@matches_bp.route('/api/manageable-tryouts')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_manageable_tryouts():
|
||||
"""API endpoint returning tryouts the current user can manage."""
|
||||
if not can_schedule_match():
|
||||
return jsonify([])
|
||||
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
manageable = []
|
||||
for t in tryouts:
|
||||
if current_user.can_manage_this_tryout(t):
|
||||
manageable.append(
|
||||
{
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'date': t.date.strftime('%Y-%m-%d'),
|
||||
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
|
||||
}
|
||||
)
|
||||
return jsonify(manageable)
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a match."""
|
||||
match = db.get_or_404(Match, match_id)
|
||||
tryout = match.tryout
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||
return redirect(url_for('matches.calendar'))
|
||||
if tryout.is_ended:
|
||||
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
# Notes outlive the match they were taken during: a coach's observation
|
||||
# keeps its value, and deleting it here would destroy unrelated content.
|
||||
# Only the context link is dropped. Participants go through the
|
||||
# relationship's delete-orphan cascade.
|
||||
PersonalNote.query.filter_by(match_id=match_id).update(
|
||||
{'match_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.delete(match)
|
||||
db.session.commit()
|
||||
flash(_('Match deleted successfully.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
|
||||
def get_players_available_at_time(date_str, time_str):
|
||||
"""Player IDs whose weekly availability covers this date and time.
|
||||
|
||||
Two queries, whatever the size of the club. This used to load every
|
||||
active player and then run one PlayerDisponibility query per player, on
|
||||
an unindexed column — sixty players meant sixty-one round trips to
|
||||
answer a question the database can answer in one (PERF-003).
|
||||
|
||||
Args:
|
||||
date_str: 'YYYY-MM-DD'.
|
||||
time_str: 'HH:MM'.
|
||||
|
||||
Returns:
|
||||
list[int]: Player IDs, empty when the input does not parse.
|
||||
"""
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
day_of_week = parsed_date.weekday()
|
||||
active_player_ids = {
|
||||
row.id
|
||||
for row in User.query.with_entities(User.id)
|
||||
.filter_by(role='player', is_active_account=True)
|
||||
.all()
|
||||
}
|
||||
if not active_player_ids:
|
||||
return []
|
||||
|
||||
# The comparison stays in Python: start_time and end_time are stored as
|
||||
# time columns, and comparing them in SQL across three backends is not
|
||||
# worth the portability risk for a single day's rows.
|
||||
minutes = time_obj.hour * 60 + time_obj.minute
|
||||
available = []
|
||||
seen = set()
|
||||
for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
|
||||
if disp.player_id in seen or disp.player_id not in active_player_ids:
|
||||
continue
|
||||
start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||
if start <= minutes < end:
|
||||
available.append(disp.player_id)
|
||||
seen.add(disp.player_id)
|
||||
return available
|
||||
|
||||
|
||||
@matches_bp.route('/api/available_players/<date>/<time>')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_available_players(date, time):
|
||||
"""API endpoint to get players available at a specific date/time slot."""
|
||||
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
player_ids = get_players_available_at_time(date, time)
|
||||
return jsonify({'available_player_ids': player_ids})
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle attendance_confirmed for a match participant."""
|
||||
match = db.get_or_404(Match, match_id)
|
||||
tryout = match.tryout
|
||||
|
||||
participant = db.get_or_404(MatchParticipant, participant_id)
|
||||
if participant.match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
is_self = participant.player_id == current_user.id
|
||||
if not is_self and not current_user.can_manage_this_tryout(tryout):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
participant.attendance_confirmed = not participant.attendance_confirmed
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'attendance_confirmed': participant.attendance_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Team match management routes for regular season matches.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin,
|
||||
Coach,
|
||||
Manager,
|
||||
OrgTeam,
|
||||
Player,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
from app.routes.matches import default_end_time
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.validators import TeamMatchSchema
|
||||
|
||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||
|
||||
|
||||
def can_manage_team_match(team):
|
||||
"""Whether the current user can manage matches for this team.
|
||||
|
||||
Same rule as administering the team itself, so it is the same call.
|
||||
This function used to restate it, and the restatement drifted.
|
||||
"""
|
||||
return can_manage_org_team(current_user, team)
|
||||
|
||||
|
||||
@team_matches_bp.route('')
|
||||
@login_required
|
||||
def list_matches():
|
||||
"""List all team matches visible to the current user."""
|
||||
filter_team_id = request.args.get('team_id', type=int)
|
||||
|
||||
# A manager administers every team, so the listing shows them all;
|
||||
# visible_org_teams() only reports the teams they are attached to.
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, (Coach, Player)):
|
||||
teams = visible_org_teams(current_user)
|
||||
team_ids = [t.id for t in teams]
|
||||
matches_query = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(team_ids),
|
||||
)
|
||||
if team_ids
|
||||
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
else:
|
||||
teams = []
|
||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
|
||||
if filter_team_id:
|
||||
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
||||
|
||||
# Pagination also bounds the per-match participant loop below, which is
|
||||
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
|
||||
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
|
||||
matches = matches_page.items
|
||||
|
||||
match_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participants = []
|
||||
for p in tm.participants.all():
|
||||
participants.append(
|
||||
{
|
||||
'id': p.id,
|
||||
'player': p.player,
|
||||
'is_confirmed': p.is_confirmed,
|
||||
}
|
||||
)
|
||||
match_data.append(
|
||||
{
|
||||
'match': tm,
|
||||
'participants': participants,
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/team_matches.html',
|
||||
teams=teams,
|
||||
match_data=match_data,
|
||||
pagination=matches_page,
|
||||
now=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_match(team_id):
|
||||
"""Create a new regular-season team match."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not can_manage_team_match(team):
|
||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
||||
prefill_date = request.args.get('date', '')
|
||||
is_practice = request.args.get('type') == 'practice'
|
||||
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
||||
|
||||
if is_practice and request.method == 'GET':
|
||||
|
||||
class TryoutProxy:
|
||||
def __init__(self, team_obj):
|
||||
self.id = 0
|
||||
self.title = team_obj.name
|
||||
self.date = ''
|
||||
self.game = ''
|
||||
self.target_org_team = team_obj
|
||||
|
||||
proxy_tryout = TryoutProxy(team)
|
||||
all_players = [tp.player for tp in team_players if tp.player]
|
||||
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=proxy_tryout,
|
||||
teams=[],
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=True,
|
||||
team_id=team_id,
|
||||
team=team,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
payload = form_payload(list_fields=(), optional_blank=())
|
||||
# A practice has no opponent, whatever the form sent.
|
||||
payload.setdefault('title', default_title)
|
||||
if is_practice:
|
||||
payload.pop('opponent', None)
|
||||
|
||||
try:
|
||||
data = TeamMatchSchema().load(payload)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/team_match_form.html',
|
||||
team=team,
|
||||
team_players=team_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=is_practice,
|
||||
)
|
||||
|
||||
start_time = data['start_time']
|
||||
end_time = data['end_time'] or default_end_time(data['date'], start_time)
|
||||
|
||||
team_match = TeamMatch(
|
||||
org_team_id=team_id,
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
opponent=data['opponent'],
|
||||
date=data['date'],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
location=data['location'],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team_match)
|
||||
db.session.flush()
|
||||
|
||||
notified_participant_ids = []
|
||||
for tp in team_players:
|
||||
participant = TeamMatchParticipant(
|
||||
team_match_id=team_match.id,
|
||||
player_id=tp.player_id,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
notify_participants(
|
||||
title=team_match.title,
|
||||
date=team_match.date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
participants=zip_participants(
|
||||
[tp.player_id for tp in team_players], notified_participant_ids
|
||||
),
|
||||
fallback_id=team_match.id,
|
||||
)
|
||||
|
||||
flash(
|
||||
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
|
||||
)
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing team match."""
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
if not can_manage_team_match(team):
|
||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# The three date and time fields used to be checked one at a time,
|
||||
# each flashing and redirecting on its own: a form with two mistakes
|
||||
# took two round trips to be told about both. One schema now, every
|
||||
# problem reported at once and in place.
|
||||
#
|
||||
# Known limit: the re-render reads the stored record, so what was
|
||||
# typed is not echoed back. Repopulating the form from the
|
||||
# submission is a separate change to the template.
|
||||
try:
|
||||
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
)
|
||||
|
||||
team_match.title = data['title']
|
||||
team_match.description = data['description']
|
||||
team_match.opponent = data['opponent']
|
||||
team_match.date = data['date']
|
||||
team_match.start_time = data['start_time']
|
||||
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||
team_match.location = data['location']
|
||||
team_match.status = data['status']
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Match updated successfully!'), 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a team match."""
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
if not can_manage_team_match(team):
|
||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
db.session.delete(team_match)
|
||||
db.session.commit()
|
||||
flash(_('Match deleted successfully.'), 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
|
||||
@team_matches_bp.route('/api/manageable-teams')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_manageable_teams():
|
||||
"""API endpoint returning teams the current user can schedule matches for."""
|
||||
if not current_user.can_schedule_matches():
|
||||
return jsonify([])
|
||||
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = coach_org_teams(current_user)
|
||||
else:
|
||||
return jsonify([])
|
||||
|
||||
return jsonify([{'id': t.id, 'name': t.name} for t in teams])
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle is_confirmed for a team match participant."""
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
participant = db.get_or_404(TeamMatchParticipant, participant_id)
|
||||
if participant.team_match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id
|
||||
if not can_toggle:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
participant.is_confirmed = not participant.is_confirmed
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'is_confirmed': participant.is_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,620 @@
|
||||
"""Organization team management routes.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin,
|
||||
Coach,
|
||||
Contract,
|
||||
Manager,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
TeamMatch,
|
||||
TeamNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
User,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
|
||||
@teams_bp.route('')
|
||||
@login_required
|
||||
def list_teams():
|
||||
"""List all organization teams visible to the current user."""
|
||||
can_manage = current_user.can_manage_teams()
|
||||
|
||||
if isinstance(current_user, Player):
|
||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||
return redirect(url_for('teams.my_teams'))
|
||||
if not isinstance(current_user, (Admin, Coach, Manager)):
|
||||
flash(_('You do not have permission to view teams.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
teams = visible_org_teams(current_user)
|
||||
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
# is_active_account, like the two queries above it. Without it the "add
|
||||
# player" select offered accounts that had been deactivated, and
|
||||
# add_player accepted them.
|
||||
all_players = (
|
||||
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
return render_template(
|
||||
'pages/teams.html',
|
||||
teams=teams,
|
||||
coaches=coaches,
|
||||
managers=managers,
|
||||
all_players=all_players,
|
||||
can_manage=can_manage,
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/my-teams')
|
||||
@login_required
|
||||
def my_teams():
|
||||
"""View the player's own teams with upcoming matches."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('This page is for players.'), 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
from app.models import TeamMatch, TeamMatchParticipant
|
||||
|
||||
player_teams = current_user.get_org_teams()
|
||||
now = datetime.utcnow()
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
matches = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id == org_team.id,
|
||||
TeamMatch.status == 'scheduled',
|
||||
)
|
||||
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
matches_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participant = TeamMatchParticipant.query.filter_by(
|
||||
team_match_id=tm.id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
matches_data.append(
|
||||
{
|
||||
'match': tm,
|
||||
'participant_id': participant.id if participant else None,
|
||||
'is_confirmed': participant.is_confirmed if participant else False,
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
team_data.append(
|
||||
{
|
||||
'team': org_team,
|
||||
'matches': matches_data,
|
||||
'coaches': org_team.get_coaches(),
|
||||
'managers': org_team.get_managers(),
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
||||
|
||||
|
||||
def _posted(schema):
|
||||
"""Load a form through `schema`, or None when it will not load.
|
||||
|
||||
The five assignment routes below each answer a bad field with their own
|
||||
flash and a redirect to the same page, so a shared "it did not validate"
|
||||
return is enough; the field-level message is flashed on the way out.
|
||||
"""
|
||||
try:
|
||||
return schema.load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return None
|
||||
|
||||
|
||||
def _assignable(user, expected_class):
|
||||
"""Whether this account may be given a role on a team.
|
||||
|
||||
Deactivated accounts were offered by the selects and accepted by the
|
||||
routes. `is_active_account` is what stops someone logging in — a person
|
||||
who has left the club — so putting them on a roster contradicts the one
|
||||
control that says they are gone. The listings filtered it for coaches and
|
||||
managers and not for players, two lines apart, which is how it went
|
||||
unnoticed.
|
||||
"""
|
||||
return isinstance(user, expected_class) and bool(user.is_active_account)
|
||||
|
||||
|
||||
def _staff_member(user_id, expected_class):
|
||||
"""The user behind an id, only if they may hold the role being assigned.
|
||||
|
||||
Returns None for a missing id, an unknown id, an account of the wrong
|
||||
role, or a deactivated one. The role check is the point (SEC-16): the id
|
||||
comes from a `<select>` the browser rendered, so it is a value the client
|
||||
chooses, and nothing checked it in two of the three places that used it.
|
||||
A forged submission could therefore list a player among a team's coaches
|
||||
— the same defect wave G fixed in `tryouts.py`, left standing here.
|
||||
|
||||
Defers to `_assignable` rather than repeating `isinstance`: two functions
|
||||
in one file answering "may this account take this role" differently is
|
||||
the shape of every defect this module has had.
|
||||
|
||||
Args:
|
||||
user_id: Already an int or None, thanks to the schema.
|
||||
expected_class: Coach or Manager.
|
||||
|
||||
Returns:
|
||||
User | None: The account, when it may take the role.
|
||||
"""
|
||||
if not user_id:
|
||||
return None
|
||||
user = db.session.get(User, user_id)
|
||||
return user if user and _assignable(user, expected_class) else None
|
||||
|
||||
|
||||
@teams_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_team():
|
||||
"""Create a new organization team."""
|
||||
if not current_user.can_manage_teams():
|
||||
flash(_('You do not have permission to create teams.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
try:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
name = data['name']
|
||||
if OrgTeam.query.filter_by(name=name).first():
|
||||
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach = _staff_member(data['coach_id'], Coach)
|
||||
manager = _staff_member(data['manager_id'], Manager)
|
||||
|
||||
team = OrgTeam(
|
||||
name=name,
|
||||
coach_id=coach.id if coach else None,
|
||||
manager_id=manager.id if manager else None,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
|
||||
if coach:
|
||||
team.coaches.append(coach)
|
||||
if manager:
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
def edit_team(team_id):
|
||||
"""Edit an existing organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
try:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
name = data['name']
|
||||
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
|
||||
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.name = name
|
||||
|
||||
if data['sync_staff'] == '1':
|
||||
team.coaches = [
|
||||
user for user in (_staff_member(cid, Coach) for cid in data['coach_ids']) if user
|
||||
]
|
||||
coach_list = team.coaches.all()
|
||||
team.coach_id = coach_list[0].id if coach_list else None
|
||||
|
||||
team.managers = [
|
||||
user for user in (_staff_member(mid, Manager) for mid in data['manager_ids']) if user
|
||||
]
|
||||
manager_list = team.managers.all()
|
||||
team.manager_id = manager_list[0].id if manager_list else None
|
||||
else:
|
||||
# This branch never checked the role, while the one above did — the
|
||||
# same file disagreeing with itself (SEC-16). _staff_member is the
|
||||
# single answer now.
|
||||
coach = _staff_member(data['coach_id'], Coach)
|
||||
manager = _staff_member(data['manager_id'], Manager)
|
||||
|
||||
team.coach_id = coach.id if coach else None
|
||||
team.manager_id = manager.id if manager else None
|
||||
|
||||
if coach and not team.coaches.filter_by(id=coach.id).first():
|
||||
team.coaches.append(coach)
|
||||
if manager and not team.managers.filter_by(id=manager.id).first():
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_team(team_id):
|
||||
"""Delete an organization team.
|
||||
|
||||
Two checks, not one, and not the one the audit recommended (SEC-AUTHZ-006).
|
||||
|
||||
The constat was right about the inconsistency: this was the only team
|
||||
operation guarded by the global `can_manage_teams()` while the other
|
||||
nine use `can_manage_this_org_team(team)`. It was wrong about the fix.
|
||||
Simply swapping to the per-object check **widens** access — `Coach`
|
||||
returns False for the global capability and True for its own teams, so
|
||||
the swap would hand every coach the power to delete the team they coach,
|
||||
along with its notes and its match history. The constat reasoned about
|
||||
`Manager`, where both return True, and missed the role where they differ.
|
||||
|
||||
Requiring both preserves today's behaviour exactly (admins and managers
|
||||
yes, coaches no) and still closes the debt the constat was about: the
|
||||
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
|
||||
deletion narrows with it instead of staying the one way in.
|
||||
"""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
|
||||
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
|
||||
flash(_('You do not have permission to delete teams.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
name = team.name
|
||||
|
||||
# One transaction. This used to commit three times, so a failure at the
|
||||
# third step left the tryouts detached and the players removed without
|
||||
# the team being deleted — an inconsistent state nothing could undo.
|
||||
#
|
||||
# TeamNote.org_team_id and TeamMatch.org_team_id are NOT NULL, and were
|
||||
# not handled at all: deleting a team that had ever been used raised
|
||||
# IntegrityError. Contract.team_id and OneOnOneRequest.org_team_id are
|
||||
# nullable, and the rows outlive the team, so they are only detached.
|
||||
|
||||
# Entities that only make sense as part of the team.
|
||||
TeamNote.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
||||
for team_match in TeamMatch.query.filter_by(org_team_id=team_id).all():
|
||||
db.session.delete(team_match) # participants follow by cascade
|
||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
||||
|
||||
# Entities that survive it.
|
||||
Tryout.query.filter_by(target_org_team_id=team_id).update(
|
||||
{'target_org_team_id': None}, synchronize_session=False
|
||||
)
|
||||
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
|
||||
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
|
||||
{'org_team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
||||
@login_required
|
||||
def add_coach(team_id):
|
||||
"""Add a coach to an organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
if not data['coach_id']:
|
||||
flash(_('Please select a coach.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach = db.session.get(User, data['coach_id'])
|
||||
if not coach or not _assignable(coach, Coach):
|
||||
flash(_('Only coaches can be assigned as coach.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.coaches.filter_by(id=coach.id).first():
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a coach of %(name)s.',
|
||||
username=coach.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.coaches.append(coach)
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
flash(
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
||||
@login_required
|
||||
def add_manager(team_id):
|
||||
"""Add a manager to an organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
if not data['manager_id']:
|
||||
flash(_('Please select a manager.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
manager = db.session.get(User, data['manager_id'])
|
||||
if not manager or not _assignable(manager, Manager):
|
||||
flash(_('Only managers can be assigned as manager.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.managers.filter_by(id=manager.id).first():
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a manager of %(name)s.',
|
||||
username=manager.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.managers.append(manager)
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
flash(
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
||||
@login_required
|
||||
def remove_coach(team_id):
|
||||
"""Remove a coach from an organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if data['coach_id']:
|
||||
coach = db.session.get(User, data['coach_id'])
|
||||
if coach and team.coaches.filter_by(id=coach.id).first():
|
||||
team.coaches.remove(coach)
|
||||
if team.coach_id == coach.id:
|
||||
team.coach_id = None
|
||||
else:
|
||||
team.coaches = []
|
||||
team.coach_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
||||
@login_required
|
||||
def remove_manager(team_id):
|
||||
"""Remove a manager from an organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if data['manager_id']:
|
||||
manager = db.session.get(User, data['manager_id'])
|
||||
if manager and team.managers.filter_by(id=manager.id).first():
|
||||
team.managers.remove(manager)
|
||||
if team.manager_id == manager.id:
|
||||
team.manager_id = None
|
||||
else:
|
||||
team.managers = []
|
||||
team.manager_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
||||
@login_required
|
||||
def add_player(team_id):
|
||||
"""Add a player to an organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamPlayerSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
if not data['player_id']:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
status = data['status']
|
||||
player = db.session.get(User, data['player_id'])
|
||||
if not player or not _assignable(player, Player):
|
||||
flash(_('Can only assign players to teams.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||
if existing:
|
||||
flash(
|
||||
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||
db.session.add(tp)
|
||||
db.session.commit()
|
||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player(team_id, player_id):
|
||||
"""Remove a player from an organization team."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = db.get_or_404(User, player_id)
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
db.session.delete(tp)
|
||||
db.session.commit()
|
||||
flash(
|
||||
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_player_status(team_id, player_id):
|
||||
"""Toggle a player's status between starter and substitute."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
return jsonify({'error': 'Player not found on this team'}), 404
|
||||
|
||||
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'success': True,
|
||||
'player_id': player_id,
|
||||
'new_status': tp.status,
|
||||
'player_name': tp.player.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||
@login_required
|
||||
def add_team_note(team_id):
|
||||
"""Add a team improvement note (coaches only)."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_player_note(team_id, player_id):
|
||||
"""Add a personal note for a player (coaches only)."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -0,0 +1,700 @@
|
||||
"""Tryout management routes for creating, viewing, and managing tryout events.
|
||||
|
||||
This module handles CRUD operations for tryouts and player registrations.
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
Scout,
|
||||
Team,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.validators import (
|
||||
PlayerSelectionSchema,
|
||||
TryoutRegistrationStatusSchema,
|
||||
TryoutSchema,
|
||||
TryoutStatusSchema,
|
||||
TryoutTeamMemberSchema,
|
||||
TryoutTeamSchema,
|
||||
)
|
||||
|
||||
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
|
||||
|
||||
|
||||
def can_manage():
|
||||
"""Check if current user can manage tryouts (Admin or Manager)."""
|
||||
return isinstance(current_user, (Admin, Manager))
|
||||
|
||||
|
||||
def tryout_form_payload():
|
||||
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
||||
return form_payload(list_fields=('coach_ids',), optional_blank=())
|
||||
|
||||
|
||||
def coaches_from_ids(coach_ids):
|
||||
"""The coach accounts behind these ids.
|
||||
|
||||
Filtered by role, which the previous `User.id.in_(...)` was not: the form
|
||||
posts a list of ids and nothing stopped a hand-made submission from
|
||||
naming a player, who then appeared as a coach of the tryout and inherited
|
||||
every permission that comes with it.
|
||||
"""
|
||||
if not coach_ids:
|
||||
return []
|
||||
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load these users in one query, keyed by id.
|
||||
|
||||
Replaces the `User.query.get()`-inside-a-loop that view_tryout used in
|
||||
three separate places (PERF-001). Missing ids are simply absent from
|
||||
the result, which is what a per-row get() returning None amounted to.
|
||||
|
||||
Args:
|
||||
user_ids: Iterable of primary keys, may repeat and may be empty.
|
||||
|
||||
Returns:
|
||||
dict[int, User]
|
||||
"""
|
||||
wanted = {user_id for user_id in user_ids if user_id}
|
||||
if not wanted:
|
||||
return {}
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
def registration_lock_statement(tryout_id):
|
||||
"""The PostgreSQL row lock used by both registration entry points."""
|
||||
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
|
||||
|
||||
|
||||
def locked_tryout_or_404(tryout_id):
|
||||
"""Load and row-lock a tryout while a registration slot is decided.
|
||||
|
||||
PostgreSQL serializes concurrent registration attempts on this row. The
|
||||
duplicate check, capacity count and insert that follow therefore form
|
||||
one decision instead of three independently racing statements. SQLite
|
||||
ignores ``FOR UPDATE`` in tests, but production does not.
|
||||
"""
|
||||
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
|
||||
if tryout is None:
|
||||
abort(404)
|
||||
return tryout
|
||||
|
||||
|
||||
@tryouts_bp.route('')
|
||||
@login_required
|
||||
def list_tryouts():
|
||||
"""List all tryouts visible to the current user.
|
||||
|
||||
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
|
||||
"""
|
||||
tryouts = current_user.get_visible_tryouts()
|
||||
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
|
||||
|
||||
|
||||
@tryouts_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_tryout():
|
||||
"""Create a new tryout event. Requires Admin or Manager."""
|
||||
if not can_manage():
|
||||
flash(_('You do not have permission to create tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
|
||||
def rerender():
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=None,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = TryoutSchema().load(tryout_form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
tryout = Tryout(
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
game=data['game'],
|
||||
date=data['date'],
|
||||
end_date=data['end_date'],
|
||||
location=data['location'],
|
||||
max_players=data['max_players'],
|
||||
created_by=current_user.id,
|
||||
status='upcoming',
|
||||
target_org_team_id=data['target_org_team_id'],
|
||||
manager_id=data['manager_id'],
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tryout created successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return rerender()
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_tryout(tryout_id):
|
||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
if tryout.is_ended:
|
||||
flash(_('This tryout has ended and can no longer be modified.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
|
||||
)
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
|
||||
)
|
||||
|
||||
def rerender():
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=tryout,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = TryoutSchema().load(tryout_form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
tryout.title = data['title']
|
||||
tryout.description = data['description']
|
||||
tryout.game = data['game']
|
||||
tryout.date = data['date']
|
||||
tryout.end_date = data['end_date']
|
||||
tryout.location = data['location']
|
||||
tryout.max_players = data['max_players']
|
||||
tryout.target_org_team_id = data['target_org_team_id']
|
||||
tryout.manager_id = data['manager_id']
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tryout updated successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return rerender()
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>')
|
||||
@login_required
|
||||
def view_tryout(tryout_id):
|
||||
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
can_view = False
|
||||
if isinstance(current_user, Admin):
|
||||
can_view = True
|
||||
elif isinstance(current_user, Manager):
|
||||
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
|
||||
elif isinstance(current_user, Coach):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Player):
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
player_in_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
can_view = is_registered or player_in_match
|
||||
elif isinstance(current_user, Scout):
|
||||
can_view = True
|
||||
|
||||
if not can_view:
|
||||
flash(_('You do not have permission to view this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
# Everything below used to run one query per row (PERF-001): one
|
||||
# User.query.get() per registration, one Evaluation lookup per player,
|
||||
# one TeamMember query per team and one more User.query.get() per
|
||||
# member. Thirty registrants and four teams put this page well past a
|
||||
# hundred round trips, on unindexed columns.
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
registered_player_ids = [r.player_id for r in registrations if r.player_id]
|
||||
players_by_id = _users_by_id(registered_player_ids)
|
||||
registered_players = [
|
||||
players_by_id[player_id]
|
||||
for player_id in registered_player_ids
|
||||
if player_id in players_by_id
|
||||
]
|
||||
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
|
||||
|
||||
player_eval_status = {}
|
||||
if current_user.can_evaluate():
|
||||
evaluated_by_me = {
|
||||
row.player_id
|
||||
for row in evaluations
|
||||
if row.evaluator_id == current_user.id and row.player_id
|
||||
}
|
||||
player_eval_status = {p.id: p.id in evaluated_by_me for p in registered_players}
|
||||
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
team_ids = [team.id for team in teams]
|
||||
members_by_team = {}
|
||||
if team_ids:
|
||||
member_rows = TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).all()
|
||||
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
||||
for row in member_rows:
|
||||
members_by_team.setdefault(row.team_id, []).append(
|
||||
{'player': member_players.get(row.player_id), 'position': row.position}
|
||||
)
|
||||
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
|
||||
|
||||
can_edit = current_user.can_manage_this_tryout(tryout)
|
||||
|
||||
can_view_calendar = can_edit
|
||||
if isinstance(current_user, Player):
|
||||
player_in_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
can_view_calendar = is_registered or player_in_match
|
||||
|
||||
all_players = None
|
||||
if can_edit:
|
||||
# is_active_account, like the manager and coach queries in this same
|
||||
# module. Offering a deactivated account in a roster select
|
||||
# contradicts the one control that says the person has left.
|
||||
all_players = (
|
||||
User.query.filter_by(role='player', is_active_account=True)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
matches = (
|
||||
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
)
|
||||
match_data = []
|
||||
for match in matches:
|
||||
all_participants = list(match.participants.all())
|
||||
confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed)
|
||||
total_count = len(all_participants)
|
||||
|
||||
player_presence = []
|
||||
for p in all_participants:
|
||||
if p.player:
|
||||
player_presence.append(
|
||||
{
|
||||
'participant_id': p.id,
|
||||
'player_id': p.player_id,
|
||||
'player_name': p.player.username,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
}
|
||||
)
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
participants = {
|
||||
'team1': match.team1.name if match.team1 else 'TBD',
|
||||
'team2': match.team2.name if match.team2 else 'TBD',
|
||||
'team1_players': [
|
||||
{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team1.members.all()
|
||||
]
|
||||
if match.team1
|
||||
else [],
|
||||
'team2_players': [
|
||||
{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team2.members.all()
|
||||
]
|
||||
if match.team2
|
||||
else [],
|
||||
}
|
||||
elif match.match_type == 'player_vs_player':
|
||||
# Filtered from the list already in hand. Asking the dynamic
|
||||
# relationship again cost two more round trips per match for
|
||||
# rows that were loaded a dozen lines above.
|
||||
team1_players = [
|
||||
{'name': p.player.username, 'position': p.position}
|
||||
for p in all_participants
|
||||
if p.team_side == 1 and p.player
|
||||
]
|
||||
team2_players = [
|
||||
{'name': p.player.username, 'position': p.position}
|
||||
for p in all_participants
|
||||
if p.team_side == 2 and p.player
|
||||
]
|
||||
participants = {
|
||||
'team1': 'Team 1',
|
||||
'team2': 'Team 2',
|
||||
'team1_players': team1_players,
|
||||
'team2_players': team2_players,
|
||||
}
|
||||
else:
|
||||
participants = [p.player.username for p in match.participants.all()]
|
||||
|
||||
match_data.append(
|
||||
{
|
||||
'match': match,
|
||||
'participants': participants,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_count': total_count,
|
||||
'player_presence': player_presence,
|
||||
}
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/view_tryout.html',
|
||||
tryout=tryout,
|
||||
registered_players=registered_players,
|
||||
evaluations=evaluations,
|
||||
player_eval_status=player_eval_status,
|
||||
is_registered=is_registered,
|
||||
registrations=registrations,
|
||||
team_data=team_data,
|
||||
can_edit=can_edit,
|
||||
can_view_calendar=can_view_calendar,
|
||||
all_players=all_players,
|
||||
matches=matches,
|
||||
match_data=match_data,
|
||||
game_positions=GAME_POSITIONS,
|
||||
now=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
|
||||
@login_required
|
||||
def register_for_tryout(tryout_id):
|
||||
"""Register a player for a tryout. Only Players can self-register."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('Only players can register for tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
tryout = locked_tryout_or_404(tryout_id)
|
||||
|
||||
if tryout.status not in ['upcoming', 'in_progress']:
|
||||
flash(_('This tryout is not accepting registrations.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
if existing:
|
||||
flash(_('You are already registered for this tryout.'), 'info')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
flash(_('This tryout is full.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
flash(_('Successfully registered for tryout!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
try:
|
||||
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
tryout.status = data['status']
|
||||
db.session.commit()
|
||||
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first_or_404()
|
||||
try:
|
||||
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration.status = data['status']
|
||||
db.session.commit()
|
||||
flash(_('Registration status updated.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
||||
@login_required
|
||||
def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = locked_tryout_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
try:
|
||||
data = PlayerSelectionSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not data['player_id']:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
# Same two checks as the team roster (SEC-16): the right role, and an
|
||||
# account that has not been deactivated. The select this comes from now
|
||||
# filters both, but the select is not the control.
|
||||
player = db.session.get(User, data['player_id'])
|
||||
if not player or not isinstance(player, Player) or not player.is_active_account:
|
||||
flash(_('Can only register players.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
||||
if existing:
|
||||
flash(
|
||||
_('%(username)s is already registered for this tryout.', username=player.username),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
flash(_('This tryout is full.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
player = db.get_or_404(User, player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first()
|
||||
if registration:
|
||||
db.session.delete(registration)
|
||||
|
||||
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
||||
if team_ids:
|
||||
TeamMember.query.filter(
|
||||
TeamMember.team_id.in_(team_ids),
|
||||
TeamMember.player_id == player_id,
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||
if match_ids:
|
||||
MatchParticipant.query.filter(
|
||||
MatchParticipant.match_id.in_(match_ids),
|
||||
MatchParticipant.player_id == player_id,
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
try:
|
||||
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_team(tryout_id, team_id):
|
||||
"""Add a player to a tryout team."""
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
# The two ids arrive independently in the URL. Without this check, being
|
||||
# allowed to manage tryout A was enough to modify a team belonging to
|
||||
# tryout B, since only the tryout was authorised.
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
player_id = data['player_id']
|
||||
|
||||
# Only players registered for this tryout may be placed on its teams.
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||
if existing:
|
||||
flash(_('Player is already on this team.'), 'info')
|
||||
else:
|
||||
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
flash(_('Player added to team!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_tryout(tryout_id):
|
||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
||||
|
||||
# Personal notes outlive the tryout: they are a coach's observations
|
||||
# about a player, not tryout data. Only their context links are cleared.
|
||||
# Missing this step made the deletion fail on the foreign keys below.
|
||||
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
|
||||
{'tryout_id': None}, synchronize_session=False
|
||||
)
|
||||
if match_ids:
|
||||
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
|
||||
{'match_id': None}, synchronize_session=False
|
||||
)
|
||||
if team_ids:
|
||||
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
|
||||
{'team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
if match_ids:
|
||||
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
|
||||
|
||||
if team_ids:
|
||||
TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
|
||||
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
|
||||
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
|
||||
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
|
||||
|
||||
db.session.delete(tryout)
|
||||
db.session.commit()
|
||||
flash(_('Tryout deleted successfully.'), 'success')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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')
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Player contracts: upload, sign, download."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||
from app.routes.users._shared import (
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
pdf_upload_error,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.storage import CONTRACTS_DIR, document_path
|
||||
from app.validators import UploadContractSchema
|
||||
|
||||
|
||||
def manageable_players():
|
||||
"""Players the current user may attach a contract to.
|
||||
|
||||
A coach used to see the squad of one team — the first row matching the
|
||||
legacy coach_id column — so a coach of two teams could file a contract
|
||||
for half of their players and no more, and a coach attached only by the
|
||||
many-to-many relationship for none at all.
|
||||
"""
|
||||
if isinstance(current_user, Coach):
|
||||
player_ids = coach_player_ids(current_user)
|
||||
return (
|
||||
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
|
||||
if player_ids
|
||||
else []
|
||||
)
|
||||
# is_active_account: a contract select that still lists people who have
|
||||
# left the club invites filing paperwork against them (SEC-16).
|
||||
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
|
||||
|
||||
@users_bp.route('/contracts')
|
||||
@login_required
|
||||
def list_contracts():
|
||||
"""View contracts for the current user or players they manage."""
|
||||
contracts = None
|
||||
players = None
|
||||
|
||||
if isinstance(current_user, Player):
|
||||
contracts = (
|
||||
Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
elif isinstance(current_user, (Admin, Manager, Coach)):
|
||||
players = manageable_players()
|
||||
|
||||
if players:
|
||||
player_ids = [p.id for p in players]
|
||||
contracts = (
|
||||
Contract.query.filter(
|
||||
Contract.player_id.in_(player_ids),
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/contracts.html',
|
||||
contracts=contracts,
|
||||
players=players if isinstance(current_user, (Admin, Manager, Coach)) else None,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_contract():
|
||||
"""Upload a contract for a player."""
|
||||
if not isinstance(current_user, (Admin, Manager, Coach)):
|
||||
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
players = manageable_players()
|
||||
|
||||
if request.method == 'POST':
|
||||
contract_schema = UploadContractSchema()
|
||||
try:
|
||||
validated = contract_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
player_id = validated['player_id']
|
||||
notes = validated.get('notes')
|
||||
|
||||
if not can_manage_player_contract(current_user, player_id):
|
||||
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
file = request.files.get('contract_file')
|
||||
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
player = 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 = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash(_('Signed contract uploaded successfully!'), 'success')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download')
|
||||
@login_required
|
||||
def download_contract(contract_id):
|
||||
"""Download a contract file."""
|
||||
contract = 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,
|
||||
)
|
||||
@@ -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=[],
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""One-on-one sessions between a player and their coach."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.services.notifications import send_discord_notification
|
||||
from app.validators import 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 = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=current_user.full_name,
|
||||
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=request_obj.start_time.strftime('%I:%M %p')
|
||||
if request_obj.start_time
|
||||
else 'TBD',
|
||||
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
|
||||
points=request_obj.points or 'No specific points provided',
|
||||
approved=True,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'One on One request from %(player)s has been approved!',
|
||||
player=player.username if player else 'Unknown',
|
||||
),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
|
||||
@login_required
|
||||
def reject_one_on_one(request_id):
|
||||
"""Coach rejects a One on One request."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can reject One on One requests.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
request_obj = 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 = datetime.utcnow()
|
||||
if rejection_reason:
|
||||
request_obj.coach_rejection_message = rejection_reason
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if rejected through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=current_user.full_name,
|
||||
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=request_obj.start_time.strftime('%I:%M %p')
|
||||
if request_obj.start_time
|
||||
else 'TBD',
|
||||
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
|
||||
points=request_obj.points or 'No specific points provided',
|
||||
approved=False,
|
||||
refusal_note=rejection_reason or None,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'One on One request from %(player)s has been rejected.',
|
||||
player=player.username if player else 'Unknown',
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Business services: work that is neither a route nor a model."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Announcing a scheduled match to the people who have to be there.
|
||||
|
||||
The same twenty lines appeared three times — matches.create_match,
|
||||
matches.edit_match and team_matches.create_match — each formatting the date
|
||||
and time itself, then walking two parallel lists in lockstep to pair a
|
||||
player with the participant row that a Discord reaction has to find again
|
||||
(ARCH-003).
|
||||
|
||||
Three copies meant three chances to drift, and they had:
|
||||
create_match read the times from local variables it had just parsed, while
|
||||
edit_match re-derived them from the saved row and substituted the start
|
||||
time for a missing end time. The rule kept here is the more careful of the
|
||||
two.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.discord_bot import send_schedule_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: What the bot shows when a match has no usable time.
|
||||
TIME_UNKNOWN = 'TBD'
|
||||
|
||||
|
||||
def format_event_time(start_time, end_time):
|
||||
"""Render a match's time range the way the Discord message expects.
|
||||
|
||||
Args:
|
||||
start_time: A time, or None.
|
||||
end_time: A time, or None. Falls back to start_time, so a match with
|
||||
only a start still announces something useful.
|
||||
|
||||
Returns:
|
||||
str: 'HH:MM AM - HH:MM PM', or TIME_UNKNOWN.
|
||||
"""
|
||||
if not start_time:
|
||||
return TIME_UNKNOWN
|
||||
finish = end_time or start_time
|
||||
return f'{start_time.strftime("%I:%M %p")} - {finish.strftime("%I:%M %p")}'
|
||||
|
||||
|
||||
def zip_participants(player_ids, participant_ids):
|
||||
"""Pair each player with the participant row that was created for them.
|
||||
|
||||
The routes build these as two parallel lists, appended in step. Pairing
|
||||
them by index is what the original code did, and it is only correct as
|
||||
long as they stay in step — hence one place to look at rather than
|
||||
three. A short participant list yields None, which
|
||||
:func:`notify_participants` turns into the fallback reference.
|
||||
|
||||
Args:
|
||||
player_ids: Player primary keys, in creation order.
|
||||
participant_ids: Participant row ids, in the same order.
|
||||
|
||||
Yields:
|
||||
tuple[int, int | None]: (player_id, participant_id).
|
||||
"""
|
||||
for index, player_id in enumerate(player_ids):
|
||||
yield player_id, participant_ids[index] if index < len(participant_ids) else None
|
||||
|
||||
|
||||
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
|
||||
"""Tell each participant that a match has been scheduled or changed.
|
||||
|
||||
Args:
|
||||
title: Match title, shown in the message.
|
||||
date: The match date.
|
||||
start_time: Start time, or None.
|
||||
end_time: End time, or None.
|
||||
participants: Iterable of (player_id, participant_id) pairs.
|
||||
participant_id is what a Discord reaction resolves back to, so
|
||||
attendance lands on the right row.
|
||||
fallback_id: Reference to use when a participant row has no id —
|
||||
the match's own, which the bot can still act on.
|
||||
|
||||
Returns:
|
||||
int: How many notifications were handed to the bot.
|
||||
"""
|
||||
event_date = date.strftime('%Y-%m-%d')
|
||||
event_time = format_event_time(start_time, end_time)
|
||||
|
||||
sent = 0
|
||||
for player_id, participant_id in participants:
|
||||
if not player_id:
|
||||
continue
|
||||
send_schedule_notification(
|
||||
user_id=player_id,
|
||||
event_type='match',
|
||||
event_title=title,
|
||||
event_date=event_date,
|
||||
event_time=event_time,
|
||||
reference_id=participant_id or fallback_id,
|
||||
)
|
||||
sent += 1
|
||||
return sent
|
||||
@@ -96,6 +96,7 @@ a:hover { color: var(--primary-dark); }
|
||||
}
|
||||
|
||||
.logo i { color: var(--primary); font-size: 1.6rem; }
|
||||
.logo-img { height: 40px; width: auto; }
|
||||
|
||||
.user-badge {
|
||||
display: flex;
|
||||
@@ -138,7 +139,10 @@ a:hover { color: var(--primary-dark); }
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -148,7 +152,17 @@ a:hover { color: var(--primary-dark); }
|
||||
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);
|
||||
color: white;
|
||||
}
|
||||
@@ -158,7 +172,8 @@ a:hover { color: var(--primary-dark); }
|
||||
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 {
|
||||
height: 1px;
|
||||
@@ -607,6 +622,12 @@ a:hover { color: var(--primary-dark); }
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-logo-img {
|
||||
height: 80px;
|
||||
width: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-header h2 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
@@ -1231,9 +1252,9 @@ a:hover { color: var(--primary-dark); }
|
||||
}
|
||||
|
||||
.merged-disponibility-time-block.selected {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
background: #6366f1 !important;
|
||||
color: white !important;
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
.merged-disponibility-time-block.high-availability {
|
||||
@@ -1266,10 +1287,10 @@ a:hover { color: var(--primary-dark); }
|
||||
|
||||
/* Then specific states override the generic rule */
|
||||
[data-theme="dark"] .merged-disponibility-time-block.selected {
|
||||
background: var(--success) !important;
|
||||
background: #6366f1 !important;
|
||||
color: white !important;
|
||||
border-color: var(--success) !important;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.5);
|
||||
border-color: #818cf8 !important;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .merged-disponibility-time-block.high-availability {
|
||||
@@ -1948,3 +1969,71 @@ a:hover { color: var(--primary-dark); }
|
||||
[data-theme="dark"] .error-container p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Language switcher
|
||||
========================================================================= */
|
||||
|
||||
.nav-language {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-language .lang-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-language .lang-link:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav-language .lang-current {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-language .lang-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.auth-language {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-language .lang-link { color: var(--text-secondary); text-decoration: none; }
|
||||
.auth-language .lang-link:hover { color: var(--primary); text-decoration: underline; }
|
||||
.auth-language .lang-current { font-weight: 600; color: var(--text-primary); }
|
||||
.auth-language .lang-separator { opacity: 0.4; }
|
||||
|
||||
/* Visually hidden, still announced by screen readers. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Honeypot: hidden from everyone, filled in only by a robot (SEC-AUTH-008).
|
||||
The opposite of .sr-only above — that one hides from the eye and keeps the
|
||||
announcement, this one has to hide from both. display:none is deliberate:
|
||||
an off-screen input is still reachable by keyboard and by a screen reader,
|
||||
and a person who lands in it gets refused with no idea why. */
|
||||
.honeypot {
|
||||
display: none;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
@@ -437,3 +437,161 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}, 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
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# supporting scripts package
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Database and document backup for the Team Tryouts application.
|
||||
|
||||
Dumps the PostgreSQL database with pg_dump and archives the uploaded
|
||||
contract documents. Designed to be run from a scheduled task (Windows Task
|
||||
Scheduler) or a cron job.
|
||||
|
||||
Usage:
|
||||
python app/supporting_scripts/backup.py
|
||||
python app/supporting_scripts/backup.py --verify-only <archive>
|
||||
|
||||
Configuration via environment variables:
|
||||
DATABASE_URL PostgreSQL connection string (required)
|
||||
BACKUP_DIR Where to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS How long to keep them (default: 30)
|
||||
PG_DUMP Path to pg_dump if not on PATH
|
||||
PG_RESTORE Path to pg_restore if not on PATH
|
||||
|
||||
A note on what this file used to be
|
||||
-----------------------------------
|
||||
The previous version targeted **SQLite**: it imported sqlite3, read
|
||||
DATABASE_PATH defaulting to instance/team_tryouts.db, and used the sqlite3
|
||||
backup API. Production runs on PostgreSQL, so the file never existed, the
|
||||
script printed "[WARNING] Database not found... Skipping database backup"
|
||||
and — because main() only tracked the verification result — still exited 0.
|
||||
It reported success while backing up nothing at all. Any scheduled task
|
||||
watching the exit code saw green.
|
||||
|
||||
Restoring is documented in docs/restauration-base.md. A backup that has
|
||||
never been restored is not a backup.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
# Run as `python app/supporting_scripts/backup.py`, sys.path[0] is this
|
||||
# script's directory, so the application package is not importable. It has
|
||||
# to be — see DOCUMENTS_DIR below.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = backups_root()
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
||||
PG_DUMP = os.getenv('PG_DUMP', 'pg_dump')
|
||||
PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
|
||||
|
||||
# There is deliberately no DOCUMENTS_DIR constant any more. It held
|
||||
# `os.path.join(os.getcwd(), 'documents')`, which had stopped being true:
|
||||
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
||||
# keep uploads outside the releases, and docs/deployment.md now tells the
|
||||
# operator to set it — at which point this script archived a directory the
|
||||
# application had never written to. A missing or unreadable document store
|
||||
# is now a failed full-backup run rather than a database-only green result.
|
||||
#
|
||||
# So the more correctly an operator followed the deployment documentation,
|
||||
# the more certainly their contract backups were empty (OBS-006).
|
||||
#
|
||||
# backup_documents() now asks app.storage, at call time, the same question
|
||||
# the upload path asks. One source of truth, and one that a test can move.
|
||||
|
||||
|
||||
class BackupError(Exception):
|
||||
"""Raised when a backup step fails in a way that must stop the run."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_database_url(url):
|
||||
"""Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings.
|
||||
|
||||
Accepts the dialect suffixes SQLAlchemy uses (postgresql+psycopg://),
|
||||
which pg_dump does not understand.
|
||||
|
||||
Args:
|
||||
url: The connection string.
|
||||
|
||||
Returns:
|
||||
dict: host, port, dbname, user, password.
|
||||
|
||||
Raises:
|
||||
BackupError: If the URL is missing or is not a PostgreSQL one.
|
||||
"""
|
||||
if not url:
|
||||
raise BackupError('DATABASE_URL is not set.')
|
||||
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.split('+')[0]
|
||||
if scheme not in ('postgresql', 'postgres'):
|
||||
raise BackupError(
|
||||
f'DATABASE_URL is not a PostgreSQL connection string (scheme: {scheme!r}). '
|
||||
'This script only backs up PostgreSQL.'
|
||||
)
|
||||
|
||||
dbname = (parsed.path or '').lstrip('/')
|
||||
if not dbname:
|
||||
raise BackupError('DATABASE_URL does not name a database.')
|
||||
|
||||
return {
|
||||
'host': parsed.hostname or 'localhost',
|
||||
'port': str(parsed.port or 5432),
|
||||
'dbname': dbname,
|
||||
'user': unquote(parsed.username) if parsed.username else '',
|
||||
'password': unquote(parsed.password) if parsed.password else '',
|
||||
}
|
||||
|
||||
|
||||
def describe_target(conn):
|
||||
"""Human-readable target, deliberately without the password."""
|
||||
user = f'{conn["user"]}@' if conn['user'] else ''
|
||||
return f'{user}{conn["host"]}:{conn["port"]}/{conn["dbname"]}'
|
||||
|
||||
|
||||
def build_dump_command(conn, output_path):
|
||||
"""Assemble the pg_dump invocation.
|
||||
|
||||
--format=custom is compressed and lets pg_restore rebuild selectively;
|
||||
plain SQL would be larger and all-or-nothing.
|
||||
|
||||
The password is never placed on the command line — it would be visible
|
||||
to anyone able to list processes. It travels through PGPASSWORD instead,
|
||||
which is what pg_dump documents for non-interactive use.
|
||||
"""
|
||||
return [
|
||||
PG_DUMP,
|
||||
'--host',
|
||||
conn['host'],
|
||||
'--port',
|
||||
conn['port'],
|
||||
'--username',
|
||||
conn['user'],
|
||||
'--dbname',
|
||||
conn['dbname'],
|
||||
'--format=custom',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
'--file',
|
||||
output_path,
|
||||
]
|
||||
|
||||
|
||||
def dump_environment(conn):
|
||||
"""Environment for pg_dump/pg_restore, carrying the password out of argv."""
|
||||
env = os.environ.copy()
|
||||
if conn['password']:
|
||||
env['PGPASSWORD'] = conn['password']
|
||||
return env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_backup_dir():
|
||||
"""Create the backup directory if it doesn't exist."""
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def backup_database(conn):
|
||||
"""Dump the PostgreSQL database.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive.
|
||||
|
||||
Raises:
|
||||
BackupError: If pg_dump is missing, fails, or produces nothing.
|
||||
"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = os.path.join(BACKUP_DIR, f'db_backup_{timestamp}.dump')
|
||||
|
||||
print(f'[INFO] Dumping {describe_target(conn)}')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
build_dump_command(conn, backup_path),
|
||||
env=dump_environment(conn),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
except FileNotFoundError as err:
|
||||
raise BackupError(
|
||||
f'{PG_DUMP} not found. Install the PostgreSQL client tools, or set '
|
||||
'PG_DUMP to its full path.'
|
||||
) from err
|
||||
except subprocess.TimeoutExpired as err:
|
||||
raise BackupError('pg_dump timed out after 15 minutes.') from err
|
||||
|
||||
if result.returncode != 0:
|
||||
raise BackupError(f'pg_dump failed: {result.stderr.strip()}')
|
||||
|
||||
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
|
||||
raise BackupError('pg_dump reported success but produced an empty file.')
|
||||
|
||||
size_mb = os.path.getsize(backup_path) / (1024 * 1024)
|
||||
print(f'[OK] Database backed up to: {backup_path} ({size_mb:.1f} MB)')
|
||||
return backup_path
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Check that the archive is readable and actually contains tables.
|
||||
|
||||
pg_restore --list parses the whole archive without touching any
|
||||
database. A dump that cannot be listed cannot be restored, and an
|
||||
archive holding no table would mean the dump ran against the wrong
|
||||
target — both are silent failures worth catching here rather than
|
||||
during an incident.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the archive to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if the archive looks restorable.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
print('[ERROR] Nothing to verify.')
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[PG_RESTORE, '--list', backup_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(f'[WARNING] {PG_RESTORE} not found: archive left unverified.')
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[ERROR] pg_restore --list timed out.')
|
||||
return False
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f'[ERROR] Archive is not readable: {result.stderr.strip()}')
|
||||
return False
|
||||
|
||||
table_count = sum(1 for line in result.stdout.splitlines() if ' TABLE DATA ' in line)
|
||||
if table_count == 0:
|
||||
print('[ERROR] Archive contains no table data.')
|
||||
return False
|
||||
|
||||
print(f'[OK] Archive verified: {table_count} table(s) present.')
|
||||
return True
|
||||
|
||||
|
||||
def backup_documents():
|
||||
"""Archive the uploaded contract documents directory.
|
||||
|
||||
The directory is resolved through `app.storage.documents_root()` — the
|
||||
same function the upload path uses — so that setting DOCUMENTS_ROOT
|
||||
moves both together. Resolved here rather than at import, so that what
|
||||
is backed up depends on the environment the run has, not on the one the
|
||||
module happened to be imported with.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive.
|
||||
|
||||
Raises:
|
||||
BackupError: If the configured store is absent or cannot be archived.
|
||||
Signed contracts live only on disk, so a database-only run must
|
||||
never be reported as a complete backup.
|
||||
"""
|
||||
documents_dir = documents_root()
|
||||
|
||||
if not os.path.exists(documents_dir):
|
||||
raise BackupError(f'Documents directory does not exist: {documents_dir}')
|
||||
if not os.path.isdir(documents_dir):
|
||||
raise BackupError(f'Documents path is not a directory: {documents_dir}')
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
||||
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
|
||||
raise BackupError(f'Document backup failed: {exc}') from exc
|
||||
|
||||
zip_path = f'{archive_basename}.zip'
|
||||
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
|
||||
raise BackupError('Document archiver reported success but produced an empty file.')
|
||||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||
return zip_path
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||
if not os.path.exists(BACKUP_DIR):
|
||||
return
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
|
||||
removed_count = 0
|
||||
|
||||
for filename in os.listdir(BACKUP_DIR):
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
if datetime.fromtimestamp(os.path.getmtime(file_path)) >= cutoff:
|
||||
continue
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||
except OSError as exc:
|
||||
print(f'[WARNING] Could not remove {filename}: {exc}')
|
||||
|
||||
if removed_count:
|
||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||
else:
|
||||
print('[CLEANUP] No old backups to remove.')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""Run the full backup process.
|
||||
|
||||
Returns:
|
||||
int: 0 when the database was dumped AND verified, 1 otherwise. The
|
||||
previous version returned 0 even when it had backed up nothing.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='Team Tryouts backup')
|
||||
parser.add_argument(
|
||||
'--verify-only', metavar='ARCHIVE', help='Verify an existing archive and exit'
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.verify_only:
|
||||
return 0 if verify_backup(args.verify_only) else 1
|
||||
|
||||
print('=== Team Tryouts Backup ===')
|
||||
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print(f'Backup directory: {BACKUP_DIR}')
|
||||
# Printed because it is the value that was wrong for months without
|
||||
# anyone being able to see it from the output.
|
||||
print(f'Document source: {documents_root()}')
|
||||
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
||||
print()
|
||||
|
||||
try:
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
create_backup_dir()
|
||||
backup_path = backup_database(conn)
|
||||
except BackupError as exc:
|
||||
print(f'[ERROR] {exc}')
|
||||
print('\n=== Backup FAILED — no database backup was produced ===')
|
||||
return 1
|
||||
|
||||
verified = verify_backup(backup_path)
|
||||
documents_ok = True
|
||||
try:
|
||||
backup_documents()
|
||||
except BackupError as exc:
|
||||
documents_ok = False
|
||||
print(f'[ERROR] {exc}')
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if verified and documents_ok:
|
||||
print('=== Backup completed successfully ===')
|
||||
return 0
|
||||
|
||||
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -6,19 +6,23 @@ This script performs pre-deployment security checks to validate:
|
||||
- Debug mode status
|
||||
- HTTPS configuration
|
||||
- Dependency vulnerabilities
|
||||
- Database connectivity
|
||||
- Required database configuration
|
||||
|
||||
Usage:
|
||||
python security_scan.py [--url http://localhost:5000]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
||||
|
||||
|
||||
def check_environment():
|
||||
@@ -31,8 +35,8 @@ def check_environment():
|
||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
critical_vars = ['SECRET_KEY']
|
||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
||||
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
|
||||
recommended_vars = ['CORS_ALLOWED_ORIGINS']
|
||||
all_ok = True
|
||||
|
||||
for var in critical_vars:
|
||||
@@ -58,7 +62,8 @@ def check_environment():
|
||||
# Check FLASK_DEBUG
|
||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||
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:
|
||||
print('[OK] FLASK_DEBUG is disabled')
|
||||
|
||||
@@ -91,10 +96,11 @@ def check_https_headers(url):
|
||||
all_ok = True
|
||||
|
||||
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.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
|
||||
@@ -128,7 +134,9 @@ def check_https_headers(url):
|
||||
all_ok = False
|
||||
|
||||
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:
|
||||
print('[WARN] Cookies missing SameSite attribute')
|
||||
all_ok = False
|
||||
@@ -146,9 +154,9 @@ def check_https_headers(url):
|
||||
all_ok = False
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
||||
print('[SKIP] Run with --url <application_url> to check headers')
|
||||
return True # Not a failure, just can't check
|
||||
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
|
||||
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
||||
return False
|
||||
|
||||
return all_ok
|
||||
|
||||
@@ -165,38 +173,52 @@ def check_dependencies():
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
||||
[
|
||||
sys.executable,
|
||||
'-m',
|
||||
'pip_audit',
|
||||
'--requirement',
|
||||
str(REQUIREMENTS_FILE),
|
||||
'--format',
|
||||
'json',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print('[OK] No known vulnerabilities found')
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
vulns = data.get('dependencies', [])
|
||||
if vulns:
|
||||
for vuln in vulns:
|
||||
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}')
|
||||
return False
|
||||
else:
|
||||
print('[OK] No vulnerabilities found')
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
if result.stdout:
|
||||
print(f'[INFO] {result.stdout.strip()}')
|
||||
if result.stderr:
|
||||
print(f'[WARN] {result.stderr.strip()}')
|
||||
return True
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
# pip-audit's "dependencies" array lists EVERY dependency, each
|
||||
# carrying a "vulns" list that is empty when the package is
|
||||
# clean. Treating the array itself as the vulnerability list
|
||||
# reported all ~45 installed packages as vulnerable on every
|
||||
# run, which is why this check was pure noise.
|
||||
affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
|
||||
if affected:
|
||||
for dep in affected:
|
||||
ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
|
||||
print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
|
||||
return False
|
||||
print('[OK] No vulnerabilities found')
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
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:
|
||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
||||
return True
|
||||
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[WARN] pip-audit timed out')
|
||||
return True
|
||||
print('[FAIL] pip-audit timed out')
|
||||
return False
|
||||
|
||||
|
||||
def check_file_permissions():
|
||||
@@ -215,7 +237,7 @@ def check_file_permissions():
|
||||
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
|
||||
if os.path.exists(gitignore_path):
|
||||
required_patterns = ['.env', 'instance/', '*.db', '*.log']
|
||||
with open(gitignore_path, 'r') as f:
|
||||
with open(gitignore_path) as f:
|
||||
content = f.read()
|
||||
|
||||
for pattern in required_patterns:
|
||||
@@ -237,7 +259,7 @@ def check_file_permissions():
|
||||
|
||||
# Check for leftover .pyc or __pycache__
|
||||
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:
|
||||
pycache_count += 1
|
||||
for f in files:
|
||||
@@ -264,8 +286,22 @@ def check_flask_config():
|
||||
all_ok = True
|
||||
|
||||
try:
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
# 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
|
||||
|
||||
# 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
|
||||
cookie_checks = [
|
||||
@@ -312,8 +348,17 @@ def check_flask_config():
|
||||
else:
|
||||
print('[OK] DEBUG mode: disabled')
|
||||
|
||||
except Exception as e:
|
||||
print(f'[SKIP] Cannot check Flask config: {e}')
|
||||
except Exception as e: # noqa: BLE001 — any failure to load the app is a failed check
|
||||
# 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
|
||||
|
||||
@@ -327,19 +372,32 @@ def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Security validation scanner')
|
||||
parser.add_argument('--url', default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)')
|
||||
parser.add_argument(
|
||||
'--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()
|
||||
|
||||
print('╔══════════════════════════════════════════════════════════╗')
|
||||
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║')
|
||||
print('╠══════════════════════════════════════════════════════════╣')
|
||||
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print('╚══════════════════════════════════════════════════════════╝')
|
||||
# Plain ASCII: the box-drawing characters this banner used crashed the
|
||||
# script outright on a cp1252 Windows console, which is the platform the
|
||||
# project is developed and deployed on.
|
||||
print('=' * 60)
|
||||
print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER')
|
||||
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print('=' * 60)
|
||||
|
||||
checks = [
|
||||
check_environment,
|
||||
lambda: check_https_headers(args.url),
|
||||
checks = [check_environment]
|
||||
if args.skip_http:
|
||||
print('\n[SKIP] HTTP header check disabled via --skip-http')
|
||||
else:
|
||||
checks.append(lambda: check_https_headers(args.url))
|
||||
checks += [
|
||||
check_dependencies,
|
||||
check_file_permissions,
|
||||
check_flask_config,
|
||||
@@ -363,9 +421,8 @@ def main():
|
||||
if failed == 0:
|
||||
print('\n[OK] All security checks passed!')
|
||||
return 0
|
||||
else:
|
||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||
return 1
|
||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('400 Bad Request') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Bad Request') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h2>{{ _('400 — Bad Request') }}</h2>
|
||||
<p>{{ _('The request could not be understood by the server. Please check your input and try again.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('403 Forbidden') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Access Denied') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h2>{{ _('403 — Forbidden') }}</h2>
|
||||
<p>{{ _('You do not have permission to access this resource. If you believe this is an error, please contact an administrator.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('404 Not Found') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Page Not Found') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h2>{{ _('404 — Not Found') }}</h2>
|
||||
<p>{{ _('The page you are looking for does not exist. It may have been moved or deleted.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i> {{ _('Return Home') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('429 Too Many Requests') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Rate Limit Exceeded') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<h2>{{ _('429 — Too Many Requests') }}</h2>
|
||||
<p>{{ _('You have sent too many requests in a short period. Please wait a moment and try again.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('500 Server Error') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Internal Server Error') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-cogs"></i>
|
||||
</div>
|
||||
<h2>{{ _('500 — Internal Server Error') }}</h2>
|
||||
<p>{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}</p>
|
||||
{# The reference is what makes a report actionable: it names one request
|
||||
in errors.log. It identifies nothing else — no session, no account —
|
||||
so there is nothing to protect here (OBS-005). #}
|
||||
{% if request_id and request_id != '-' %}
|
||||
<p class="text-muted small">{{ _('Reference to quote if you report this:') }} <code>{{ request_id }}</code></p>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{# Language switcher.
|
||||
|
||||
Included from both branches of the layout: the signed-in sidebar and the
|
||||
anonymous authentication page. Someone who cannot read the current
|
||||
language has to be able to change it *before* signing in, so this cannot
|
||||
live behind the login.
|
||||
|
||||
Each language is written in its own language, for the same reason. #}
|
||||
<i class="fas fa-language" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{ _('Language') }}</span>
|
||||
{% for code in supported_locales %}
|
||||
{%- if code == current_locale %}
|
||||
<span class="lang-current" aria-current="true">{{ locale_names[code] }}</span>
|
||||
{%- else %}
|
||||
<a href="{{ url_for('main.set_language', locale=code) }}"
|
||||
class="lang-link" hreflang="{{ code }}" rel="alternate">{{ locale_names[code] }}</a>
|
||||
{%- endif %}
|
||||
{%- if not loop.last %}<span class="lang-separator" aria-hidden="true">·</span>{% endif %}
|
||||
{% endfor %}
|
||||
@@ -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">« {{ _('Previous') }}</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary btn-sm is-disabled" aria-disabled="true">« {{ _('Previous') }}</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="pagination-status">
|
||||
{{ _('Page %(page)s of %(pages)s', page=pagination.page, pages=pagination.pages) }}
|
||||
·
|
||||
{{ _('%(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') }} »</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary btn-sm is-disabled" aria-disabled="true">{{ _('Next') }} »</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -1,10 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ current_locale }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Team Tryout Management{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<title>{% block title %}UdeS team manager{% endblock %}</title>
|
||||
{# 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="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>
|
||||
@@ -13,8 +28,8 @@
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<i class="fas fa-trophy"></i>
|
||||
<span>TryoutPro</span>
|
||||
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="logo-img">
|
||||
<span>UdeS team manager</span>
|
||||
</div>
|
||||
<div class="user-badge">
|
||||
<div class="user-avatar">
|
||||
@@ -30,103 +45,111 @@
|
||||
<li>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-th-large"></i>
|
||||
<span>Dashboard</span>
|
||||
<span>{{ _('Dashboard') }}</span>
|
||||
</a>
|
||||
</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 %}">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
<span>Tryouts</span>
|
||||
<span>{{ _('Tryouts') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
<span>{{ _('Calendar') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<li>
|
||||
<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>
|
||||
<span>Evaluations</span>
|
||||
<span>{{ _('Evaluations') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' %}
|
||||
<li>
|
||||
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Teams</span>
|
||||
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>{{ _('My Team(s)') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>{{ _('Manage Teams') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.can_manage_users() %}
|
||||
<li>
|
||||
<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>
|
||||
<span>Manage Users</span>
|
||||
<span>{{ _('Manage Users') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>My Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.role == 'player' %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.one_on_one') }}" class="{% if request.endpoint == 'users.one_on_one' %}active{% endif %}">
|
||||
<i class="fas fa-calendar-check"></i>
|
||||
<span>One on One</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span>My Notes</span>
|
||||
<span>{{ _('My Notes') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'coach' %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.manage_coach_availability') }}" class="{% if request.endpoint == 'users.manage_coach_availability' %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Availability</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<span>Notes</span>
|
||||
<span>{{ _('Notes & One on One') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<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>
|
||||
<span>Contracts</span>
|
||||
<span>{{ _('Contracts') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-divider"></li>
|
||||
<li>
|
||||
<a href="{{ url_for('auth.logout') }}" class="logout-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>{{ _('My Profile') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{# A form, not a link: logging out is a state change, and a
|
||||
GET route carries no CSRF token — any site could sign the
|
||||
user out with an <img> tag. Styled as a nav entry. #}
|
||||
<form method="POST" action="{{ url_for('auth.logout') }}" class="nav-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="logout-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>{{ _('Logout') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li class="nav-divider"></li>
|
||||
<li class="nav-language">
|
||||
{% include "layouts/_language_switcher.html" %}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="main-content" id="mainContent">
|
||||
<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>
|
||||
</button>
|
||||
<div class="page-header">
|
||||
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
|
||||
<h1>{% block page_title %}{{ _('Dashboard') }}{% endblock %}</h1>
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
</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>
|
||||
</button>
|
||||
{% block header_actions %}{% endblock %}
|
||||
@@ -137,7 +160,8 @@
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
<button type="button" class="alert-close" data-action="dismiss-alert"
|
||||
aria-label="{{ _('Dismiss') }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -155,7 +179,8 @@
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
<button type="button" class="alert-close" data-action="dismiss-alert"
|
||||
aria-label="{{ _('Dismiss') }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -163,10 +188,27 @@
|
||||
</div>
|
||||
<div class="auth-container">
|
||||
<div class="auth-header">
|
||||
<i class="fas fa-trophy"></i>
|
||||
<h2>TryoutPro</h2>
|
||||
<p>Team Tryout Management System</p>
|
||||
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="auth-logo-img">
|
||||
<h2>UdeS team manager</h2>
|
||||
<p>{{ _('UdeS team manager') }}</p>
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user