Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5903b131 | ||
|
|
bd9ed4bdc7 | ||
|
|
a9b33b30f5 | ||
|
|
32c112ceea | ||
|
|
30069d782f | ||
|
|
d994d88c12 | ||
|
|
b9791d1547 | ||
|
|
4a2e2f053f | ||
|
|
e2e2b1b07e | ||
|
|
e4a271cfd8 | ||
|
|
742995d3cd | ||
|
|
cf551867c9 | ||
|
|
b43c5b1734 | ||
|
|
85eca9edd4 | ||
|
|
c7b321a636 | ||
|
|
90c00ad29b | ||
|
|
bfb25f4d16 | ||
|
|
820cccb25e | ||
|
|
38defc53a5 | ||
|
|
d18979a3e9 | ||
|
|
1549fbaef3 | ||
|
|
c72ed9b0b1 | ||
|
|
6232b77094 | ||
|
|
a616c79663 | ||
|
|
48ca62cdd0 | ||
|
|
1bef716a12 | ||
|
|
a0e31d1a2f | ||
|
|
e15b3c1293 | ||
|
|
d7a8907953 | ||
|
|
105a72700f | ||
|
|
f84cb4e3b6 | ||
|
|
06508cc7d6 | ||
|
|
9647003c3f | ||
|
|
437b229c82 | ||
|
|
3b7b9182d7 | ||
|
|
20dabecd67 | ||
|
|
838b247649 | ||
|
|
9166d8abeb | ||
|
|
9cedf4a038 | ||
|
|
dd8b9671c6 | ||
|
|
ad3dea6a15 | ||
|
|
8d7de75e99 | ||
|
|
06d6ad7eaa | ||
|
|
e76cd7bb23 | ||
|
|
bda23dbb67 | ||
|
|
66f2838402 | ||
|
|
546f28571b | ||
|
|
7a1dab21cd | ||
|
|
709e8a5d51 | ||
|
|
70db8a7491 | ||
|
|
7b9eee4805 | ||
|
|
3882b6035f | ||
|
|
39808dd04e | ||
|
|
506a061405 | ||
|
|
c5e5cfa014 | ||
|
|
0308eb9eef | ||
|
|
d8541678a6 | ||
|
|
d0a9e75fe6 | ||
|
|
47ff544848 | ||
|
|
ab0b975211 | ||
|
|
aebc28fb8a | ||
|
|
e5c29d8113 | ||
|
|
2e10bbbd62 | ||
|
|
9d0456c2fd | ||
|
|
0cd9a186ee | ||
|
|
5c87064a17 | ||
|
|
8e3865f557 | ||
|
|
7cec18c139 | ||
|
|
2f40290f00 | ||
|
|
51877b46a0 | ||
|
|
20158a9e7a | ||
|
|
835a394d3f | ||
|
|
d85da32ef5 | ||
|
|
92d72e4d48 | ||
|
|
9fc4ba0b98 | ||
|
|
37f70c89e3 | ||
|
|
80bc1413f1 | ||
|
|
fcb58e8a17 | ||
|
|
09453199b8 | ||
|
|
15bfebf4fc | ||
|
|
d15a3de2a1 | ||
|
|
a92600c305 | ||
|
|
afab7070fb | ||
|
|
7bf428f9a0 | ||
|
|
ca45be80db | ||
|
|
ab44258d72 | ||
|
|
983b7a1f49 | ||
|
|
d05e9cde32 | ||
|
|
32f193c008 | ||
|
|
5ecea55f55 | ||
|
|
a793b7ed0d | ||
|
|
b277c453f9 | ||
|
|
1b990a84d9 | ||
|
|
de9448a9aa | ||
|
|
2c37c05c8f | ||
|
|
56aee7f3f3 | ||
|
|
90866f830e | ||
|
|
ed586233f6 | ||
|
|
fa0a378827 | ||
|
|
53e390672e |
@@ -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
|
||||
@@ -6,16 +6,89 @@ on:
|
||||
# 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@v7
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Install lftp and ssh
|
||||
run: sudo apt-get update && sudo apt-get install -y lftp openssh-client
|
||||
- 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:
|
||||
@@ -29,14 +102,51 @@ jobs:
|
||||
|
||||
- name: Push files via SFTP with progress
|
||||
run: |
|
||||
# The mirror command below uses the -R (reverse) flag
|
||||
# to push from local './' to remote './'
|
||||
# Connection is made using 'open' inside the execution block to enforce SSH key usage
|
||||
# --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 ./ ./; \
|
||||
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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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
|
||||
|
||||
+148
-21
@@ -1,27 +1,154 @@
|
||||
.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/
|
||||
*.db
|
||||
|
||||
documents/
|
||||
|
||||
__pycache__/
|
||||
*.cpython-313.pyc
|
||||
*.cpython-312.pyc
|
||||
*.pyc
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
||||
htmlcov/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
.certs/
|
||||
*.key
|
||||
*.pem
|
||||
*.pfx
|
||||
*.p12
|
||||
.certs/
|
||||
id_rsa
|
||||
id_ed25519
|
||||
known_hosts
|
||||
credentials.json
|
||||
service-account*.json
|
||||
|
||||
docs/
|
||||
*.html
|
||||
# =============================================================================
|
||||
# Assistants IA — Claude
|
||||
# =============================================================================
|
||||
.claude/
|
||||
.claude.json
|
||||
.claude/settings.local.json
|
||||
CLAUDE.md
|
||||
CLAUDE.local.md
|
||||
.anthropic/
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — ChatGPT / OpenAI
|
||||
# =============================================================================
|
||||
.chatgpt/
|
||||
.openai/
|
||||
.codex/
|
||||
AGENTS.md
|
||||
chatgpt-*.md
|
||||
openai-*.md
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — autres outils
|
||||
# =============================================================================
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.cursorignore
|
||||
.windsurf/
|
||||
.windsurfrules
|
||||
.aider*
|
||||
.continue/
|
||||
.clinerules
|
||||
.roo/
|
||||
.gemini/
|
||||
GEMINI.md
|
||||
.github/copilot-instructions.md
|
||||
.copilot/
|
||||
|
||||
# =============================================================================
|
||||
# Notes de travail et suivi générés par assistant IA
|
||||
# =============================================================================
|
||||
# Rapports d'audit, plans, brouillons et notes de session : documents de
|
||||
# travail, non destinés à être versionnés dans le dépôt applicatif.
|
||||
.ai/
|
||||
audit/
|
||||
*.audit.md
|
||||
NOTES-IA.md
|
||||
TODO-IA.md
|
||||
|
||||
# =============================================================================
|
||||
# Python
|
||||
# =============================================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environnements virtuels
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Outils
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
coverage_html_report/
|
||||
coverage.xml
|
||||
|
||||
# =============================================================================
|
||||
# Données applicatives — ne jamais versionner
|
||||
# =============================================================================
|
||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||
# voir l'historique du commit `4fd27ba`).
|
||||
# Données PostgreSQL locales — gérées par app/local_postgres.py
|
||||
instance/pgdata/
|
||||
instance/pg.log
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Contrats téléversés (données personnelles)
|
||||
documents/
|
||||
|
||||
# Sauvegardes produites par app/supporting_scripts/backup.py
|
||||
backups/
|
||||
|
||||
# Journaux applicatifs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# État d'exécution du bot Discord
|
||||
discord_pending.json
|
||||
|
||||
# =============================================================================
|
||||
# Éditeurs et systèmes d'exploitation
|
||||
# =============================================================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# =============================================================================
|
||||
# NOTE — règles retirées volontairement
|
||||
# =============================================================================
|
||||
# `docs/` et `*.html` figuraient ici et ignoraient TOUT fichier .html du
|
||||
# dépôt, y compris les templates Jinja2 de app/templates/. Un nouveau
|
||||
# template était donc invisible pour git : l'application fonctionnait en
|
||||
# local et cassait en production avec une TemplateNotFound, sans que
|
||||
# `git status` ne signale quoi que ce soit.
|
||||
#
|
||||
# Les rapports de couverture HTML, qui étaient vraisemblablement la cible
|
||||
# de ces règles, sont couverts ci-dessus par `htmlcov/` et
|
||||
# `coverage_html_report/`.
|
||||
|
||||
# =============================================================================
|
||||
# Internationalisation
|
||||
# =============================================================================
|
||||
# Le gabarit de catalogue est entierement regenerable :
|
||||
# pybabel extract -F babel.cfg -k _l -o messages.pot .
|
||||
# Les catalogues .po (sources de traduction) et .mo (compiles, lus a
|
||||
# l'execution) sont eux versionnes : le deploiement est un simple miroir de
|
||||
# fichiers, sans etape de compilation.
|
||||
messages.pot
|
||||
|
||||
@@ -1,90 +1,153 @@
|
||||
# Plateforme centralisée de tryouts
|
||||
|
||||
## Security Configuration
|
||||
Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
|
||||
évaluations, gestion des équipes, disponibilités, contrats, et notifications
|
||||
Discord.
|
||||
|
||||
### Required Environment Variables
|
||||
Le site est servi **en français**, l'anglais reste accessible par le sélecteur
|
||||
de la barre latérale (voir `docs/translations.md`).
|
||||
|
||||
Before deploying, create a `.env` file which integrates everything in the .env.exemple.
|
||||
Ensure you follow the comments of the exemple if you are to use this tool in production.
|
||||
---
|
||||
|
||||
## Démarrer
|
||||
|
||||
### Security Features Implemented
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
|
||||
cp app/.env.example .env # puis remplir SECRET_KEY et DATABASE_URL
|
||||
.venv/Scripts/python run.py # développement, http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
|
||||
- **Secure Session Cookies**: HTTPSOnly, 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
|
||||
- **nginx**: reverse-proxy and load balancer
|
||||
- **Waitress WSGI**: Production ready WSGI
|
||||
`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.
|
||||
|
||||
### When true in .env:
|
||||
- **Forces HTTPS only**
|
||||
- **Forcer secure cookies**
|
||||
Production : `python wsgi.py` (Waitress derrière nginx). Voir
|
||||
`docs/deployment.md`.
|
||||
|
||||
## App details
|
||||
Ce sont les **deux seuls** points d'entrée.
|
||||
|
||||
### Code
|
||||
## Vérifier
|
||||
|
||||
- Full python backend using flask
|
||||
- statics are pure HTML and CSS
|
||||
- Some js to add logic to styling and showing certain pages/cards
|
||||
```bash
|
||||
.venv/Scripts/python -m pytest # suite complète
|
||||
.venv/Scripts/python -m ruff check . # lint
|
||||
.venv/Scripts/python -m ruff format --check .
|
||||
```
|
||||
|
||||
### Functionalities
|
||||
Les trois tournent en CI et y sont bloquants.
|
||||
|
||||
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role.
|
||||
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
|
||||
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
|
||||
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
|
||||
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
|
||||
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
|
||||
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
|
||||
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
|
||||
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
|
||||
---
|
||||
|
||||
## Ce que fait l'application
|
||||
|
||||
## Discord Integration
|
||||
- **Comptes et rôles** — cinq rôles : président (`admin`), gérant
|
||||
(`manager`), coach, joueur (`player`), recruteur (`scout`). Le président
|
||||
attribue les rôles.
|
||||
- **Sélections** — organisation des tryouts, trois formats de match
|
||||
(équipe contre équipe, joueur contre joueur, scrim), évaluation des
|
||||
joueurs sur dix critères.
|
||||
- **Équipes** — effectifs de la saison, matchs et entraînements. Le
|
||||
formulaire d'entraînement affiche les disponibilités des joueurs.
|
||||
- **Disponibilités** — créneaux hebdomadaires des joueurs, créneaux
|
||||
réservables des coachs.
|
||||
- **Notes** — un coach écrit des notes d'équipe (visibles par l'équipe) et
|
||||
des notes nominatives (visibles par le joueur concerné).
|
||||
- **Un-à-un** — un joueur demande une séance à son coach ; le coach répond
|
||||
depuis le site ou par une réaction sur le message privé Discord.
|
||||
- **Contrats** — dépôt d'un contrat par le staff, signature par le joueur.
|
||||
|
||||
The application supports sending Discord direct messages to coaches when players request One on One sessions,
|
||||
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
|
||||
## Comment c'est construit
|
||||
|
||||
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request.
|
||||
Same thing with **matches** and **practices**, the players can react or answer on the platform.
|
||||
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.
|
||||
|
||||
### Setup Instructions
|
||||
`docs/architecture.md` contient les diagrammes (classes, paquets, flux
|
||||
d'une requête).
|
||||
|
||||
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
|
||||
---
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
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)
|
||||
## Sécurité
|
||||
|
||||
#### 2. Add Bot to your server
|
||||
En place et vérifié par des tests :
|
||||
|
||||
For the bot to send DMs:
|
||||
1. Each user must have the bot added to their Discord server OR be friends with the bot
|
||||
2. Users 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
|
||||
- **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 :
|
||||
|
||||
### How It Works
|
||||
- **Aucune migration de schéma.** `db.create_all()` crée les tables
|
||||
manquantes et ne modifie jamais une table existante : une colonne ajoutée
|
||||
à un modèle est absente de la production.
|
||||
- **Les secrets de l'historique git ne sont pas révoqués** — jeton du bot,
|
||||
mot de passe PostgreSQL, `SECRET_KEY`.
|
||||
- **`trusted_proxy='*'`** reste le défaut : l'en-tête `X-Forwarded-For` est
|
||||
accepté de n'importe quelle source, donc la limitation par IP est
|
||||
contournable. C'est désormais la variable `TRUSTED_PROXY` plutôt qu'une
|
||||
constante — `docs/deployment.md` donne la valeur pour chaque topologie.
|
||||
- **Les comptes créés par le formulaire d'inscription sont actifs
|
||||
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
|
||||
de produit en attente, voir `docs/roles-and-permissions.md`.
|
||||
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
|
||||
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
|
||||
snowflake et l'application refuse les nouvelles collisions. Les doublons
|
||||
historiques doivent être relevés puis corrigés avant la contrainte
|
||||
`UNIQUE` (`schema_report.py --check-discord-identities`).
|
||||
|
||||
When a player submits a One on One request:
|
||||
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
|
||||
`docs/security-checklist.md` détaille la liste avant mise en production.
|
||||
|
||||
### Message Format
|
||||
### Documentation
|
||||
|
||||
The Discord DM includes:
|
||||
- Player name
|
||||
- Team name
|
||||
- Requested date and time slot
|
||||
- Discussion points (if provided)
|
||||
- Link to the application for approval/rejection
|
||||
- Two provided reactions to accept or refuse via discord
|
||||
| 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é.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# Contract storage — Google Drive
|
||||
# =============================================================================
|
||||
|
||||
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
|
||||
# refresh token, and folder ID are secrets/configuration: do not commit them.
|
||||
# Use `local` only for isolated development and legacy-file maintenance.
|
||||
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||
|
||||
# Target folder in the owner's personal Drive. The application keeps every
|
||||
# contract directly in this folder and stores only each Drive file ID in the
|
||||
# database.
|
||||
GOOGLE_DRIVE_FOLDER_ID=
|
||||
|
||||
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
|
||||
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
|
||||
# refresh token here. See docs/deployment.md before enabling this in production.
|
||||
GOOGLE_DRIVE_CLIENT_ID=
|
||||
GOOGLE_DRIVE_CLIENT_SECRET=
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN=
|
||||
|
||||
# Local storage is retained only for historical rows and local test runs.
|
||||
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
|
||||
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
|
||||
#
|
||||
# LOCAL_PG=true start a local PostgreSQL server (requires PostgreSQL
|
||||
# installed on the system). Uses port 5433,
|
||||
# data in instance/pgdata/, database team_tryouts.
|
||||
#
|
||||
# LOCAL_PG_PORT=5433 override the PostgreSQL port
|
||||
# LOCAL_PG_USER=team_tryouts_db override the PostgreSQL user
|
||||
# LOCAL_PG_DATABASE=team_tryouts_db override the database name
|
||||
#
|
||||
#
|
||||
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||
# development session cannot accidentally inherit a production binding.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Team Tryouts Application - Environment Variables
|
||||
# Copy this file to .env and fill in the values for production
|
||||
|
||||
# Security Configuration
|
||||
# Generate a secure random secret key: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=flask_app_secret_key
|
||||
|
||||
# Set to 'true' in production to enable secure cookies (requires HTTPS)
|
||||
SESSION_COOKIE_SECURE=false
|
||||
FORCE_HTTPS=false
|
||||
|
||||
# Flask Debug Mode - Set to 'true' only in development
|
||||
FLASK_DEBUG=true
|
||||
|
||||
# Discord Bot Token (required for notifications)
|
||||
# This is the UdeS Esports BOT token, it will send notifications to people that have their
|
||||
# Dicord_User_ID in the db / remove if you don't want discord notifs.
|
||||
DISCORD_BOT_TOKEN=my_discord_bot_token
|
||||
|
||||
# Discord OAuth2 Configuration (for "Connect Discord" on sign-up page)
|
||||
# Create an application at https://discord.com/developers/applications
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||
|
||||
#where to find the db (hosted on render for now)
|
||||
DATABASE_URL=URI_vers_db_posgres
|
||||
|
||||
|
||||
#Where the app will be hosted (corresponds to: localhost:5000 in local)
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
+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
|
||||
+499
-92
@@ -5,16 +5,66 @@ the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||
from flask_cors import CORS
|
||||
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
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.api import json_endpoint
|
||||
from app.extensions import babel, csrf, db, limiter, login_manager
|
||||
from app.pagination import page_url
|
||||
from app.responders import current_revision, is_ajax_submit
|
||||
|
||||
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.
|
||||
@@ -26,13 +76,103 @@ def nl2br(value):
|
||||
Markup: HTML-safe string with line breaks.
|
||||
"""
|
||||
if value:
|
||||
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
|
||||
# Markup('<br>').join() escapes each segment before joining.
|
||||
# Markup('<br>'.join(...)) would mark attacker-controlled text as safe.
|
||||
return markupsafe.Markup('<br>').join(str(value).splitlines())
|
||||
return ''
|
||||
|
||||
|
||||
def create_app():
|
||||
def normalise_database_url(url):
|
||||
"""Name the PostgreSQL driver explicitly in a connection URL.
|
||||
|
||||
`postgresql://…` does not mean "whichever driver is installed": it means
|
||||
psycopg2, which SQLAlchemy imports at create_engine() time. requirements
|
||||
.txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean
|
||||
install starting against the URL Render hands out — and the one this
|
||||
project's own documentation shows — raises
|
||||
|
||||
ModuleNotFoundError: No module named 'psycopg2'
|
||||
|
||||
before the first request. Anything with a driver already spelled out
|
||||
(`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so
|
||||
naming psycopg2 stays possible for an environment that has it.
|
||||
|
||||
`postgres://` is the legacy alias several hosts still emit; SQLAlchemy
|
||||
dropped it in 1.4.
|
||||
|
||||
Args:
|
||||
url: Value of DATABASE_URL, or None.
|
||||
|
||||
Returns:
|
||||
str | None: The URL, with a driver named when it was PostgreSQL.
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
scheme, separator, rest = url.partition('://')
|
||||
if not separator or '+' in scheme:
|
||||
return url
|
||||
if scheme in ('postgres', 'postgresql'):
|
||||
return f'postgresql+psycopg://{rest}'
|
||||
return url
|
||||
|
||||
|
||||
def build_csp(*, allow_inline_script, nonce=None):
|
||||
"""Assemble the Content-Security-Policy header.
|
||||
|
||||
Two mutually exclusive modes, and they really are exclusive.
|
||||
|
||||
Under CSP level 3, a browser that understands nonces **ignores
|
||||
'unsafe-inline' entirely as soon as a nonce is present**. Emitting both
|
||||
would therefore not be a gentle transition: it would drop every inline
|
||||
script and every onclick attribute at once, in modern browsers only.
|
||||
The switch has to be atomic, which is why one flag drives it.
|
||||
|
||||
While allow_inline_script is true no nonce is emitted at all, so adding
|
||||
nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless.
|
||||
|
||||
Flipping the flag requires every inline event handler to be gone first.
|
||||
A nonce cannot authorise an onclick attribute — nonces apply to script
|
||||
elements, never to handler attributes. See tests/test_csp.py, which
|
||||
tracks how many are left.
|
||||
|
||||
Args:
|
||||
allow_inline_script: Keep 'unsafe-inline' in script-src.
|
||||
nonce: Per-request nonce, used only when inline script is not allowed.
|
||||
|
||||
Returns:
|
||||
str: The header value.
|
||||
"""
|
||||
if allow_inline_script:
|
||||
script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
else:
|
||||
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
|
||||
|
||||
return '; '.join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
f'script-src {script_src}',
|
||||
# style-src is a separate migration: inline style="" attributes are
|
||||
# spread across the templates and are not an XSS vector on their own.
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
|
||||
"font-src 'self' https://cdnjs.cloudflare.com",
|
||||
"img-src 'self' data: https://cdn.discordapp.com",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
"""Create and configure the Flask application.
|
||||
|
||||
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
|
||||
@@ -52,30 +192,103 @@ def create_app():
|
||||
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')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
||||
if not app.config['SQLALCHEMY_DATABASE_URI']:
|
||||
raise RuntimeError(
|
||||
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
|
||||
)
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
|
||||
# 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_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 = 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,
|
||||
@@ -84,33 +297,159 @@ def create_app():
|
||||
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)
|
||||
|
||||
# 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.before_request
|
||||
def reject_stale_ajax_write():
|
||||
"""Reject an AJAX edit whose view is stale, instead of overwriting.
|
||||
|
||||
The AJAX form handler in main.js sends the revision it last saw in the
|
||||
X-Revision header. If another client has committed since then the
|
||||
server's revision has moved on, and clobbering their change would be
|
||||
silent last-write-wins. Answer 409 so the client asks the user to
|
||||
reload the page first.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return None
|
||||
if not is_ajax_submit():
|
||||
return None
|
||||
sent = request.headers.get('X-Revision')
|
||||
if sent is None:
|
||||
return None
|
||||
try:
|
||||
sent = int(sent)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if sent != current_revision():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
'error': 'conflict',
|
||||
'message': _(
|
||||
'This page was changed by someone else. Reload to '
|
||||
'see the latest changes before saving.'
|
||||
),
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
return None
|
||||
|
||||
@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.admin import admin_bp
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.evaluations import evaluations_bp
|
||||
from app.routes.users import users_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.matches import matches_bp
|
||||
from app.routes.team_matches import team_matches_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.users import users_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
@@ -120,6 +459,7 @@ def create_app():
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
app.register_blueprint(team_matches_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
@@ -137,25 +477,20 @@ def create_app():
|
||||
HSTS is only sent in production (non-debug) to avoid breaking
|
||||
local development over plain HTTP.
|
||||
"""
|
||||
# X-XSS-Protection is deliberately not set: the auditor it addressed
|
||||
# has been removed from every current browser, and its last versions
|
||||
# introduced vulnerabilities of their own. CSP frame-ancestors and
|
||||
# X-Frame-Options cover the remaining ground.
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), '
|
||||
'interest-cohort=(), payment=(), usb=()'
|
||||
'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: https://cdn.discordapp.com; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
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
|
||||
@@ -177,11 +512,17 @@ def create_app():
|
||||
|
||||
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:
|
||||
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
|
||||
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true':
|
||||
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
|
||||
@@ -202,20 +543,77 @@ def create_app():
|
||||
'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 as e:
|
||||
except Exception:
|
||||
# Never echo the driver error: it routinely carries the host,
|
||||
# database name and user of the connection string, and /health
|
||||
# is unauthenticated.
|
||||
app.logger.error('Health check: database unreachable', exc_info=True)
|
||||
health_data['status'] = 'unhealthy'
|
||||
health_data['database'] = f'error: {str(e)}'
|
||||
health_data['database'] = 'error'
|
||||
return jsonify(health_data), 503
|
||||
|
||||
return jsonify(health_data), 200
|
||||
|
||||
@app.route('/api/revision')
|
||||
@json_endpoint
|
||||
def data_revision():
|
||||
"""The current dataset revision, for the AJAX form handler in main.js.
|
||||
|
||||
One primary-key lookup in app_settings. The client compares this with
|
||||
the revision it last saw to decide whether a focus refresh is needed,
|
||||
and sends it back with every AJAX mutation so a stale edit is rejected
|
||||
with a 409 instead of overwriting someone else's change.
|
||||
"""
|
||||
return jsonify({'revision': current_revision()})
|
||||
|
||||
# =========================================================================
|
||||
# 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.
|
||||
@@ -226,28 +624,21 @@ def create_app():
|
||||
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/'):
|
||||
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 401 Unauthorized errors.
|
||||
"""Handle an explicit abort(401).
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login for pages, JSON for API.
|
||||
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.
|
||||
"""
|
||||
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'))
|
||||
return handle_unauthorized()
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
@@ -259,8 +650,7 @@ def create_app():
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@@ -274,8 +664,7 @@ def create_app():
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@@ -289,12 +678,10 @@ def create_app():
|
||||
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
|
||||
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)
|
||||
@@ -315,13 +702,21 @@ def create_app():
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
# 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.'
|
||||
}), 500
|
||||
return render_template('errors/500.html'), 500
|
||||
'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):
|
||||
@@ -333,13 +728,10 @@ def create_app():
|
||||
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
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{'error': error.name, 'message': error.description, 'code': error.code}
|
||||
), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
@@ -347,25 +739,40 @@ def create_app():
|
||||
# =========================================================================
|
||||
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 as e:
|
||||
app.logger.warning('Could not start Discord bot: %s', e)
|
||||
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
|
||||
|
||||
|
||||
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='0.0.0.0', port=10000)
|
||||
# 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).
|
||||
|
||||
+1187
-345
File diff suppressed because it is too large
Load Diff
+15
-9
@@ -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=["1000 per day", "200 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
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Google Drive storage for contract PDFs.
|
||||
|
||||
The application uses an OAuth refresh token for the owner's personal Google
|
||||
account. Tokens and client secrets come only from environment variables; the
|
||||
database stores opaque file IDs, never a credential or a shareable Drive URL.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
|
||||
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
|
||||
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
|
||||
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
|
||||
class GoogleDriveStorageError(RuntimeError):
|
||||
"""A configuration or API failure while storing a contract in Drive."""
|
||||
|
||||
|
||||
def _setting(name):
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise GoogleDriveStorageError(
|
||||
f'{name} must be configured for Google Drive document storage.'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _drive_service():
|
||||
"""Build an authorized Drive client from the owner's refresh token."""
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError as exc:
|
||||
raise GoogleDriveStorageError(
|
||||
'Google Drive dependencies are not installed. Install requirements.txt again.'
|
||||
) from exc
|
||||
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
|
||||
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
|
||||
scopes=[GOOGLE_DRIVE_SCOPE],
|
||||
)
|
||||
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
|
||||
|
||||
|
||||
def list_folder_files():
|
||||
"""Return (file_id, name) tuples for every file in the configured folder.
|
||||
|
||||
Used by the backup script to enumerate Drive-stored contracts. Returns
|
||||
only files directly in the folder (not nested inside sub-folders).
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str]]: (file_id, name) for each file found.
|
||||
"""
|
||||
try:
|
||||
folder_id = _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
|
||||
page_token = None
|
||||
results = []
|
||||
while True:
|
||||
response = (
|
||||
_drive_service()
|
||||
.files()
|
||||
.list(
|
||||
q=f"'{folder_id}' in parents and trashed=false",
|
||||
fields='nextPageToken, files(id, name)',
|
||||
pageToken=page_token,
|
||||
pageSize=1000,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
for f in response.get('files', []):
|
||||
results.append((f['id'], f['name']))
|
||||
page_token = response.get('nextPageToken')
|
||||
if not page_token:
|
||||
break
|
||||
return results
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
||||
|
||||
|
||||
def upload_file(*, stream, filename, mimetype):
|
||||
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseUpload
|
||||
|
||||
stream.seek(0)
|
||||
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
|
||||
response = (
|
||||
_drive_service()
|
||||
.files()
|
||||
.create(
|
||||
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
||||
media_body=media,
|
||||
fields='id',
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
|
||||
|
||||
file_id = response.get('id')
|
||||
if not file_id:
|
||||
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
|
||||
return file_id
|
||||
|
||||
|
||||
def download_file(file_id):
|
||||
"""Download a Drive file into memory for Flask's authenticated response."""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
destination = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(
|
||||
destination,
|
||||
_drive_service().files().get_media(fileId=file_id),
|
||||
)
|
||||
complete = False
|
||||
while not complete:
|
||||
_status, complete = downloader.next_chunk()
|
||||
return destination.getvalue()
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
|
||||
|
||||
|
||||
def delete_file(file_id):
|
||||
"""Permanently delete a Drive document when its contract record is deleted."""
|
||||
try:
|
||||
_drive_service().files().delete(fileId=file_id).execute()
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
|
||||
+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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,442 @@
|
||||
"""Manage a local PostgreSQL instance for development.
|
||||
|
||||
Searches for PostgreSQL binaries on the system, initialises a data directory
|
||||
under ``instance/pgdata/``, starts the server on port 5433, creates the
|
||||
``team_tryouts`` database, and registers cleanup on exit.
|
||||
|
||||
Usage from run.py::
|
||||
|
||||
if os.getenv('LOCAL_PG', '').lower() == 'true':
|
||||
from app.local_postgres import start as start_local_pg
|
||||
os.environ['DATABASE_URL'] = start_local_pg()
|
||||
|
||||
Environment used (all optional):
|
||||
LOCAL_PG_PORT Port to bind (default 5433)
|
||||
LOCAL_PG_USER Database superuser name (default team_tryouts_dev)
|
||||
LOCAL_PG_DATABASE Database name (default team_tryouts)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DATADIR = pathlib.Path(__file__).resolve().parent.parent / 'instance' / 'pgdata'
|
||||
PG_LOG = DATADIR.parent / 'pg.log'
|
||||
|
||||
PORT = int(os.getenv('LOCAL_PG_PORT', '5433'))
|
||||
_USER = os.getenv('LOCAL_PG_USER', 'team_tryouts_dev')
|
||||
_DATABASE = os.getenv('LOCAL_PG_DATABASE', 'team_tryouts')
|
||||
|
||||
DATABASE_URL = f'postgresql://{_USER}@127.0.0.1:{PORT}/{_DATABASE}'
|
||||
|
||||
_started = False
|
||||
|
||||
#: Name of the system Windows PostgreSQL service we temporarily stopped.
|
||||
_stopped_system_service_name: str | None = None
|
||||
|
||||
logger = logging.getLogger('app.local_postgres')
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIN_PG_ROOTS = [
|
||||
pathlib.Path(os.environ.get('PROGRAMFILES', 'C:\\Program Files')) / 'PostgreSQL',
|
||||
pathlib.Path(os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)')) / 'PostgreSQL',
|
||||
]
|
||||
|
||||
|
||||
def _find_binary(name: str) -> pathlib.Path | None:
|
||||
"""Locate a PostgreSQL binary (pg_ctl, initdb, psql)."""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return pathlib.Path(found)
|
||||
|
||||
if sys.platform == 'win32':
|
||||
for root in _WIN_PG_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
versions = sorted(
|
||||
(p for p in root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
for version_dir in versions:
|
||||
candidate = version_dir / 'bin' / f'{name}.exe'
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows service management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Path to the Windows service controller.
|
||||
_SC_EXE = pathlib.Path(os.environ.get('SYSTEMROOT', 'C:\\Windows')) / 'System32' / 'sc.exe'
|
||||
|
||||
|
||||
def _detect_system_service() -> str | None:
|
||||
"""Find a running Windows PostgreSQL service installed by the EDB installer.
|
||||
|
||||
Returns the service name (e.g. ``postgresql-x64-18``) or None.
|
||||
"""
|
||||
if sys.platform != 'win32' or not _SC_EXE.is_file():
|
||||
return None
|
||||
for root in _WIN_PG_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
versions = sorted(
|
||||
(p for p in root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
for version_dir in versions:
|
||||
srv = f'postgresql-x64-{version_dir.name}'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(_SC_EXE), 'query', srv],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and 'RUNNING' in result.stdout:
|
||||
return srv
|
||||
except (FileNotFoundError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _stop_system_service(name: str):
|
||||
"""Stop a Windows system service."""
|
||||
print(f'[INFO] Stopping system PostgreSQL service "{name}" ...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(_SC_EXE), 'stop', name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
print(f'[WARNING] Could not stop service "{name}": {exc}')
|
||||
|
||||
|
||||
def _start_system_service(name: str):
|
||||
"""Start a Windows system service."""
|
||||
print(f'[INFO] Restarting system PostgreSQL service "{name}" ...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(_SC_EXE), 'start', name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
print(f'[WARNING] Could not restart service "{name}": {exc}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data directory management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TRUST_HBA = """\
|
||||
# Local development configuration — trust all local connections.
|
||||
# Installed by app/local_postgres.py.
|
||||
local all all trust
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
"""
|
||||
|
||||
|
||||
def _init_cluster():
|
||||
"""Create the data directory if it does not exist yet."""
|
||||
if DATADIR.is_dir() and (DATADIR / 'PG_VERSION').is_file():
|
||||
return
|
||||
|
||||
initdb = _find_binary('initdb')
|
||||
if not initdb:
|
||||
print(
|
||||
'[ERROR] PostgreSQL not found. Install it from:',
|
||||
'https://www.postgresql.org/download/windows/',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f'[INFO] Initialising PostgreSQL data directory at {DATADIR} ...')
|
||||
DATADIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write a non-empty password to a temporary file. PostgreSQL >= 18
|
||||
# rejects an empty password (NUL device), and we immediately overwrite
|
||||
# pg_hba.conf with trust auth anyway, so the value is irrelevant.
|
||||
pw_file = tempfile.NamedTemporaryFile( # noqa: SIM115 — delete=False needed for initdb --pwfile
|
||||
mode='w',
|
||||
delete=False,
|
||||
suffix='.pw',
|
||||
prefix='pg_',
|
||||
)
|
||||
pw_file.write('dev\n')
|
||||
pw_file.close()
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(initdb),
|
||||
'--locale=C',
|
||||
'--encoding=UTF8',
|
||||
'--data-checksums',
|
||||
'--username',
|
||||
_USER,
|
||||
'--pwfile',
|
||||
pw_file.name,
|
||||
'-D',
|
||||
str(DATADIR),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f'[ERROR] initdb failed (exit {exc.returncode}):')
|
||||
if exc.stderr:
|
||||
print(exc.stderr)
|
||||
if exc.stdout:
|
||||
print(exc.stdout)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(pw_file.name)
|
||||
|
||||
_set_postgresql_conf()
|
||||
|
||||
hba_path = DATADIR / 'pg_hba.conf'
|
||||
hba_path.write_text(_TRUST_HBA, encoding='utf-8')
|
||||
print('[INFO] pg_hba.conf set to trust local connections.')
|
||||
|
||||
|
||||
def _set_postgresql_conf():
|
||||
"""Write postgresql.conf to match our development settings."""
|
||||
conf_path = DATADIR / 'postgresql.conf'
|
||||
if not conf_path.is_file():
|
||||
return
|
||||
|
||||
lines = conf_path.read_text(encoding='utf-8').splitlines()
|
||||
lines.append('\n# ---- local_postgres.py overrides ----')
|
||||
lines.append(f'port = {PORT}')
|
||||
lines.append("listen_addresses = '127.0.0.1'")
|
||||
lines.append("unix_socket_directories = ''")
|
||||
lines.append('autovacuum = off') # avoid DLL conflicts with system PostgreSQL service
|
||||
lines.append('logging_collector = off')
|
||||
lines.append('log_destination = stderr')
|
||||
conf_path.write_text('\n'.join(lines), encoding='utf-8')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server start / stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pg_ctl(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run ``pg_ctl`` with the data directory pre-filled."""
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
if not pg_ctl:
|
||||
raise RuntimeError('pg_ctl binary not found')
|
||||
|
||||
return subprocess.run(
|
||||
[str(pg_ctl), *args, '-D', str(DATADIR), '-w'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or str(DATADIR),
|
||||
)
|
||||
|
||||
|
||||
def _server_running() -> bool:
|
||||
"""Return True if the server is already up on our port."""
|
||||
try:
|
||||
result = _pg_ctl(['status'])
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _create_database():
|
||||
"""Create the database if it does not exist."""
|
||||
psql = _find_binary('psql')
|
||||
if not psql:
|
||||
raise RuntimeError('psql binary not found')
|
||||
|
||||
check = subprocess.run(
|
||||
[
|
||||
str(psql),
|
||||
'-h',
|
||||
'127.0.0.1',
|
||||
'-p',
|
||||
str(PORT),
|
||||
'-U',
|
||||
_USER,
|
||||
'-d',
|
||||
'postgres',
|
||||
'-tAc',
|
||||
f"SELECT 1 FROM pg_database WHERE datname = '{_DATABASE}'",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, 'PGPASSWORD': ''},
|
||||
)
|
||||
|
||||
if check.stdout.strip() == '1':
|
||||
print(f'[INFO] Database "{_DATABASE}" already exists.')
|
||||
return
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
str(psql),
|
||||
'-h',
|
||||
'127.0.0.1',
|
||||
'-p',
|
||||
str(PORT),
|
||||
'-U',
|
||||
_USER,
|
||||
'-d',
|
||||
'postgres',
|
||||
'-c',
|
||||
f'CREATE DATABASE "{_DATABASE}"',
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, 'PGPASSWORD': ''},
|
||||
)
|
||||
print(f'[INFO] Created database "{_DATABASE}".')
|
||||
|
||||
|
||||
def _stop(*_args):
|
||||
"""Stop the PostgreSQL server with a fast shutdown.
|
||||
|
||||
Registered as an ``atexit`` handler so it fires on normal exit. Signal
|
||||
handling goes through ``_handle_stop_signal``, which also stops the
|
||||
server but then re-raises so the process actually exits.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
if not _started:
|
||||
return
|
||||
_started = False
|
||||
|
||||
try:
|
||||
result = _pg_ctl(['stop', '-m', 'fast'])
|
||||
if result.returncode != 0:
|
||||
logger.info('pg_ctl stop returned %d: %s', result.returncode, result.stderr.strip())
|
||||
else:
|
||||
logger.info('Local PostgreSQL stopped.')
|
||||
except Exception as exc: # noqa: BLE001 — shutdown must never raise
|
||||
logger.warning('Failed to stop local PostgreSQL: %s', exc)
|
||||
|
||||
# Restart the system service if we stopped it earlier.
|
||||
if _stopped_system_service_name:
|
||||
srv = _stopped_system_service_name
|
||||
_stopped_system_service_name = None
|
||||
_start_system_service(srv)
|
||||
|
||||
|
||||
def _handle_stop_signal(signum, frame):
|
||||
"""Stop PostgreSQL, then let the interpreter shut down normally.
|
||||
|
||||
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
|
||||
system service, then returns without exiting, leaving the dev server
|
||||
running against a stopped database and the terminal frozen. Re-raising
|
||||
``KeyboardInterrupt`` hands control back to the framework, which exits;
|
||||
the ``atexit`` registration then runs ``_stop`` again (idempotent).
|
||||
"""
|
||||
_stop()
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def start() -> str:
|
||||
"""Start a local PostgreSQL server for development.
|
||||
|
||||
Returns the ``DATABASE_URL`` the application should use.
|
||||
|
||||
The caller is expected to set this on ``os.environ['DATABASE_URL']``
|
||||
before invoking ``create_app()``.
|
||||
|
||||
This function is idempotent: if the server is already running and the
|
||||
database exists, it returns immediately.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
|
||||
# On Windows, temporarily stop the system PostgreSQL service so its child
|
||||
# processes (autovacuum, etc.) do not collide with our local instance.
|
||||
if _stopped_system_service_name is None: # only do this once per process
|
||||
srv = _detect_system_service()
|
||||
if srv:
|
||||
_stop_system_service(srv)
|
||||
_stopped_system_service_name = srv
|
||||
|
||||
# 1. Initialise if needed.
|
||||
_init_cluster()
|
||||
|
||||
# 2. Start if not already running.
|
||||
if not _server_running():
|
||||
print(f'[INFO] Starting PostgreSQL on port {PORT} ...')
|
||||
PG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(pg_ctl),
|
||||
'start',
|
||||
'-D',
|
||||
str(DATADIR),
|
||||
'-l',
|
||||
str(PG_LOG),
|
||||
'-w', # wait for readiness
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print('[ERROR] pg_ctl start failed.')
|
||||
# Also show the server log — it often has the real error.
|
||||
try:
|
||||
log_text = PG_LOG.read_text(encoding='utf-8')
|
||||
if log_text.strip():
|
||||
print(f'Server log ({PG_LOG}):')
|
||||
print(log_text)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sys.exit(1)
|
||||
print(f'[INFO] PostgreSQL is up (pid in {PG_LOG}).')
|
||||
else:
|
||||
print(f'[INFO] PostgreSQL is already running on port {PORT}.')
|
||||
|
||||
# 3. Create the application database (idempotent).
|
||||
_create_database()
|
||||
|
||||
# 4. Register cleanup.
|
||||
if not _started:
|
||||
_started = True
|
||||
atexit.register(_stop)
|
||||
with contextlib.suppress(ValueError, OSError):
|
||||
signal.signal(signal.SIGINT, _handle_stop_signal)
|
||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
|
||||
|
||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||
return DATABASE_URL
|
||||
+140
-20
@@ -11,8 +11,41 @@ Usage:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
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):
|
||||
@@ -24,14 +57,30 @@ class SensitiveDataFilter(logging.Filter):
|
||||
|
||||
# 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'(?: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 message.
|
||||
"""Apply redaction to the log record's fully rendered message.
|
||||
|
||||
The record is rendered first (msg % args) and the result stored back
|
||||
as msg with args cleared. Redacting record.msg alone would miss almost
|
||||
everything: this codebase logs with %s placeholders, so the sensitive
|
||||
value lives in record.args while record.msg holds only the format
|
||||
string.
|
||||
|
||||
Args:
|
||||
record: The log record to filter.
|
||||
@@ -39,14 +88,21 @@ class SensitiveDataFilter(logging.Filter):
|
||||
Returns:
|
||||
bool: Always True (never drops records, only redacts).
|
||||
"""
|
||||
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
||||
msg = record.msg
|
||||
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:
|
||||
if callable(replacement):
|
||||
msg = pattern.sub(replacement, msg)
|
||||
else:
|
||||
msg = pattern.sub(replacement, msg)
|
||||
record.msg = msg
|
||||
rendered = pattern.sub(replacement, rendered)
|
||||
|
||||
record.msg = rendered
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
@@ -63,7 +119,12 @@ def configure_logging(app):
|
||||
Args:
|
||||
app: The Flask application instance to configure logging for.
|
||||
"""
|
||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
||||
# Anchored on the project, not on the working directory (OBS-006). The
|
||||
# old form put the logs wherever the process happened to be started
|
||||
# from, so a service restarted by hand from another directory quietly
|
||||
# began writing somewhere else — and the file you go looking at when
|
||||
# something is wrong is the one that must not move.
|
||||
log_dir = logs_root()
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Remove default Flask handlers to avoid duplicate logging
|
||||
@@ -76,11 +137,17 @@ def configure_logging(app):
|
||||
|
||||
# Create the sensitive data filter
|
||||
sensitive_filter = SensitiveDataFilter()
|
||||
request_id_filter = RequestIdFilter()
|
||||
|
||||
# Formatter with timestamp, level, module, and message
|
||||
# Formatter with timestamp, level, module, request id, and message.
|
||||
#
|
||||
# request_id comes from RequestIdFilter, which is attached to every
|
||||
# handler below. A handler that formats with this string and does not
|
||||
# carry the filter raises on its first record — so if one is ever added,
|
||||
# add the filter with it.
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -89,11 +156,12 @@ def configure_logging(app):
|
||||
error_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
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)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -102,11 +170,12 @@ def configure_logging(app):
|
||||
auth_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'auth.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5
|
||||
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')
|
||||
@@ -120,22 +189,41 @@ def configure_logging(app):
|
||||
app_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
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 enabled for debugging in both dev and production)
|
||||
# 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(log_level)
|
||||
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')
|
||||
@@ -151,3 +239,35 @@ def get_auth_logger():
|
||||
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))
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models._constants import (
|
||||
GAME_PLATFORMS,
|
||||
PLATFORM_CODES,
|
||||
TRN_URLS,
|
||||
EVALUATION_CRITERIA,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -93,3 +94,6 @@ 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
|
||||
from app.models.admin_settings import AppSettings
|
||||
from app.models.backup_record import BackupRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
+40
-16
@@ -2,24 +2,48 @@
|
||||
|
||||
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_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),
|
||||
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),
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
tryout_managers = db.Table(
|
||||
'tryout_managers',
|
||||
db.Column(
|
||||
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
|
||||
|
||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||
|
||||
# Ordered list of (field_name, human_label) pairs for the player evaluation
|
||||
# score criteria. Kept in a single place so the evaluation forms, batch
|
||||
# evaluation page, and any future reporting all stay in sync.
|
||||
EVALUATION_CRITERIA = [
|
||||
('mecanics_score', 'Mecanics'),
|
||||
('cohesion_score', 'Cohesion'),
|
||||
('communication_score', 'Communication'),
|
||||
('gamesense_score', 'Gamesense'),
|
||||
('versatility_score', 'Versatility'),
|
||||
('discipline_score', 'Discipline'),
|
||||
('analysis_score', 'Analysis'),
|
||||
('sport_ethics_score', 'Sport Ethics'),
|
||||
('mental_score', 'Mental'),
|
||||
]
|
||||
|
||||
ESPORT_GAMES = [
|
||||
'Valorant',
|
||||
'League of Legends',
|
||||
@@ -52,17 +67,13 @@ PLATFORM_CODES = {
|
||||
'Epic': 'epic',
|
||||
}
|
||||
|
||||
PLATFORM_DEFAULTS = {
|
||||
'Apex Legends': 'pc',
|
||||
'Rainbow Six Siege': 'ubi',
|
||||
'Rocket League': '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}',
|
||||
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
|
||||
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform_code}/{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}',
|
||||
|
||||
+16
-1
@@ -9,6 +9,21 @@ def load_user(user_id):
|
||||
|
||||
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
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
try:
|
||||
pk = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
user = db.session.get(User, pk)
|
||||
if user is None or not user.is_active_account:
|
||||
return None
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Global application settings stored as key-value pairs in the database."""
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class AppSettings(db.Model):
|
||||
"""Key-value store for global application settings.
|
||||
|
||||
Stores toggles and configuration that admins control through the
|
||||
admin panel, such as whether tryouts are open, season state, etc.
|
||||
"""
|
||||
|
||||
__tablename__ = 'app_settings'
|
||||
|
||||
key = db.Column(db.String(100), primary_key=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Well-known keys (documented here for discoverability)
|
||||
# ------------------------------------------------------------------
|
||||
# tryouts_open – "true" / "false" (default: "true")
|
||||
# document_uploads_enabled – "true" / "false" (default: "true")
|
||||
# season_active – "true" / "false" (default: "false")
|
||||
# season_name – e.g. "Fall 2026"
|
||||
# season_start – ISO date string
|
||||
# season_end – ISO date string
|
||||
|
||||
@staticmethod
|
||||
def get(key, default=None):
|
||||
"""Return the value for *key*, or *default* if not set."""
|
||||
row = db.session.get(AppSettings, key)
|
||||
return row.value if row is not None else default
|
||||
|
||||
@staticmethod
|
||||
def set(key, value):
|
||||
"""Upsert a setting."""
|
||||
row = db.session.get(AppSettings, key)
|
||||
if row is None:
|
||||
row = AppSettings(key=key, value=str(value) if value is not None else None)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.value = str(value) if value is not None else None
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_bool(key, default=False):
|
||||
"""Return a boolean setting."""
|
||||
val = AppSettings.get(key)
|
||||
if val is None:
|
||||
return default
|
||||
return val.lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
@staticmethod
|
||||
def set_bool(key, value):
|
||||
"""Store a boolean setting as 'true' / 'false'."""
|
||||
AppSettings.set(key, 'true' if value else 'false')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Audit log for tracking sensitive administrative actions."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""Append-only log of critical admin actions for accountability."""
|
||||
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
action = db.Column(db.String(100), nullable=False)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
ip_address = db.Column(db.String(64), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
|
||||
|
||||
@staticmethod
|
||||
def record(user_id, action, details=None, ip_address=None):
|
||||
"""Create a new audit log entry."""
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
return entry
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Availability models — BaseAvailability and its concrete subclasses."""
|
||||
|
||||
from app.models.availability.base import BaseAvailability
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
from app.models.availability.coach_availability import CoachAvailability
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
|
||||
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BaseAvailability(db.Model):
|
||||
"""Shared schema for player disponibilities and coach availabilities."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
day_of_week = db.Column(db.Integer, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Records of database backups created through the admin panel."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BackupRecord(db.Model):
|
||||
"""Metadata for a database backup stored on disk."""
|
||||
|
||||
__tablename__ = 'backup_records'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
size_bytes = db.Column(db.BigInteger, nullable=True)
|
||||
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
|
||||
+24
-8
@@ -1,10 +1,12 @@
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Contract(db.Model):
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
__tablename__ = 'contracts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -20,7 +22,7 @@ class Contract(db.Model):
|
||||
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
signed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
||||
@@ -28,23 +30,37 @@ class Contract(db.Model):
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
|
||||
|
||||
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.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.user import User
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.permissions import coach_can_access_player
|
||||
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
player = User.query.get(self.player_id)
|
||||
player = db.session.get(User, self.player_id)
|
||||
if player and player.get_org_teams():
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
if org_team and (not self.team_id or self.team_id == org_team.id):
|
||||
return True
|
||||
return coach_can_access_player(user, self.player_id)
|
||||
return False
|
||||
|
||||
def can_upload_signed(self, user):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Player evaluation record."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Player evaluation record."""
|
||||
|
||||
__tablename__ = 'evaluations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
@@ -22,9 +24,55 @@ class Evaluation(db.Model):
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||
)
|
||||
|
||||
#: The nine criteria, in the order the form shows them. The overall score
|
||||
#: is their mean; a criterion left blank is left out of the mean rather
|
||||
#: than counted as a zero, which is why this list exists rather than the
|
||||
#: route summing nine named variables (ARCH-005, QUA-003).
|
||||
CRITERIA = (
|
||||
'mecanics_score',
|
||||
'cohesion_score',
|
||||
'communication_score',
|
||||
'gamesense_score',
|
||||
'versatility_score',
|
||||
'discipline_score',
|
||||
'analysis_score',
|
||||
'sport_ethics_score',
|
||||
'mental_score',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def overall_from(cls, scores):
|
||||
"""Mean of the criteria that were actually filled in.
|
||||
|
||||
Args:
|
||||
scores: Mapping of criterion name to score or None.
|
||||
|
||||
Returns:
|
||||
float | None: None when nothing was scored — which is not the
|
||||
same as zero, and must not become one. A player nobody could
|
||||
assess has no overall score; a player who scored zero on
|
||||
everything cannot exist, the scale starts at one.
|
||||
"""
|
||||
given = [scores.get(name) for name in cls.CRITERIA]
|
||||
given = [score for score in given if score is not None]
|
||||
if not given:
|
||||
return None
|
||||
return sum(given) / len(given)
|
||||
|
||||
def apply_scores(self, scores):
|
||||
"""Write these criteria onto the record and recompute the overall.
|
||||
|
||||
Every criterion is assigned, including the ones left blank: an edit
|
||||
that clears a score has to clear it, and the mean has to be the mean
|
||||
of what is on the record afterwards.
|
||||
"""
|
||||
for name in self.CRITERIA:
|
||||
setattr(self, name, scores.get(name))
|
||||
self.overall_score = self.overall_from(scores)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""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
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BaseMatch(db.Model):
|
||||
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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)
|
||||
@@ -16,7 +18,13 @@ class Match(BaseMatch):
|
||||
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')
|
||||
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
|
||||
# delete-orphan: without it, SQLAlchemy tries to detach participants by
|
||||
# setting match_id to NULL, which the NOT NULL column refuses — so
|
||||
# deleting any match that had participants raised IntegrityError.
|
||||
# TeamMatch.participants already declared this; Match did not.
|
||||
participants = db.relationship(
|
||||
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_participating_players(self):
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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)
|
||||
@@ -13,8 +15,8 @@ class TeamMatch(BaseMatch):
|
||||
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')
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_confirmed_count(self):
|
||||
all_p = self.participants.all()
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class OneOnOneRequest(db.Model):
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
__tablename__ = 'one_on_one_requests'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
responded_at = db.Column(db.DateTime, nullable=True)
|
||||
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
||||
coach_rejection_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Organisation team models."""
|
||||
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import org_team_coaches, org_team_managers
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class OrgTeam(db.Model):
|
||||
"""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)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
coaches = db.relationship(
|
||||
'User', secondary=org_team_coaches, lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'))
|
||||
'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'))
|
||||
'User',
|
||||
secondary=org_team_managers,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'),
|
||||
)
|
||||
|
||||
coach = db.relationship(
|
||||
'User', foreign_keys=[coach_id],
|
||||
'User',
|
||||
foreign_keys=[coach_id],
|
||||
backref=db.backref('coached_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
viewonly=True,
|
||||
)
|
||||
manager = db.relationship(
|
||||
'User', foreign_keys=[manager_id],
|
||||
'User',
|
||||
foreign_keys=[manager_id],
|
||||
backref=db.backref('managed_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
def get_coaches(self):
|
||||
coach_list = self.coaches.all()
|
||||
@@ -49,5 +61,14 @@ class OrgTeam(db.Model):
|
||||
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]
|
||||
rows = [
|
||||
{
|
||||
'player': tp.player,
|
||||
'status': tp.status,
|
||||
'position': tp.position,
|
||||
'is_team_representative': tp.is_team_representative,
|
||||
}
|
||||
for tp in self.team_players
|
||||
]
|
||||
# The responsible heads the roster; everyone else keeps roster order.
|
||||
return sorted(rows, key=lambda row: not row['is_team_representative'])
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
"""Many-to-many junction: player to org-team."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TeamPlayer(db.Model):
|
||||
"""Many-to-many: player to org-team."""
|
||||
|
||||
__tablename__ = 'team_players'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||
#: A player an admin named as their team's responsible ("joueur
|
||||
#: responsable") — see app/permissions.py. Grants match-management
|
||||
#: rights for that one team and nothing else.
|
||||
is_team_representative = db.Column(db.Boolean, nullable=False, default=False)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
||||
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""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
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Abstract base class for match participant models."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BaseParticipant(db.Model):
|
||||
"""Shared schema for match participants."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class PersonalNote(db.Model):
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
__tablename__ = 'personal_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tryout-specific temporary team models."""
|
||||
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
__tablename__ = 'teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', backref='created_teams')
|
||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TeamMember(db.Model):
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
__tablename__ = 'team_members'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||
@@ -1,17 +1,19 @@
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TeamNote(db.Model):
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
__tablename__ = 'team_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
team = db.relationship('OrgTeam', backref='team_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tryout models."""
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
|
||||
@@ -1,42 +1,68 @@
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import tryout_coaches
|
||||
from datetime import datetime
|
||||
from app.models._associations import tryout_coaches, tryout_managers
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Tryout(db.Model):
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
__tablename__ = 'tryouts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
game = db.Column(db.String(50), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
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)
|
||||
manager_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
coach_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
||||
manager = db.relationship(
|
||||
'User', foreign_keys=[manager_id], backref='managed_tryouts'
|
||||
) # deprecated
|
||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
|
||||
target_org_team = db.relationship(
|
||||
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
|
||||
)
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
def get_managers(self):
|
||||
"""Managers attached to this tryout, both legacy and many-to-many."""
|
||||
manager_list = list(self.managers)
|
||||
if not manager_list and self.manager:
|
||||
return [self.manager]
|
||||
return manager_list
|
||||
|
||||
def get_coaches(self):
|
||||
"""Coaches attached to this tryout, both legacy and many-to-many."""
|
||||
coach_list = list(self.coaches)
|
||||
if not coach_list and self.coach:
|
||||
return [self.coach]
|
||||
return coach_list
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TryoutRegistration(db.Model):
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
__tablename__ = 'tryout_registrations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
registered_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
status = db.Column(db.String(20), default='registered')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Store gamertag per game for each user."""
|
||||
from app.extensions import db
|
||||
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._constants import PLATFORM_CODES, PLATFORM_DEFAULTS, TRN_URLS
|
||||
|
||||
|
||||
class UserGamertag(db.Model):
|
||||
"""Store gamertag per game for each user."""
|
||||
|
||||
__tablename__ = 'user_gamertags'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -15,9 +18,7 @@ class UserGamertag(db.Model):
|
||||
|
||||
user = db.relationship('User', backref='gamertags')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
|
||||
)
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
|
||||
|
||||
def get_trn_url(self):
|
||||
if self.game not in TRN_URLS:
|
||||
@@ -34,11 +35,11 @@ class UserGamertag(db.Model):
|
||||
platform.lower().replace(' ', '-') if platform else '',
|
||||
)
|
||||
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
||||
elif '{platform}' in url and '{username}' in url:
|
||||
if '{platform}' in url and '{username}' in url:
|
||||
return url.format(
|
||||
platform=platform.lower().replace(' ', '-') if platform else '',
|
||||
username=encoded_gamertag,
|
||||
)
|
||||
elif '{username}' in url:
|
||||
if '{username}' in url:
|
||||
return url.format(username=encoded_gamertag)
|
||||
return url
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.player import Player
|
||||
from app.models.user_model.scout import Scout
|
||||
from app.models.user_model.user import User
|
||||
|
||||
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""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):
|
||||
@@ -27,6 +29,10 @@ class Admin(User):
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def can_designate_team_representative(self):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Coach(User):
|
||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams.
|
||||
|
||||
Every question about *which* teams or tryouts belong to this coach is
|
||||
delegated to ``app.permissions``. Two of the three methods below used to
|
||||
answer it themselves, each considering a different subset of the two
|
||||
ways a coach can be attached to a team: ``can_manage_this_tryout``
|
||||
ignored the legacy ``coach_id`` column when checking the target team,
|
||||
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
|
||||
by that column therefore saw an empty calendar (ARCH-002).
|
||||
"""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -17,38 +27,16 @@ class Coach(User):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
if tryout.target_org_team_id:
|
||||
is_coach_of_target = OrgTeam.query.filter(
|
||||
OrgTeam.id == tryout.target_org_team_id,
|
||||
OrgTeam.coaches.any(id=self.id),
|
||||
).first() is not None
|
||||
if is_coach_of_target:
|
||||
return True
|
||||
# Check many-to-many coaches relationship
|
||||
if any(c.id == self.id for c in tryout.coaches):
|
||||
return True
|
||||
# Backward compat: check deprecated coach_id
|
||||
if tryout.coach_id == self.id:
|
||||
return True
|
||||
return False
|
||||
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
|
||||
if org_team.coach_id == self.id:
|
||||
return True
|
||||
return False
|
||||
return org_team.coach_id == self.id
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models._associations import tryout_coaches
|
||||
team_ids = [t.id for t in self.coached_org_teams.all()]
|
||||
conditions = []
|
||||
if team_ids:
|
||||
conditions.append(Tryout.target_org_team_id.in_(team_ids))
|
||||
# Check many-to-many coaches
|
||||
conditions.append(Tryout.coaches.any(id=self.id))
|
||||
# Backward compat: check deprecated coach_id
|
||||
conditions.append(Tryout.coach_id == self.id)
|
||||
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
|
||||
from app.permissions import coach_tryouts
|
||||
|
||||
return coach_tryouts(self)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""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):
|
||||
@@ -19,14 +21,28 @@ class Manager(User):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
||||
return (
|
||||
tryout.created_by == self.id
|
||||
or tryout.manager_id == self.id
|
||||
or any(m.id == self.id for m in tryout.managers)
|
||||
)
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from sqlalchemy import or_
|
||||
return Tryout.query.filter(
|
||||
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)
|
||||
).order_by(Tryout.date).all()
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return (
|
||||
Tryout.query.filter(
|
||||
or_(
|
||||
Tryout.created_by == self.id,
|
||||
Tryout.manager_id == self.id,
|
||||
Tryout.managers.any(id=self.id),
|
||||
)
|
||||
)
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1,30 +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.tryout.tryout import Tryout
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
# tryouts they registered for
|
||||
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
||||
tryouts = Tryout.query.filter(
|
||||
Tryout.id.in_(player_tryout_ids)
|
||||
).order_by(Tryout.date).all() if player_tryout_ids else []
|
||||
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(
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == self.id,
|
||||
).all()
|
||||
extra_ids = set(m.tryout_id for m in player_matches)
|
||||
extra = Tryout.query.filter(
|
||||
)
|
||||
.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 []
|
||||
)
|
||||
.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]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""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):
|
||||
@@ -11,4 +13,5 @@ class Scout(User):
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Base User model — shared fields and polymorphic configuration."""
|
||||
from app.extensions import db
|
||||
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
@@ -10,6 +12,7 @@ class User(UserMixin, db.Model):
|
||||
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
||||
or Scout so that `polymorphic_identity` is set correctly.
|
||||
"""
|
||||
|
||||
__tablename__ = 'users'
|
||||
|
||||
# --- columns -----------------------------------------------------------
|
||||
@@ -21,7 +24,7 @@ class User(UserMixin, db.Model):
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
@@ -40,16 +43,26 @@ class User(UserMixin, db.Model):
|
||||
|
||||
# --- relationships (defined once on the base) --------------------------
|
||||
evaluations_given = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id',
|
||||
backref='evaluator', lazy='dynamic')
|
||||
'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')
|
||||
'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')
|
||||
'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):
|
||||
@@ -60,8 +73,9 @@ class User(UserMixin, db.Model):
|
||||
|
||||
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}
|
||||
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."""
|
||||
@@ -89,5 +103,20 @@ class User(UserMixin, db.Model):
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return False
|
||||
|
||||
def can_manage_team_matches(self, org_team):
|
||||
"""Whether the user may manage this team's matches and practices.
|
||||
|
||||
Broader than :meth:`can_manage_this_org_team`: a player named as the
|
||||
team's responsible joins whoever administers it. The rule itself
|
||||
lives in ``app.permissions``.
|
||||
"""
|
||||
from app.permissions import can_manage_team_matches
|
||||
|
||||
return can_manage_team_matches(self, org_team)
|
||||
|
||||
def can_designate_team_representative(self):
|
||||
"""Whether the user may name a team's responsible. President only."""
|
||||
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,417 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team representatives ("joueurs responsables")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_team_representative(user, org_team):
|
||||
"""Whether the user is the named responsible of this organisation team.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
org_team: The team concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
if not user or not org_team:
|
||||
return False
|
||||
|
||||
from app.models import TeamPlayer
|
||||
|
||||
return (
|
||||
TeamPlayer.query.filter_by(
|
||||
player_id=user.id,
|
||||
org_team_id=org_team.id,
|
||||
is_team_representative=True,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def representative_org_teams(user):
|
||||
"""Organisation teams the user is the responsible of, ordered by name.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
|
||||
Returns:
|
||||
list[OrgTeam]: Possibly empty.
|
||||
"""
|
||||
from app.models import OrgTeam, TeamPlayer
|
||||
|
||||
if not user:
|
||||
return []
|
||||
return (
|
||||
OrgTeam.query.join(TeamPlayer, TeamPlayer.org_team_id == OrgTeam.id)
|
||||
.filter(
|
||||
TeamPlayer.player_id == user.id,
|
||||
TeamPlayer.is_team_representative.is_(True),
|
||||
)
|
||||
.order_by(OrgTeam.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def can_manage_team_matches(user, org_team):
|
||||
"""Whether the user may manage this team's matches and practices.
|
||||
|
||||
The staff rule (see :func:`can_manage_org_team`) *plus* the player named
|
||||
as the team's responsible. Deliberately narrower than
|
||||
``can_manage_org_team``: a responsible schedules and edits matches, but
|
||||
does not administer the roster.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
org_team: The team concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
return can_manage_org_team(user, org_team) or is_team_representative(user, 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,84 @@
|
||||
"""Helpers for mutating routes that should not force a full page reload.
|
||||
|
||||
The site is server-rendered, and every mutating route used to end with
|
||||
``return redirect(url_for(...))``, so each change reloaded the whole page:
|
||||
scroll reset, open dialogs closed, and chaining several edits meant waiting
|
||||
for a full render between each one.
|
||||
|
||||
These helpers let a route answer an AJAX submit (the ``data-ajax`` forms in
|
||||
main.js) with a small JSON payload instead of a redirect, while the plain
|
||||
HTML form path keeps behaving exactly as before. The client then fetches the
|
||||
page again and swaps only the content region, so the page never reloads.
|
||||
|
||||
The counterpart that performs the swap lives in app/static/js/main.js.
|
||||
"""
|
||||
|
||||
from flask import get_flashed_messages, jsonify, redirect, request, session
|
||||
|
||||
from app.models import AppSettings
|
||||
|
||||
#: Key holding a coarse, monotonically increasing stamp for the whole dataset.
|
||||
#: It advances after every committed mutation so a stale client can be told
|
||||
#: (with a 409) that someone else has changed what it is looking at.
|
||||
REVISION_KEY = 'data_revision'
|
||||
|
||||
|
||||
def is_ajax_submit():
|
||||
"""Whether this POST came from the fetch-based form handler in main.js."""
|
||||
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
|
||||
|
||||
def current_revision():
|
||||
"""The current revision stamp, as an int (0 when nothing has been bumped)."""
|
||||
try:
|
||||
return int(AppSettings.get(REVISION_KEY, '0') or '0')
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def bump_revision():
|
||||
"""Advance the revision stamp after a committed change.
|
||||
|
||||
Stored through AppSettings so it survives restarts and is shared by every
|
||||
worker, unlike a module-level counter.
|
||||
"""
|
||||
AppSettings.set(REVISION_KEY, str(current_revision() + 1))
|
||||
|
||||
|
||||
def _flashed_success():
|
||||
"""Whether a success flash is pending, without consuming it.
|
||||
|
||||
The HTML path still redirects and must leave the flash for the next page
|
||||
to render; peeking at the session is the only way to know without clearing
|
||||
it. Only a successful commit advances the revision -- a rejected edit must
|
||||
not make other users reload for nothing.
|
||||
"""
|
||||
flashes = session.get('_flashes', [])
|
||||
return any(category == 'success' for category, _message in flashes)
|
||||
|
||||
|
||||
def respond_after_change(redirect_url):
|
||||
"""Answer a committed (or rejected) mutation.
|
||||
|
||||
For a normal HTML form submit this is exactly the ``redirect(...)`` the
|
||||
routes used to return. For an AJAX submit it returns JSON telling the
|
||||
client where to fetch the refreshed fragment and which flash messages to
|
||||
show, so the page can update in place instead of reloading.
|
||||
"""
|
||||
if _flashed_success():
|
||||
bump_revision()
|
||||
|
||||
if is_ajax_submit():
|
||||
messages = [
|
||||
{'category': category, 'text': message}
|
||||
for category, message in get_flashed_messages(with_categories=True)
|
||||
]
|
||||
return jsonify(
|
||||
{
|
||||
'ok': True,
|
||||
'redirect': redirect_url,
|
||||
'messages': messages,
|
||||
'revision': current_revision(),
|
||||
}
|
||||
)
|
||||
return redirect(redirect_url)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Admin panel routes for system-level management.
|
||||
|
||||
Provides backup/restore, tryout open/close toggling, season lifecycle
|
||||
management, team wiping, and audit log viewing. All routes are restricted
|
||||
to administrators.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin,
|
||||
AppSettings,
|
||||
AuditLog,
|
||||
BackupRecord,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
User,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.supporting_scripts.backup import (
|
||||
BackupError,
|
||||
backup_database,
|
||||
create_backup_dir,
|
||||
parse_database_url,
|
||||
)
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def require_admin():
|
||||
"""Return True if current user is an Admin, else flash and redirect."""
|
||||
if isinstance(current_user, Admin):
|
||||
return True
|
||||
flash('Only the president can access the admin panel.', 'danger')
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Best-effort client IP for audit logging."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
def _log(action, details=None):
|
||||
"""Record an audit log entry for the current user."""
|
||||
AuditLog.record(
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
details=details,
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the admin panel dashboard."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
stats = {
|
||||
'total_users': User.query.count(),
|
||||
'total_players': User.query.filter_by(role='player').count(),
|
||||
'total_org_teams': OrgTeam.query.count(),
|
||||
'active_tryouts': Tryout.query.filter_by(status='in_progress').count(),
|
||||
'upcoming_tryouts': Tryout.query.filter_by(status='upcoming').count(),
|
||||
}
|
||||
|
||||
settings = {
|
||||
'tryouts_open': AppSettings.get_bool('tryouts_open', default=True),
|
||||
'document_uploads_enabled': AppSettings.get_bool('document_uploads_enabled', default=True),
|
||||
'season_active': AppSettings.get_bool('season_active', default=False),
|
||||
'season_name': AppSettings.get('season_name') or 'Not set',
|
||||
'season_start': AppSettings.get('season_start') or 'Not set',
|
||||
'season_end': AppSettings.get('season_end') or 'Not set',
|
||||
}
|
||||
|
||||
backups = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(20).all()
|
||||
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()
|
||||
|
||||
return render_template(
|
||||
'pages/admin.html',
|
||||
stats=stats,
|
||||
settings=settings,
|
||||
backups=backups,
|
||||
audit_logs=audit_logs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_backup_record(backup_type='manual', notes=None):
|
||||
"""Run a database backup and return a BackupRecord, or raise BackupError."""
|
||||
create_backup_dir()
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
file_path = backup_database(conn)
|
||||
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
record = BackupRecord(
|
||||
filename=filename,
|
||||
file_path=file_path,
|
||||
size_bytes=size,
|
||||
backup_type=backup_type,
|
||||
notes=notes,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
return record
|
||||
|
||||
|
||||
@admin_bp.route('/backup/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_backup():
|
||||
"""Create a manual database backup."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
notes = request.form.get('notes', '').strip() or None
|
||||
try:
|
||||
record = _create_backup_record(backup_type='manual', notes=notes)
|
||||
except BackupError as e:
|
||||
flash(f'Backup failed: {e}', 'danger')
|
||||
_log('backup_failed', f'Error: {e}')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
|
||||
flash(f'Backup created successfully: {record.filename}', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/download')
|
||||
@login_required
|
||||
def download_backup(backup_id):
|
||||
"""Download a backup file."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if not os.path.exists(record.file_path):
|
||||
flash('Backup file is missing from disk.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_downloaded', f'File: {record.filename}')
|
||||
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_backup(backup_id):
|
||||
"""Delete a backup file and its record."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if os.path.exists(record.file_path):
|
||||
try:
|
||||
os.remove(record.file_path)
|
||||
except OSError as e:
|
||||
flash(f'Could not remove backup file: {e}', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_deleted', f'File: {record.filename}')
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
flash(f'Backup {record.filename} deleted.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
||||
@login_required
|
||||
def restore_backup(backup_id):
|
||||
"""Restore a selected backup.
|
||||
|
||||
A safety backup of the current state is created first, then the
|
||||
selected dump is restored via pg_restore.
|
||||
"""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if not os.path.exists(record.file_path):
|
||||
flash('Backup file is missing from disk.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
# Create a safety backup of the current state before restoring.
|
||||
try:
|
||||
safety = _create_backup_record(
|
||||
backup_type='pre_restore',
|
||||
notes='Automatic safety backup before restoring ' + record.filename,
|
||||
)
|
||||
except BackupError as e:
|
||||
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
# Restore using pg_restore
|
||||
import subprocess
|
||||
|
||||
from app.supporting_scripts.backup import (
|
||||
PG_RESTORE,
|
||||
dump_environment,
|
||||
parse_database_url,
|
||||
)
|
||||
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
cmd = [
|
||||
PG_RESTORE,
|
||||
'--host',
|
||||
conn['host'],
|
||||
'--port',
|
||||
conn['port'],
|
||||
'--username',
|
||||
conn['user'],
|
||||
'--dbname',
|
||||
conn['dbname'],
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
'--no-owner',
|
||||
record.file_path,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=dump_environment(conn),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or 'pg_restore failed')
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
|
||||
flash(f'Restore failed: {e}', 'danger')
|
||||
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_restored', f'File: {record.filename}')
|
||||
flash(
|
||||
f'Backup {record.filename} restored successfully. The database has been rolled back.',
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tryouts open/close toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/toggle-tryouts', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_tryouts():
|
||||
"""Toggle the global tryout open/close state."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
current = AppSettings.get_bool('tryouts_open', default=True)
|
||||
new_value = not current
|
||||
AppSettings.set_bool('tryouts_open', new_value)
|
||||
|
||||
state = 'opened' if new_value else 'closed'
|
||||
_log('tryouts_toggled', f'Tryouts {state}')
|
||||
flash(f'Tryouts are now {state}.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Season lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/season/start', methods=['POST'])
|
||||
@login_required
|
||||
def start_season():
|
||||
"""Begin a new regular season."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if AppSettings.get_bool('season_active', default=False):
|
||||
flash('A season is already active. End it before starting a new one.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
name = request.form.get('season_name', '').strip()
|
||||
start_date = request.form.get('season_start', '').strip()
|
||||
if not start_date:
|
||||
flash('A season start date is required.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
try:
|
||||
datetime.strptime(start_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid season start date format.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
AppSettings.set('season_name', name or 'Untitled Season')
|
||||
AppSettings.set('season_start', start_date)
|
||||
AppSettings.set('season_end', None)
|
||||
AppSettings.set_bool('season_active', True)
|
||||
|
||||
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
|
||||
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/season/end', methods=['POST'])
|
||||
@login_required
|
||||
def end_season():
|
||||
"""End the current regular season."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if not AppSettings.get_bool('season_active', default=False):
|
||||
flash('No season is currently active.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
end_date = request.form.get('season_end', '').strip()
|
||||
if not end_date:
|
||||
flash('A season end date is required.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
try:
|
||||
datetime.strptime(end_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid season end date format.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
name = AppSettings.get('season_name')
|
||||
AppSettings.set('season_end', end_date)
|
||||
AppSettings.set_bool('season_active', False)
|
||||
|
||||
_log('season_ended', f'Season: {name}, End: {end_date}')
|
||||
flash(f'Season "{name}" has ended.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_document_uploads():
|
||||
"""Enable or disable document uploads for non-admin users."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
current = AppSettings.get_bool('document_uploads_enabled', default=True)
|
||||
new_value = not current
|
||||
AppSettings.set_bool('document_uploads_enabled', new_value)
|
||||
|
||||
state = 'enabled' if new_value else 'disabled'
|
||||
_log('document_uploads_toggled', f'Document uploads {state}')
|
||||
flash(f'Document uploads are now {state}.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wipe teams for new season
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/teams/wipe', methods=['POST'])
|
||||
@login_required
|
||||
def wipe_teams():
|
||||
"""Wipe team rosters and season matches for a new season.
|
||||
|
||||
Players are removed from org teams (TeamPlayer records), regular-season
|
||||
matches and their participants are deleted. OrgTeam structures, coaches,
|
||||
and managers are preserved.
|
||||
"""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
confirm = request.form.get('confirm', '').strip()
|
||||
if confirm != 'WIPE':
|
||||
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
# Safety backup before destructive operation
|
||||
try:
|
||||
_create_backup_record(
|
||||
backup_type='pre_wipe',
|
||||
notes='Automatic safety backup before wiping teams',
|
||||
)
|
||||
except BackupError as e:
|
||||
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
roster_count = TeamPlayer.query.count()
|
||||
match_count = TeamMatch.query.count()
|
||||
|
||||
# Delete team match participants first (FK order)
|
||||
TeamMatchParticipant.query.delete()
|
||||
TeamMatch.query.delete()
|
||||
TeamPlayer.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
_log('teams_wiped', f'Removed {roster_count} roster entries and {match_count} season matches')
|
||||
flash(
|
||||
f'Teams wiped for the new season. Removed {roster_count} roster entries '
|
||||
f'and {match_count} regular-season matches.',
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
+404
-166
@@ -2,24 +2,59 @@
|
||||
|
||||
This module handles user authentication including login with account lockout
|
||||
protection, logout with session clearing, and new user registration with
|
||||
password policy enforcement and CAPTCHA verification.
|
||||
password policy enforcement and sign-up screening.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.extensions import db, hash_password, check_password, limiter
|
||||
from app.models import User, Player, ESPORT_GAMES
|
||||
from app.validators import RegisterSchema, LoginSchema
|
||||
from marshmallow import ValidationError
|
||||
from urllib.parse import urlparse
|
||||
import requests
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
# Account lockout settings
|
||||
import requests
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import check_password, db, hash_password, limiter
|
||||
from app.forms import form_gamertags
|
||||
from app.i18n import LOCALE_SESSION_KEY
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import ESPORT_GAMES, Player, User
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
||||
|
||||
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||
DISCORD_STATE_KEY = 'discord_oauth_state'
|
||||
#: Whether the OAuth result should create a registration draft or relink the
|
||||
#: signed-in account. Kept server-side and covered by the same signed session
|
||||
#: as the anti-forgery state.
|
||||
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
|
||||
|
||||
# Failed-attempt tracking. The tally is kept for the audit trail and for the
|
||||
# cool-off marker below; it no longer refuses a correct password (SEC-018).
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
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')
|
||||
@@ -40,53 +75,124 @@ DISCORD_PLATFORM_TO_GAMES = {
|
||||
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 (relative or same origin).
|
||||
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)
|
||||
# Allow relative URLs (no netloc) or same-origin URLs
|
||||
return not parsed.netloc or parsed.netloc == request.host
|
||||
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 generate_captcha():
|
||||
"""Generate a simple math CAPTCHA challenge.
|
||||
def _absent_user_hash():
|
||||
"""A hash to verify against when the submitted username does not exist.
|
||||
|
||||
Creates a random addition problem and stores the answer in the session.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
||||
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.
|
||||
"""
|
||||
import random
|
||||
a = random.randint(1, 10)
|
||||
b = random.randint(1, 10)
|
||||
captcha_id = str(uuid.uuid4())
|
||||
session['captcha_id'] = captcha_id
|
||||
session['captcha_answer'] = a + b
|
||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
||||
global _ABSENT_USER_HASH
|
||||
if _ABSENT_USER_HASH is None:
|
||||
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
|
||||
return _ABSENT_USER_HASH
|
||||
|
||||
|
||||
def verify_captcha(user_answer):
|
||||
"""Verify the CAPTCHA answer from the session.
|
||||
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:
|
||||
user_answer: The user's submitted answer (string or int).
|
||||
failed_attempts: Consecutive failures recorded on the account.
|
||||
|
||||
Returns:
|
||||
bool: True if the answer matches the stored CAPTCHA, False otherwise.
|
||||
int: Minutes.
|
||||
"""
|
||||
try:
|
||||
expected = session.pop('captcha_answer', None)
|
||||
session.pop('captcha_id', None)
|
||||
if expected is None:
|
||||
return False
|
||||
return int(user_answer) == expected
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
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')
|
||||
@@ -95,16 +201,17 @@ 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 with account lockout protection.
|
||||
"""Handle user login authentication.
|
||||
|
||||
GET: Render the login form.
|
||||
POST: Authenticate user credentials with lockout check and audit logging.
|
||||
POST: Authenticate user credentials, with audit logging.
|
||||
|
||||
Account lockout: After 5 consecutive failed attempts, the account is
|
||||
locked for 15 minutes. Successful login resets the counter.
|
||||
|
||||
Redirects authenticated users to dashboard. Validates credentials and checks
|
||||
account status before login.
|
||||
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.
|
||||
@@ -120,80 +227,124 @@ def login():
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
username = validated['username']
|
||||
password = validated['password']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Check if account is locked
|
||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
||||
flash(
|
||||
f'Account is locked due to too many failed attempts. '
|
||||
f'Please try again in {remaining} minute(s).',
|
||||
'danger'
|
||||
# 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
|
||||
)
|
||||
return render_template('pages/login.html')
|
||||
|
||||
if user and check_password(user.password_hash, password):
|
||||
if user and credentials_ok:
|
||||
if not user.is_active_account:
|
||||
flash('This account has been deactivated.', 'danger')
|
||||
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
|
||||
flash(_('This account has been deactivated.'), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
# Reset failed login attempts on successful login
|
||||
# Correct credentials clear the tally, cool-off window included.
|
||||
# The window used to refuse them too, which is what turned it
|
||||
# into a way to lock a known account out at will (SEC-018).
|
||||
user.failed_login_attempts = 0
|
||||
user.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)
|
||||
_csrf_token = session.get('csrf_token')
|
||||
# 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()
|
||||
if _csrf_token:
|
||||
session['csrf_token'] = _csrf_token
|
||||
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(f'Welcome back, {user.username}!', 'success')
|
||||
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
# Track failed login attempt
|
||||
# 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
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
flash(
|
||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
||||
'danger'
|
||||
log_auth_event(
|
||||
'login.failure',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
else:
|
||||
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
|
||||
flash(
|
||||
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
|
||||
'danger'
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
|
||||
log_auth_event(
|
||||
'account.throttled',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
minutes=minutes,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
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 with CAPTCHA and password policy.
|
||||
"""Handle new player registration.
|
||||
|
||||
GET: Render the registration form with E-Sports games list and CAPTCHA.
|
||||
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
|
||||
and create a new player account.
|
||||
GET: Render the registration form with the E-Sports games list.
|
||||
POST: Screen the submission (see check_registration_challenge), validate
|
||||
every input against RegisterSchema, and create a new player account.
|
||||
|
||||
Only players can register through this form. Validates username/email
|
||||
uniqueness and password confirmation.
|
||||
@@ -209,20 +360,24 @@ def register():
|
||||
form_data = dict(request.form)
|
||||
form_data['games'] = request.form.getlist('games')
|
||||
|
||||
# Validate CAPTCHA first
|
||||
captcha_answer = request.form.get('captcha_answer', '')
|
||||
if not verify_captcha(captcha_answer):
|
||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields only on CAPTCHA failure
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
# 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()
|
||||
@@ -231,17 +386,8 @@ def register():
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields on validation failure
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
username = validated['username']
|
||||
email = validated['email']
|
||||
@@ -249,33 +395,41 @@ def register():
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
discord_username = validated.get('discord_username')
|
||||
discord_user_id = validated.get('discord_user_id')
|
||||
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')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
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(
|
||||
@@ -291,36 +445,37 @@ def register():
|
||||
league_os_profile=league_os_profile,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
# flush, not commit: the id is needed for the gamertag rows below,
|
||||
# and signing up is one operation. Committing here made it two, so a
|
||||
# failure while writing the gamertags left an account whose declared
|
||||
# games were silently absent (ARCH-006).
|
||||
db.session.flush()
|
||||
|
||||
# Create UserGamertag records for each selected game
|
||||
from app.models import UserGamertag
|
||||
for game in selected_games:
|
||||
field_name = f'gamertag_{game}'
|
||||
gamertag_value = request.form.get(field_name, '').strip()
|
||||
if gamertag_value:
|
||||
|
||||
for game, gamertag_data in submitted_gamertags.items():
|
||||
gamertag = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=gamertag_value,
|
||||
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)
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||
|
||||
flash(_('Your account has been created! You can now log in.'), 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# GET request — render empty form
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data={},
|
||||
)
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration({})
|
||||
|
||||
|
||||
@auth_bp.route('/discord/login')
|
||||
@@ -333,18 +488,31 @@ def discord_login():
|
||||
Returns:
|
||||
Response: Redirect to Discord authorization URL.
|
||||
"""
|
||||
if not DISCORD_CLIENT_ID:
|
||||
flash('Discord OAuth2 is not configured.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
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,
|
||||
}
|
||||
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
@@ -352,18 +520,41 @@ def discord_login():
|
||||
def discord_callback():
|
||||
"""Handle the OAuth2 callback from Discord.
|
||||
|
||||
Exchanges the authorization code for an access token, then fetches
|
||||
the user's profile (/users/@me) and connections (/users/@me/connections).
|
||||
Results are stored in the session and the user is redirected back to
|
||||
the registration form where fields will be pre-filled.
|
||||
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 registration page.
|
||||
Response: Redirect to the registration form or profile editor.
|
||||
"""
|
||||
# The state is consumed whatever happens next: a token is single-use, and
|
||||
# leaving it in the session would allow a replay.
|
||||
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
|
||||
if purpose == 'profile' and current_user.is_authenticated:
|
||||
return_endpoint = 'users.edit_profile'
|
||||
elif purpose == 'profile':
|
||||
return_endpoint = 'auth.login'
|
||||
else:
|
||||
return_endpoint = 'auth.register'
|
||||
|
||||
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||
received_state = request.args.get('state', '')
|
||||
|
||||
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||
flash(
|
||||
_(
|
||||
'Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
flash('Discord authorization failed. No code received.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Exchange the authorization code for an access token
|
||||
token_data = {
|
||||
@@ -385,13 +576,13 @@ def discord_callback():
|
||||
token_response.raise_for_status()
|
||||
token_json = token_response.json()
|
||||
access_token = token_json.get('access_token')
|
||||
except requests.RequestException as e:
|
||||
flash(f'Failed to connect to Discord. Please try again.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
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('auth.register'))
|
||||
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
@@ -405,8 +596,44 @@ def discord_callback():
|
||||
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('auth.register'))
|
||||
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 = []
|
||||
@@ -445,29 +672,40 @@ def discord_callback():
|
||||
|
||||
# Store in session for the registration form to use
|
||||
session['discord_oauth'] = {
|
||||
'id': user_data.get('id'),
|
||||
'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')
|
||||
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@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()
|
||||
flash('You have been logged out.', 'info')
|
||||
if _locale:
|
||||
session[LOCALE_SESSION_KEY] = _locale
|
||||
flash(_('You have been logged out.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
+496
-169
@@ -3,120 +3,457 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Coach, Manager, Player,
|
||||
User, Tryout, Evaluation, TryoutRegistration,
|
||||
OrgTeam, GAME_POSITIONS,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
import math
|
||||
|
||||
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 false, func, select
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
EVALUATION_CRITERIA,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Player,
|
||||
Scout,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE
|
||||
from app.responders import respond_after_change
|
||||
from app.time_utils import utc_now_naive
|
||||
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()}
|
||||
|
||||
|
||||
def validate_score(score_value):
|
||||
"""Validate that a score is between 1 and 10."""
|
||||
if score_value is None:
|
||||
"""Turn a raw form value into a criterion score (1..10), or None.
|
||||
|
||||
A blank value means "not assessed". Anything else outside 1..10 is a
|
||||
validation error, matching the single-player form's ``EvaluationSchema``
|
||||
-- a coach must not be able to quietly turn 11 or 0 into "no score"
|
||||
(QUA-003).
|
||||
"""
|
||||
if score_value is None or str(score_value).strip() == '':
|
||||
return None
|
||||
try:
|
||||
score = int(score_value)
|
||||
if 1 <= score <= 10:
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError('A score must be a whole number from 1 to 10.') from exc
|
||||
if not 1 <= score <= 10:
|
||||
raise ValidationError('Scores run from 1 to 10.')
|
||||
return score
|
||||
|
||||
|
||||
def _apply_evaluation(evaluation, scores, comments, position):
|
||||
"""Write validated scores, comments and position onto an Evaluation."""
|
||||
evaluation.apply_scores(scores)
|
||||
evaluation.comments = comments
|
||||
evaluation.position_recommendation = position
|
||||
|
||||
|
||||
def _visible_evaluations_query(user):
|
||||
"""Evaluations the acting user is allowed to see, as a query.
|
||||
|
||||
Admins and scouts see everything. Managers and coaches see the
|
||||
evaluations authored by managers and coaches inside the tryouts they
|
||||
manage; players never reach the evaluation pages.
|
||||
"""
|
||||
if isinstance(user, (Admin, Scout)):
|
||||
return Evaluation.query
|
||||
tryout_ids = [t.id for t in user.get_visible_tryouts()]
|
||||
if not tryout_ids:
|
||||
return Evaluation.query.filter(false())
|
||||
evaluator_alias = aliased(User, name='visible_evaluator')
|
||||
return (
|
||||
Evaluation.query
|
||||
.join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.filter(evaluator_alias.role.in_(['manager', 'coach']))
|
||||
.filter(Evaluation.tryout_id.in_(tryout_ids))
|
||||
)
|
||||
|
||||
|
||||
def _visible_evaluation_ids(user):
|
||||
"""A ``select`` of the ids of evaluations the acting user may see."""
|
||||
ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery()
|
||||
return select(ids.c.id)
|
||||
|
||||
|
||||
def _average(values):
|
||||
"""Mean of the non-None values, rounded to one decimal (or None)."""
|
||||
values = [value for value in values if value is not None]
|
||||
if not values:
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return round(sum(values) / len(values), 1)
|
||||
|
||||
|
||||
def _aggregate(evaluations):
|
||||
"""Per-criterion averages plus a combined overall average."""
|
||||
criterion_averages = [
|
||||
(label, _average([getattr(e, field) for e in evaluations]))
|
||||
for field, label in EVALUATION_CRITERIA
|
||||
]
|
||||
return {
|
||||
'criterion_averages': criterion_averages,
|
||||
'combined_average': _average([e.overall_score for e in evaluations]),
|
||||
'count': len(evaluations),
|
||||
}
|
||||
|
||||
|
||||
def _evaluation_view(evaluation, evaluator, tryout):
|
||||
"""Shape an Evaluation for the templates (scores in criterion order)."""
|
||||
return {
|
||||
'evaluation': evaluation,
|
||||
'evaluator': evaluator,
|
||||
'tryout': tryout,
|
||||
'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA],
|
||||
'overall': evaluation.overall_score,
|
||||
'position': evaluation.position_recommendation,
|
||||
'comment': evaluation.comments,
|
||||
'date': evaluation.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _players_by_average(user):
|
||||
"""Players with visible evaluations, best combined average first."""
|
||||
visible_ids = _visible_evaluation_ids(user)
|
||||
rows = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
players_by_id = _users_by_id(row.player_id for row in rows)
|
||||
ordered = [
|
||||
(players_by_id[row.player_id], row.avg_score)
|
||||
for row in rows
|
||||
if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player)
|
||||
]
|
||||
ordered.sort(
|
||||
key=lambda pair: (
|
||||
pair[1] is None,
|
||||
-(pair[1] if pair[1] is not None else 0),
|
||||
pair[0].username.lower(),
|
||||
)
|
||||
)
|
||||
return ordered
|
||||
|
||||
|
||||
@evaluations_bp.route('')
|
||||
@login_required
|
||||
def list_evaluations():
|
||||
"""List all evaluations accessible to the current user."""
|
||||
"""List player evaluations grouped by player, with a per-player view."""
|
||||
user = current_user
|
||||
|
||||
if isinstance(user, Player):
|
||||
flash('You do not have permission to view evaluations.', 'danger')
|
||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
sort_column = request.args.get('sort', 'created_at')
|
||||
sort_column = request.args.get('sort', 'overall_score')
|
||||
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')
|
||||
visible_ids = _visible_evaluation_ids(user)
|
||||
|
||||
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 = 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).all()
|
||||
avg_scores = db.session.query(
|
||||
aggregate = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).all()
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p, 'count': row.eval_count,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
||||
}
|
||||
elif user.can_evaluate():
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.filter(Evaluation.evaluator_id == user.id) \
|
||||
.order_by(sort_expr).all()
|
||||
player_scores = {}
|
||||
else:
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.filter(Evaluation.player_id == user.id) \
|
||||
.order_by(sort_expr).all()
|
||||
player_scores = {}
|
||||
func.max(Evaluation.created_at).label('last_eval'),
|
||||
)
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template('pages/evaluations.html',
|
||||
evaluations=evaluations, player_scores=player_scores,
|
||||
sort_column=sort_column, sort_order=sort_order)
|
||||
players_by_id = _users_by_id(row.player_id for row in aggregate)
|
||||
grouped = []
|
||||
for row in aggregate:
|
||||
player = players_by_id.get(row.player_id)
|
||||
if not player or not isinstance(player, Player):
|
||||
continue
|
||||
grouped.append(
|
||||
{
|
||||
'player': player,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
|
||||
'count': row.eval_count,
|
||||
'last_eval': row.last_eval,
|
||||
'evaluations': [],
|
||||
}
|
||||
)
|
||||
|
||||
def sort_value(entry):
|
||||
if sort_column == 'player':
|
||||
return entry['player'].username.lower()
|
||||
if sort_column == 'count':
|
||||
return entry['count']
|
||||
if sort_column == 'created_at':
|
||||
return entry['last_eval'] or utc_now_naive()
|
||||
return entry['avg'] if entry['avg'] is not None else float('-inf')
|
||||
|
||||
grouped.sort(key=sort_value, reverse=(sort_order == 'desc'))
|
||||
|
||||
page = request.args.get('page', 1, type=int) or 1
|
||||
page = max(1, page)
|
||||
per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE
|
||||
per_page = max(1, min(per_page, MAX_PER_PAGE))
|
||||
|
||||
total = len(grouped)
|
||||
pages = math.ceil(total / per_page) if total else 1
|
||||
page = min(page, pages)
|
||||
start = (page - 1) * per_page
|
||||
page_items = grouped[start:start + per_page]
|
||||
|
||||
players_on_page = {entry['player'].id: entry for entry in page_items}
|
||||
if players_on_page:
|
||||
evaluations = (
|
||||
Evaluation.query
|
||||
.filter(Evaluation.player_id.in_(players_on_page))
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
|
||||
.all()
|
||||
)
|
||||
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
|
||||
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
|
||||
tryouts_by_id = (
|
||||
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
|
||||
if tryout_ids
|
||||
else {}
|
||||
)
|
||||
for evaluation in evaluations:
|
||||
players_on_page[evaluation.player_id]['evaluations'].append(
|
||||
_evaluation_view(
|
||||
evaluation,
|
||||
evaluators_by_id.get(evaluation.evaluator_id),
|
||||
tryouts_by_id.get(evaluation.tryout_id),
|
||||
)
|
||||
)
|
||||
|
||||
pagination = {
|
||||
'page': page,
|
||||
'pages': pages,
|
||||
'total': total,
|
||||
'has_prev': page > 1,
|
||||
'has_next': page < pages,
|
||||
'prev_num': page - 1,
|
||||
'next_num': page + 1,
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'pages/evaluations.html',
|
||||
players=page_items,
|
||||
pagination=pagination,
|
||||
sort_column=sort_column,
|
||||
sort_order=sort_order,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
first_player_id=grouped[0]['player'].id if grouped else None,
|
||||
)
|
||||
|
||||
|
||||
@evaluations_bp.route('/player/<int:player_id>')
|
||||
@login_required
|
||||
def player_evaluations(player_id):
|
||||
"""One player's evaluations, with prev/next navigation between players."""
|
||||
user = current_user
|
||||
if not user.can_evaluate():
|
||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only view evaluations for players.'), 'danger')
|
||||
return redirect(url_for('evaluations.list_evaluations'))
|
||||
|
||||
ordered = _players_by_average(user)
|
||||
index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None)
|
||||
prev_player = ordered[index - 1][0] if index is not None and index > 0 else None
|
||||
next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None
|
||||
|
||||
visible_ids = _visible_evaluation_ids(user)
|
||||
evaluations = (
|
||||
Evaluation.query
|
||||
.filter(Evaluation.player_id == player.id)
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
|
||||
.all()
|
||||
)
|
||||
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
|
||||
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
|
||||
tryouts_by_id = (
|
||||
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
|
||||
if tryout_ids
|
||||
else {}
|
||||
)
|
||||
evaluation_views = [
|
||||
_evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id))
|
||||
for e in evaluations
|
||||
]
|
||||
|
||||
aggregates = _aggregate(evaluations)
|
||||
|
||||
return render_template(
|
||||
'pages/player_evaluations.html',
|
||||
player=player,
|
||||
evaluations=evaluation_views,
|
||||
prev_player=prev_player,
|
||||
next_player=next_player,
|
||||
criterion_averages=aggregates['criterion_averages'],
|
||||
combined_average=aggregates['combined_average'],
|
||||
evaluation_count=aggregates['count'],
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
)
|
||||
|
||||
|
||||
@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 respond_after_change(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 respond_after_change(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 respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_evaluation_form()
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||
@login_required
|
||||
def players_to_evaluate(tryout_id):
|
||||
"""List players that need evaluation in a specific tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
players_by_id = _users_by_id(reg.player_id for reg in registrations)
|
||||
evaluated_player_ids = {
|
||||
player_id
|
||||
for (player_id,) in db.session.query(Evaluation.player_id)
|
||||
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
|
||||
.all()
|
||||
}
|
||||
players = [
|
||||
{
|
||||
'player': player,
|
||||
'evaluated': player.id in evaluated_player_ids,
|
||||
'registration': registration,
|
||||
}
|
||||
for registration in registrations
|
||||
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
|
||||
]
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def batch_evaluate(tryout_id):
|
||||
"""Evaluate multiple players at once in a tryout.
|
||||
|
||||
GET renders a single form listing every selected player with their
|
||||
evaluation criteria. POST saves (creates or updates) all of them.
|
||||
"""
|
||||
if not current_user.can_evaluate():
|
||||
flash('You do not have permission to evaluate players.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
@@ -126,108 +463,98 @@ def evaluate_player(tryout_id, player_id):
|
||||
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))
|
||||
# Resolve selected player ids (query string on GET, hidden fields on POST).
|
||||
player_ids = []
|
||||
for raw in request.values.getlist('player_ids'):
|
||||
try:
|
||||
pid = int(raw)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if pid not in player_ids:
|
||||
player_ids.append(pid)
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only evaluate players.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
if not player_ids:
|
||||
flash('Please select at least one player to evaluate.', 'warning')
|
||||
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||
|
||||
existing_eval = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
|
||||
players = []
|
||||
for pid in player_ids:
|
||||
player = User.query.get(pid)
|
||||
if not player or not isinstance(player, Player):
|
||||
continue
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=pid,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
continue
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=pid,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
existing_scores = {
|
||||
field_name: getattr(existing, field_name) if existing else None
|
||||
for field_name, _ in EVALUATION_CRITERIA
|
||||
}
|
||||
players.append(
|
||||
{
|
||||
'player': player,
|
||||
'existing': existing,
|
||||
'existing_scores': existing_scores,
|
||||
}
|
||||
)
|
||||
|
||||
if not players:
|
||||
flash('No valid players selected for evaluation.', 'danger')
|
||||
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||
|
||||
if request.method == 'POST':
|
||||
mecanics = validate_score(request.form.get('mecanics_score'))
|
||||
cohesion = validate_score(request.form.get('cohesion_score'))
|
||||
communication = validate_score(request.form.get('communication_score'))
|
||||
gamesense = validate_score(request.form.get('gamesense_score'))
|
||||
versatility = validate_score(request.form.get('versatility_score'))
|
||||
discipline = validate_score(request.form.get('discipline_score'))
|
||||
analysis = validate_score(request.form.get('analysis_score'))
|
||||
sport_ethics = validate_score(request.form.get('sport_ethics_score'))
|
||||
mental = validate_score(request.form.get('mental_score'))
|
||||
comments = request.form.get('comments')
|
||||
position = request.form.get('position_recommendation')
|
||||
saved = 0
|
||||
try:
|
||||
for entry in players:
|
||||
pid = entry['player'].id
|
||||
scores = {
|
||||
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||
for field_name, _ in EVALUATION_CRITERIA
|
||||
}
|
||||
comments = request.form.get(f'comments_{pid}')
|
||||
position = request.form.get(f'position_recommendation_{pid}')
|
||||
|
||||
scores = [s for s in [mecanics, cohesion, communication, gamesense,
|
||||
versatility, discipline, analysis, sport_ethics, mental]
|
||||
if s is not None]
|
||||
overall = sum(scores) / len(scores) if scores else None
|
||||
|
||||
if existing_eval:
|
||||
existing_eval.mecanics_score = mecanics
|
||||
existing_eval.cohesion_score = cohesion
|
||||
existing_eval.communication_score = communication
|
||||
existing_eval.gamesense_score = gamesense
|
||||
existing_eval.versatility_score = versatility
|
||||
existing_eval.discipline_score = discipline
|
||||
existing_eval.analysis_score = analysis
|
||||
existing_eval.sport_ethics_score = sport_ethics
|
||||
existing_eval.mental_score = mental
|
||||
existing_eval.overall_score = overall
|
||||
existing_eval.comments = comments
|
||||
existing_eval.position_recommendation = position
|
||||
flash('Evaluation updated!', 'success')
|
||||
existing = entry['existing']
|
||||
if existing:
|
||||
_apply_evaluation(existing, scores, comments, position)
|
||||
else:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=pid,
|
||||
evaluator_id=current_user.id,
|
||||
mecanics_score=mecanics, cohesion_score=cohesion,
|
||||
communication_score=communication, gamesense_score=gamesense,
|
||||
versatility_score=versatility, discipline_score=discipline,
|
||||
analysis_score=analysis, sport_ethics_score=sport_ethics,
|
||||
mental_score=mental, overall_score=overall,
|
||||
comments=comments, position_recommendation=position,
|
||||
)
|
||||
_apply_evaluation(evaluation, scores, comments, position)
|
||||
db.session.add(evaluation)
|
||||
flash('Evaluation submitted successfully!', 'success')
|
||||
saved += 1
|
||||
except ValidationError:
|
||||
db.session.rollback()
|
||||
flash(_('Scores must be whole numbers from 1 to 10.'), 'danger')
|
||||
return render_template(
|
||||
'pages/batch_evaluate.html',
|
||||
tryout=tryout,
|
||||
players=players,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
evaluators = None
|
||||
if isinstance(current_user, Admin):
|
||||
all_evaluations = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
).all()
|
||||
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
|
||||
for e in all_evaluations]
|
||||
|
||||
return render_template('pages/evaluate_player.html',
|
||||
tryout=tryout, player=player,
|
||||
existing_eval=existing_eval,
|
||||
evaluators=evaluators,
|
||||
game_positions=GAME_POSITIONS)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||
@login_required
|
||||
def players_to_evaluate(tryout_id):
|
||||
"""List players that need evaluation in a specific tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
players = []
|
||||
for reg in registrations:
|
||||
p = User.query.get(reg.player_id)
|
||||
if p and isinstance(p, Player):
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
).first()
|
||||
players.append({'player': p, 'evaluated': existing is not None,
|
||||
'registration': reg})
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
return render_template(
|
||||
'pages/batch_evaluate.html',
|
||||
tryout=tryout,
|
||||
players=players,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
+152
-51
@@ -3,16 +3,29 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
|
||||
Match, MatchParticipant, OrgTeam,
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
Player,
|
||||
Scout,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from datetime import date
|
||||
from app.permissions import coach_tryout_ids
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -23,6 +36,32 @@ def index():
|
||||
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():
|
||||
@@ -43,46 +82,95 @@ def dashboard():
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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(
|
||||
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 []
|
||||
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()
|
||||
registrations = TryoutRegistration.query.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended'])).all()
|
||||
registered_player_ids = [r.player_id for r in registrations]
|
||||
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||
|
||||
# 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()
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
||||
target_org_team_id=org_team.id).all()] if org_team else []
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.tryout_id.in_(coach_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
|
||||
# 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()
|
||||
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 = []
|
||||
@@ -93,10 +181,15 @@ def dashboard():
|
||||
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(
|
||||
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()
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
for match in upcoming_matches:
|
||||
is_participant = False
|
||||
@@ -104,36 +197,44 @@ def dashboard():
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
next_matches.append(
|
||||
{
|
||||
'tryout': match.tryout,
|
||||
'match': match,
|
||||
'team': team.team if team else None,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
stats['next_matches'] = next_matches
|
||||
|
||||
elif isinstance(user, Scout):
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['avg_scores'] = db.session.query(
|
||||
Evaluation.player_id,
|
||||
top_rows = (
|
||||
db.session.query(
|
||||
User,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).order_by(
|
||||
func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
||||
stats['top_players'] = []
|
||||
for row in stats['avg_scores']:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
stats['top_players'].append((p, round(row.avg_score, 1)))
|
||||
)
|
||||
.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)
|
||||
+568
-316
File diff suppressed because it is too large
Load Diff
+192
-160
@@ -3,31 +3,55 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from flask import Blueprint, flash, jsonify, 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, Manager, Coach, Player,
|
||||
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||
Admin,
|
||||
AppSettings,
|
||||
Coach,
|
||||
Manager,
|
||||
OrgTeam,
|
||||
Player,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
from app.pagination import paginate
|
||||
from app.permissions import coach_org_teams, representative_org_teams, visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.matches import default_end_time
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import TeamMatchSchema
|
||||
|
||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||
|
||||
|
||||
def can_manage_team_match(team):
|
||||
"""Check if current user can manage matches for this team."""
|
||||
"""Whether the current user can manage matches for this team.
|
||||
|
||||
Everyone who administers the team, plus the player named as its
|
||||
responsible. Narrower than administering the team itself: a responsible
|
||||
schedules and edits matches, but does not touch the roster.
|
||||
"""
|
||||
return current_user.can_manage_team_matches(team)
|
||||
|
||||
|
||||
def season_locked():
|
||||
"""Return True when the regular season is inactive for non-admins.
|
||||
|
||||
Admins bypass the lock. Coaches and managers may only schedule regular
|
||||
season matches while the season is active.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return True
|
||||
if isinstance(current_user, Manager):
|
||||
return True
|
||||
if isinstance(current_user, Coach):
|
||||
if team.coaches.filter_by(id=current_user.id).first():
|
||||
return True
|
||||
if team.coach_id == current_user.id:
|
||||
return True
|
||||
return False
|
||||
return not AppSettings.get_bool('season_active', default=False)
|
||||
|
||||
|
||||
@team_matches_bp.route('')
|
||||
@@ -36,29 +60,21 @@ def list_matches():
|
||||
"""List all team matches visible to the current user."""
|
||||
filter_team_id = request.args.get('team_id', type=int)
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
# A manager administers every team, so the listing shows them all;
|
||||
# visible_org_teams() only reports the teams they are attached to.
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
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(
|
||||
matches_query = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(team_ids),
|
||||
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
elif isinstance(current_user, Player):
|
||||
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
|
||||
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
|
||||
matches_query = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(player_team_ids),
|
||||
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
if team_ids
|
||||
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
else:
|
||||
teams = []
|
||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
@@ -66,42 +82,63 @@ def list_matches():
|
||||
if filter_team_id:
|
||||
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
||||
|
||||
matches = matches_query.order_by(TeamMatch.date.desc()).all()
|
||||
# Pagination also bounds the per-match participant loop below, which is
|
||||
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
|
||||
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
|
||||
matches = matches_page.items
|
||||
|
||||
match_data = []
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
)
|
||||
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,
|
||||
now=datetime.utcnow())
|
||||
return render_template(
|
||||
'pages/team_matches.html',
|
||||
teams=teams,
|
||||
match_data=match_data,
|
||||
pagination=matches_page,
|
||||
now=utc_now_naive(),
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_match(team_id):
|
||||
"""Create a new regular-season team match."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to schedule matches for this team.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if season_locked():
|
||||
flash(
|
||||
'The regular season is not active. Begin a season before scheduling matches.', 'warning'
|
||||
)
|
||||
return respond_after_change(url_for('team_matches.list_matches'))
|
||||
|
||||
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
|
||||
if not can_manage_team_match(team):
|
||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||
return respond_after_change(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
|
||||
@@ -113,56 +150,49 @@ def create_match(team_id):
|
||||
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)
|
||||
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':
|
||||
title = request.form.get('title', default_title)
|
||||
opponent = request.form.get('opponent', '').strip() if not is_practice else None
|
||||
description = request.form.get('description', '')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location', '')
|
||||
|
||||
if not date_str:
|
||||
flash('Date is required.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date)
|
||||
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:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
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 = None
|
||||
end_time = None
|
||||
if start_time_str:
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(date_obj, start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash('Invalid time format.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
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=title,
|
||||
description=description or None,
|
||||
opponent=opponent or None,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location or None, created_by=current_user.id,
|
||||
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()
|
||||
@@ -170,7 +200,8 @@ def create_match(team_id):
|
||||
notified_participant_ids = []
|
||||
for tp in team_players:
|
||||
participant = TeamMatchParticipant(
|
||||
team_match_id=team_match.id, player_id=tp.player_id,
|
||||
team_match_id=team_match.id,
|
||||
player_id=tp.player_id,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
@@ -178,21 +209,21 @@ def create_match(team_id):
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
|
||||
for i, tp in enumerate(team_players):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
|
||||
send_schedule_notification(
|
||||
user_id=tp.player_id, event_type='match',
|
||||
event_title=team_match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
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(f'Team match "{title}" scheduled successfully!', 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
flash(
|
||||
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
|
||||
)
|
||||
return respond_after_change(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||
|
||||
@@ -201,84 +232,82 @@ def create_match(team_id):
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing team match."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
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'))
|
||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||
return respond_after_change(url_for('team_matches.list_matches'))
|
||||
|
||||
if request.method == 'POST':
|
||||
team_match.title = request.form.get('title', team_match.title)
|
||||
team_match.description = request.form.get('description', '') or None
|
||||
team_match.opponent = request.form.get('opponent', '').strip() or None
|
||||
|
||||
date_str = request.form.get('date')
|
||||
if date_str:
|
||||
# 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:
|
||||
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return redirect(url_for('team_matches.edit_match', match_id=match_id))
|
||||
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=[]
|
||||
)
|
||||
|
||||
start_time_str = request.form.get('start_time')
|
||||
if start_time_str:
|
||||
try:
|
||||
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
end_time_str = request.form.get('end_time')
|
||||
if end_time_str:
|
||||
try:
|
||||
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
team_match.location = request.form.get('location', '') or None
|
||||
status = request.form.get('status')
|
||||
if status in ['scheduled', 'completed', 'cancelled']:
|
||||
team_match.status = status
|
||||
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'))
|
||||
flash(_('Match updated successfully!'), 'success')
|
||||
return respond_after_change(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html',
|
||||
match=team_match, team=team, team_players=[])
|
||||
return render_template(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a team match."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
if season_locked():
|
||||
flash(
|
||||
'The regular season is not active. Begin a season before deleting matches.', 'warning'
|
||||
)
|
||||
return respond_after_change(url_for('team_matches.list_matches'))
|
||||
|
||||
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'))
|
||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||
return respond_after_change(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'))
|
||||
flash(_('Match deleted successfully.'), 'success')
|
||||
return respond_after_change(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 = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
teams = coach_org_teams(current_user)
|
||||
elif isinstance(current_user, Player):
|
||||
# A responsible schedules matches for their own team(s) only.
|
||||
teams = representative_org_teams(current_user)
|
||||
else:
|
||||
return jsonify([])
|
||||
|
||||
@@ -286,13 +315,14 @@ def api_manageable_teams():
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle is_confirmed for a team match participant."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
participant = TeamMatchParticipant.query.get_or_404(participant_id)
|
||||
participant = db.get_or_404(TeamMatchParticipant, participant_id)
|
||||
if participant.team_match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
@@ -302,8 +332,10 @@ def toggle_presence(match_id, participant_id):
|
||||
|
||||
participant.is_confirmed = not participant.is_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'is_confirmed': participant.is_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
+414
-217
@@ -3,15 +3,33 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from 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, Manager, Coach, Player,
|
||||
OrgTeam, User, Team, TeamMember,
|
||||
PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||
Admin,
|
||||
Coach,
|
||||
Contract,
|
||||
Manager,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
TeamMatch,
|
||||
TeamNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
User,
|
||||
)
|
||||
from datetime import datetime
|
||||
from app.permissions import representative_org_teams, visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
@@ -22,34 +40,37 @@ def list_teams():
|
||||
"""List all organization teams visible to the current user."""
|
||||
can_manage = current_user.can_manage_teams()
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.managers.any(id=current_user.id),
|
||||
OrgTeam.manager_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Player):
|
||||
flash('Use My Team(s) to view your teams.', 'info')
|
||||
# A player reaches this page only as a team's responsible; anyone else
|
||||
# is sent to their own view.
|
||||
if isinstance(current_user, Player) and not representative_org_teams(current_user):
|
||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||
return redirect(url_for('teams.my_teams'))
|
||||
else:
|
||||
flash('You do not have permission to view teams.', 'danger')
|
||||
if not isinstance(current_user, (Admin, Coach, Manager, Player)):
|
||||
flash(_('You do not have permission to view teams.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
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()
|
||||
all_players = User.query.filter_by(role='player').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 = 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')
|
||||
@@ -57,247 +78,363 @@ def list_teams():
|
||||
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'))
|
||||
flash(_('This page is for players.'), 'info')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
from app.models import TeamMatch, TeamMatchParticipant
|
||||
|
||||
player_teams = current_user.get_org_teams()
|
||||
now = datetime.utcnow()
|
||||
now = utc_now_naive()
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
matches = TeamMatch.query.filter(
|
||||
matches = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id == org_team.id,
|
||||
TeamMatch.status == 'scheduled',
|
||||
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
|
||||
)
|
||||
.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,
|
||||
team_match_id=tm.id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
matches_data.append({
|
||||
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,
|
||||
})
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
team_data.append({
|
||||
'team': org_team, 'matches': matches_data,
|
||||
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'))
|
||||
flash(_('You do not have permission to create teams.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
name = request.form.get('name')
|
||||
coach_id = request.form.get('coach_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
try:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
if not name:
|
||||
flash('Team name is required.', 'danger')
|
||||
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 respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
existing = OrgTeam.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
flash(f'Team "{name}" already exists.', '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=int(coach_id) if coach_id else None,
|
||||
manager_id=int(manager_id) if manager_id else None,
|
||||
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_id:
|
||||
coach_user = User.query.get(int(coach_id))
|
||||
if coach_user:
|
||||
team.coaches.append(coach_user)
|
||||
if manager_id:
|
||||
manager_user = User.query.get(int(manager_id))
|
||||
if manager_user:
|
||||
team.managers.append(manager_user)
|
||||
if coach:
|
||||
team.coaches.append(coach)
|
||||
if manager:
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" created successfully!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
def edit_team(team_id):
|
||||
"""Edit an existing organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
name = request.form.get('name')
|
||||
coach_id = request.form.get('coach_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
try:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
if not name:
|
||||
flash('Team name is required.', 'danger')
|
||||
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 respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first()
|
||||
if existing:
|
||||
flash(f'Team "{name}" already exists.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
team.name = name
|
||||
|
||||
if request.form.get('sync_staff') == '1':
|
||||
coach_ids = request.form.getlist('coach_ids')
|
||||
manager_ids = request.form.getlist('manager_ids')
|
||||
|
||||
team.coaches = []
|
||||
for cid in coach_ids:
|
||||
if cid and cid.strip():
|
||||
coach_user = User.query.get(int(cid))
|
||||
if coach_user and isinstance(coach_user, Coach):
|
||||
team.coaches.append(coach_user)
|
||||
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 = []
|
||||
for mid in manager_ids:
|
||||
if mid and mid.strip():
|
||||
manager_user = User.query.get(int(mid))
|
||||
if manager_user and isinstance(manager_user, Manager):
|
||||
team.managers.append(manager_user)
|
||||
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:
|
||||
team.coach_id = int(coach_id) if coach_id else None
|
||||
team.manager_id = int(manager_id) if manager_id else None
|
||||
# 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)
|
||||
|
||||
if coach_id:
|
||||
coach_user = User.query.get(int(coach_id))
|
||||
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
|
||||
team.coaches.append(coach_user)
|
||||
if manager_id:
|
||||
manager_user = User.query.get(int(manager_id))
|
||||
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
|
||||
team.managers.append(manager_user)
|
||||
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(f'Team "{name}" updated successfully!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||
return respond_after_change(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."""
|
||||
if not current_user.can_manage_teams():
|
||||
flash('You do not have permission to delete teams.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
"""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 respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
name = team.name
|
||||
|
||||
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
|
||||
for t in tryouts:
|
||||
t.target_org_team_id = None
|
||||
db.session.commit()
|
||||
# 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.
|
||||
|
||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
|
||||
db.session.commit()
|
||||
# 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(f'Team "{name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
||||
@login_required
|
||||
def add_coach(team_id):
|
||||
"""Add a coach to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
coach_id = request.form.get('coach_id')
|
||||
if not coach_id:
|
||||
flash('Please select a coach.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
if not data['coach_id']:
|
||||
flash(_('Please select a coach.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
coach = User.query.get_or_404(int(coach_id))
|
||||
if not isinstance(coach, Coach):
|
||||
flash('Only coaches can be assigned as 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 respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
if team.coaches.filter_by(id=coach.id).first():
|
||||
flash(f'{coach.username} is already a coach of {team.name}.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a coach of %(name)s.',
|
||||
username=coach.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
team.coaches.append(coach)
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
flash(f'{coach.username} added as coach of {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
||||
@login_required
|
||||
def add_manager(team_id):
|
||||
"""Add a manager to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
manager_id = request.form.get('manager_id')
|
||||
if not manager_id:
|
||||
flash('Please select a manager.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
if not data['manager_id']:
|
||||
flash(_('Please select a manager.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
manager = User.query.get_or_404(int(manager_id))
|
||||
if not isinstance(manager, Manager):
|
||||
flash('Only managers can be assigned as 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 respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
if team.managers.filter_by(id=manager.id).first():
|
||||
flash(f'{manager.username} is already a manager of {team.name}.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a manager of %(name)s.',
|
||||
username=manager.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
team.managers.append(manager)
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
flash(f'{manager.username} added as manager of {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
||||
@login_required
|
||||
def remove_coach(team_id):
|
||||
"""Remove a coach from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
coach_id = request.form.get('coach_id')
|
||||
if coach_id:
|
||||
coach = User.query.get(int(coach_id))
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return respond_after_change(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:
|
||||
@@ -307,22 +444,25 @@ def remove_coach(team_id):
|
||||
team.coach_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Coach removed from {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
||||
@login_required
|
||||
def remove_manager(team_id):
|
||||
"""Remove a manager from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
manager_id = request.form.get('manager_id')
|
||||
if manager_id:
|
||||
manager = User.query.get(int(manager_id))
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return respond_after_change(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:
|
||||
@@ -332,68 +472,80 @@ def remove_manager(team_id):
|
||||
team.manager_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Manager removed from {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
||||
@login_required
|
||||
def add_player(team_id):
|
||||
"""Add a player to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
player_id = request.form.get('player_id')
|
||||
status = request.form.get('status', 'starter')
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
data = _posted(TeamPlayerSchema())
|
||||
if data is None:
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
if not data['player_id']:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(int(player_id))
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only assign players to teams.', '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 respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||
if existing:
|
||||
flash(f'{player.username} is already on {team.name}.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
||||
'info',
|
||||
)
|
||||
return respond_after_change(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(f'{player.username} added to {team.name}!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player(team_id, player_id):
|
||||
"""Remove a player from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
db.session.delete(tp)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} removed from {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_player_status(team_id, player_id):
|
||||
"""Toggle a player's status between starter and substitute."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
@@ -403,53 +555,98 @@ def toggle_player_status(team_id, player_id):
|
||||
|
||||
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,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
'success': True,
|
||||
'player_id': player_id,
|
||||
'new_status': tp.status,
|
||||
'player_name': tp.player.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/toggle_representative/<int:player_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_player_representative(team_id, player_id):
|
||||
"""Name or un-name a player as their team's responsible (president only).
|
||||
|
||||
The responsible gains match-management rights for this one team. The
|
||||
flag itself stays a president's decision: this is deliberately the one
|
||||
roster action a responsible cannot perform on their own team.
|
||||
"""
|
||||
db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_designate_team_representative():
|
||||
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.is_team_representative = not tp.is_team_representative
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'success': True,
|
||||
'player_id': player_id,
|
||||
'is_team_representative': tp.is_team_representative,
|
||||
'player_name': tp.player.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||
@login_required
|
||||
def add_team_note(team_id):
|
||||
"""Add a team improvement note (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(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'))
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_player_note(team_id, player_id):
|
||||
"""Add a personal note for a player (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only add notes for players.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(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(f'Note added for {player.username}!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
+669
-249
File diff suppressed because it is too large
Load Diff
-1270
File diff suppressed because it is too large
Load Diff
@@ -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,109 @@
|
||||
"""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).
|
||||
|
||||
The check now goes beyond the header: it parses the upload with pypdf
|
||||
and requires at least one parseable page, so a file with ``%PDF-``
|
||||
prepended or a renamed non-PDF is rejected.
|
||||
|
||||
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.')
|
||||
|
||||
# Fast-fail: the PDF signature must appear at the start of the stream.
|
||||
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.')
|
||||
|
||||
# Full-content validation: parse with pypdf and require at least one
|
||||
# page. A file with a forged header but no real PDF structure (e.g. a
|
||||
# renamed executable with ``%PDF-`` prepended) will fail here.
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PyPdfError
|
||||
|
||||
reader = PdfReader(file.stream, strict=False)
|
||||
if len(reader.pages) == 0:
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
except (PyPdfError, ValueError, TypeError):
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
finally:
|
||||
file.stream.seek(0)
|
||||
|
||||
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,431 @@
|
||||
"""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.timeslots import total_hours
|
||||
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))
|
||||
|
||||
# Build a per-user hours map for the current page with two bounded
|
||||
# queries (one per availability table), avoiding an N+1 per row.
|
||||
user_ids = [u.id for u in page.items]
|
||||
hours_by_user: dict[int, float] = {}
|
||||
|
||||
for slots, fk in [
|
||||
(
|
||||
PlayerDisponibility.query.filter(
|
||||
PlayerDisponibility.player_id.in_(user_ids),
|
||||
).all(),
|
||||
'player_id',
|
||||
),
|
||||
(
|
||||
CoachAvailability.query.filter(
|
||||
CoachAvailability.coach_id.in_(user_ids),
|
||||
).all(),
|
||||
'coach_id',
|
||||
),
|
||||
]:
|
||||
buckets: dict[int, list] = {}
|
||||
for slot in slots:
|
||||
buckets.setdefault(getattr(slot, fk), []).append(slot)
|
||||
for uid, group in buckets.items():
|
||||
hours_by_user[uid] = total_hours(group)
|
||||
|
||||
return render_template(
|
||||
'pages/users.html',
|
||||
users=page.items,
|
||||
pagination=page,
|
||||
roles=USER_TYPES,
|
||||
hours=hours_by_user,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
# Load availability for players and coaches so the public profile
|
||||
# can render a read-only weekly grid.
|
||||
availability = None
|
||||
if user.role == 'player':
|
||||
availability = user.disponibilities
|
||||
elif user.role == 'coach':
|
||||
availability = user.coach_availabilities
|
||||
availability_hours = total_hours(availability) if availability else None
|
||||
|
||||
return render_template(
|
||||
'pages/view_user.html',
|
||||
profile_user=user,
|
||||
availability=availability,
|
||||
availability_hours=availability_hours,
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""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 jsonify, request
|
||||
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=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def manage_coach_availability():
|
||||
"""Manage coach availability for One on One sessions."""
|
||||
if not isinstance(current_user, Coach):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
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)})
|
||||
|
||||
|
||||
@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,255 @@
|
||||
"""Player contracts: upload, sign, download."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.responders import respond_after_change
|
||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
||||
from app.models.admin_settings import AppSettings
|
||||
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,
|
||||
GoogleDriveStorageError,
|
||||
is_google_drive_path,
|
||||
open_document,
|
||||
store_uploaded_document,
|
||||
)
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import UploadContractSchema
|
||||
|
||||
|
||||
def manageable_players():
|
||||
"""Players the current user may attach a contract to.
|
||||
|
||||
A coach used to see the squad of one team — the first row matching the
|
||||
legacy coach_id column — so a coach of two teams could file a contract
|
||||
for half of their players and no more, and a coach attached only by the
|
||||
many-to-many relationship for none at all.
|
||||
"""
|
||||
if isinstance(current_user, Coach):
|
||||
player_ids = coach_player_ids(current_user)
|
||||
return (
|
||||
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
|
||||
if player_ids
|
||||
else []
|
||||
)
|
||||
# is_active_account: a contract select that still lists people who have
|
||||
# left the club invites filing paperwork against them (SEC-16).
|
||||
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
|
||||
|
||||
def document_uploads_locked():
|
||||
"""Return True when document uploads are globally disabled.
|
||||
|
||||
Admins are always allowed to bypass the lock. Managers, coaches, and
|
||||
players are blocked when the toggle is off.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return False
|
||||
return not AppSettings.get_bool('document_uploads_enabled', default=True)
|
||||
|
||||
|
||||
@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 respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
if document_uploads_locked():
|
||||
flash(_('Document uploads are currently disabled.'), 'danger')
|
||||
return respond_after_change(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 respond_after_change(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 respond_after_change(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)
|
||||
|
||||
try:
|
||||
stored_path = store_uploaded_document(file, relative_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(
|
||||
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
|
||||
)
|
||||
return respond_after_change(url_for('users.upload_contract'))
|
||||
|
||||
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=stored_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 respond_after_change(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 respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
if document_uploads_locked():
|
||||
flash(_('Document uploads are currently disabled.'), 'danger')
|
||||
return respond_after_change(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 respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
signed_filename = f"signed_{contract.stored_filename}"
|
||||
# Legacy local records keep their signed copy beside the original. Drive
|
||||
# identifiers are opaque rather than filenames, so new Drive records use
|
||||
# a fresh logical path and receive a second Drive ID.
|
||||
signed_path = (
|
||||
os.path.join(CONTRACTS_DIR, signed_filename)
|
||||
if is_google_drive_path(contract.file_path)
|
||||
else contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
)
|
||||
try:
|
||||
stored_signed_path = store_uploaded_document(file, signed_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
contract.signed_filename = signed_filename
|
||||
contract.signed_file_path = stored_signed_path
|
||||
contract.status = 'signed'
|
||||
contract.signed_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
flash(_('Signed contract uploaded successfully!'), 'success')
|
||||
return respond_after_change(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 respond_after_change(url_for('users.list_contracts'))
|
||||
try:
|
||||
document = open_document(contract.file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
return send_file(document, 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 respond_after_change(url_for('users.list_contracts'))
|
||||
if not contract.signed_file_path:
|
||||
flash(_('No signed contract available.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
try:
|
||||
document = open_document(contract.signed_file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
||||
@@ -0,0 +1,438 @@
|
||||
"""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.responders import respond_after_change
|
||||
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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 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.responders import respond_after_change
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.services.notifications import send_discord_notification
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def one_on_one():
|
||||
"""One on One request page for players."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('Only players can request One on One sessions.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_teams = current_user.get_org_teams()
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
# Reading org_team.coach_id directly told every player whose team lists
|
||||
# its coaches through the many-to-many relationship — the newer of the
|
||||
# two ways — that they had no coach, and closed the page to them.
|
||||
# get_coaches() falls back to the legacy column when the list is empty.
|
||||
team_coaches = org_team.get_coaches() if org_team else []
|
||||
coach = team_coaches[0] if team_coaches else None
|
||||
|
||||
if not coach:
|
||||
flash(_('You do not have a coach assigned to your team.'), 'info')
|
||||
|
||||
team_notes = []
|
||||
if org_team:
|
||||
team_notes = (
|
||||
TeamNote.query.filter_by(org_team_id=org_team.id)
|
||||
.order_by(TeamNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
personal_notes = (
|
||||
PersonalNote.query.filter_by(player_id=current_user.id)
|
||||
.order_by(PersonalNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Kept as model objects for the availability check below, and serialised
|
||||
# separately for the page. They used to be the same list of strings,
|
||||
# which is what made the check compare '9:00' with '10:00' as text.
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all() if coach else []
|
||||
coach_availability = [
|
||||
{
|
||||
'day_of_week': av.day_of_week,
|
||||
'start_time': av.start_time.strftime('%H:%M'),
|
||||
'end_time': av.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for av in availabilities
|
||||
]
|
||||
|
||||
if request.method == 'POST':
|
||||
if not coach:
|
||||
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
|
||||
return respond_after_change(url_for('users.one_on_one'))
|
||||
|
||||
try:
|
||||
data = OneOnOneRequestSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
if request_obj.status != 'pending':
|
||||
flash(_('This request has already been processed.'), 'info')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
player = request_obj.player
|
||||
request_obj.status = 'approved'
|
||||
request_obj.responded_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=current_user.full_name,
|
||||
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=request_obj.start_time.strftime('%I:%M %p')
|
||||
if request_obj.start_time
|
||||
else 'TBD',
|
||||
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
|
||||
points=request_obj.points or 'No specific points provided',
|
||||
approved=True,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'One on One request from %(player)s has been approved!',
|
||||
player=player.username if player else 'Unknown',
|
||||
),
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(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 respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
if request_obj.status != 'pending':
|
||||
flash(_('This request has already been processed.'), 'info')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
try:
|
||||
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
rejection_reason = data['rejection_reason']
|
||||
player = request_obj.player
|
||||
|
||||
request_obj.status = 'rejected'
|
||||
request_obj.responded_at = utc_now_naive()
|
||||
if rejection_reason:
|
||||
request_obj.coach_rejection_message = rejection_reason
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if rejected through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=current_user.full_name,
|
||||
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=request_obj.start_time.strftime('%I:%M %p')
|
||||
if request_obj.start_time
|
||||
else 'TBD',
|
||||
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
|
||||
points=request_obj.points or 'No specific points provided',
|
||||
approved=False,
|
||||
refusal_note=rejection_reason or None,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'One on One request from %(player)s has been rejected.',
|
||||
player=player.username if player else 'Unknown',
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""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.responders import respond_after_change
|
||||
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 respond_after_change(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,101 @@
|
||||
"""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, change_kind='scheduled'
|
||||
):
|
||||
"""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.
|
||||
change_kind: 'scheduled' (new match / new player added) or
|
||||
'modified' (date/time changed for existing players).
|
||||
|
||||
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,
|
||||
change_kind=change_kind,
|
||||
)
|
||||
sent += 1
|
||||
return sent
|
||||
+359
-18
@@ -139,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;
|
||||
@@ -149,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;
|
||||
}
|
||||
@@ -159,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;
|
||||
@@ -1029,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
|
||||
|
||||
.fc {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--fc-border-color: var(--gray-200);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: var(--gray-50);
|
||||
--fc-today-bg-color: var(--info-light);
|
||||
}
|
||||
|
||||
.fc-toolbar {
|
||||
@@ -1041,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.fc-button {
|
||||
background: var(--primary) !important;
|
||||
border: none !important;
|
||||
.fc .fc-button {
|
||||
background: var(--gray-50) !important;
|
||||
border: 1px solid var(--gray-200) !important;
|
||||
color: var(--gray-700) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 6px 12px !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.fc-button:hover {
|
||||
background: var(--primary-dark) !important;
|
||||
.fc .fc-button:hover {
|
||||
background: var(--gray-100) !important;
|
||||
color: var(--gray-900) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary-dark) !important;
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc .fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-day {
|
||||
.fc-daygrid-day,
|
||||
.fc-timegrid-slot {
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
@@ -1066,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
font-size: 0.8rem;
|
||||
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
|
||||
its inline event colors) */
|
||||
#calendar .fc-event {
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 3px 6px !important;
|
||||
font-size: 0.8rem !important;
|
||||
background-color: var(--fc-event-bg, var(--primary)) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-weight: 600;
|
||||
#calendar .fc-event-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#calendar .fc-event-body i {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#calendar .fc-event-text {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#calendar .fc-event-time {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#calendar .fc-event-title {
|
||||
font-weight: 600 !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Event type colors (semantic, light theme) */
|
||||
#calendar .fc-event--team { --fc-event-bg: #10b981; }
|
||||
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
|
||||
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
|
||||
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
|
||||
|
||||
#calendar .fc-now-indicator {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Dark theme: the calendar chrome reacts to the light/dark switch */
|
||||
[data-theme="dark"] .fc {
|
||||
--fc-border-color: var(--border-color);
|
||||
--fc-neutral-bg-color: var(--bg-secondary);
|
||||
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-toolbar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button:hover {
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-daygrid-day:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-daygrid-day-number,
|
||||
[data-theme="dark"] .fc-col-header-cell-cushion,
|
||||
[data-theme="dark"] .fc-timegrid-axis-cushion,
|
||||
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Pastel event chips with dark text keep contrast on a dark background. */
|
||||
[data-theme="dark"] #calendar .fc-event {
|
||||
color: var(--gray-900) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
|
||||
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
|
||||
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
|
||||
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -1242,6 +1360,64 @@ a:hover { color: var(--primary-dark); }
|
||||
color: white !important;
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
/* Read-only weekly availability grid (public profile) */
|
||||
.readonly-availability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ra-day-column {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.ra-day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 6px;
|
||||
color: var(--primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ra-slot {
|
||||
padding: 4px 6px;
|
||||
margin: 3px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ra-slot.selected {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.ra-slot.ra-empty {
|
||||
color: var(--gray-400);
|
||||
border: 1px dashed var(--gray-300);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.readonly-availability-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.readonly-availability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.merged-disponibility-time-block.high-availability {
|
||||
background: var(--success);
|
||||
@@ -1955,3 +2131,168 @@ 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;
|
||||
}
|
||||
|
||||
/* Batch Evaluation - 2 cards wide layout */
|
||||
.batch-eval-form {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.batch-eval-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.batch-eval-grid .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
|
||||
.batch-eval-grid .form-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.batch-eval-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Evaluation comments - separate grid so long notes stay readable */
|
||||
.eval-comments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.eval-comments-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eval-comment {
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.eval-comment-text {
|
||||
margin: 0 0 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.eval-comment-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.eval-comment-author strong {
|
||||
color: var(--gray-700);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment-meta {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment-author strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.eval-comments-empty {
|
||||
margin: 8px 0 0;
|
||||
color: var(--gray-500);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comments-empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
+357
-4
@@ -420,15 +420,214 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
initDraggableBlocks();
|
||||
|
||||
/**
|
||||
* Auto-dismiss flash alerts after 5 seconds.
|
||||
* Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
|
||||
*/
|
||||
armAlertAutoDismiss();
|
||||
});
|
||||
/* =========================================================================
|
||||
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?">
|
||||
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
|
||||
* data-confirm-input-message="Type DELETE to continue."
|
||||
* data-confirm="Delete this record?">
|
||||
*
|
||||
* 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputName = form.getAttribute('data-confirm-input');
|
||||
if (inputName) {
|
||||
const input = form.elements.namedItem(inputName);
|
||||
const expectedValue = form.getAttribute('data-confirm-value') || '';
|
||||
if (!input || input.value.trim() !== expectedValue) {
|
||||
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!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) {
|
||||
submitForm(control.form);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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));
|
||||
});
|
||||
|
||||
/* =========================================================================
|
||||
AJAX forms — server-rendered changes without a full page reload
|
||||
=========================================================================
|
||||
|
||||
A <form method="post" data-ajax> is submitted with fetch() instead of a
|
||||
navigation. The route answers a JSON payload (see app/responders.py) that
|
||||
says where to fetch the refreshed content and which flash messages to
|
||||
show; the content region is then swapped in place, so scroll position and
|
||||
open state survive between edits.
|
||||
|
||||
A stale edit — the page changed since this copy was loaded — is answered
|
||||
409, and the user is asked to reload rather than silently overwrite the
|
||||
other person's change.
|
||||
========================================================================= */
|
||||
|
||||
let TT_REVISION = null;
|
||||
|
||||
function armAlertAutoDismiss() {
|
||||
const alerts = document.querySelectorAll('.alert-dismissible');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
alerts.forEach(function (alert) {
|
||||
if (alert.getAttribute('data-armed')) return;
|
||||
alert.setAttribute('data-armed', '1');
|
||||
setTimeout(function () {
|
||||
if (alert.parentElement) {
|
||||
alert.style.opacity = '0';
|
||||
alert.style.transition = 'opacity 0.3s ease';
|
||||
setTimeout(function() {
|
||||
setTimeout(function () {
|
||||
if (alert.parentElement) {
|
||||
alert.remove();
|
||||
}
|
||||
@@ -436,4 +635,158 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchRevision() {
|
||||
return fetch('/api/revision', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||
TT_REVISION = data.revision;
|
||||
return data.revision;
|
||||
}).catch(function () {
|
||||
// Revision tracking is best-effort; a failed lookup just disables it.
|
||||
});
|
||||
}
|
||||
|
||||
function showFlashMessages(messages) {
|
||||
const container = document.querySelector('.flash-messages');
|
||||
if (!container || !messages || !messages.length) return;
|
||||
messages.forEach(function (m) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-' + (m.category || 'info') + ' alert-dismissible';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = m.text;
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'alert-close';
|
||||
close.setAttribute('data-action', 'dismiss-alert');
|
||||
close.setAttribute('aria-label', 'Dismiss');
|
||||
close.innerHTML = '×';
|
||||
alert.appendChild(span);
|
||||
alert.appendChild(close);
|
||||
container.appendChild(alert);
|
||||
});
|
||||
armAlertAutoDismiss();
|
||||
}
|
||||
|
||||
function replaceContent(html, url) {
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = html;
|
||||
const next = holder.querySelector('#page-content');
|
||||
const target = document.getElementById('page-content');
|
||||
if (next && target) {
|
||||
target.innerHTML = next.innerHTML;
|
||||
}
|
||||
if (url && window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', url);
|
||||
}
|
||||
initDraggableBlocks();
|
||||
document.dispatchEvent(new Event('fragment:updated'));
|
||||
}
|
||||
|
||||
function refreshPage(url) {
|
||||
return fetch(url, { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) { replaceContent(html, url); });
|
||||
}
|
||||
|
||||
function submitAjaxForm(form) {
|
||||
const headers = {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
const token = form.querySelector('input[name="csrf_token"]');
|
||||
if (token) headers['X-CSRFToken'] = token.value;
|
||||
if (TT_REVISION !== null && TT_REVISION !== undefined) {
|
||||
headers['X-Revision'] = String(TT_REVISION);
|
||||
}
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
body: new FormData(form),
|
||||
headers: headers,
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
return { status: response.status, data: data };
|
||||
}).catch(function () {
|
||||
return { status: response.status, data: null };
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (result.status === 409) {
|
||||
const message = (result.data && result.data.message) ||
|
||||
'This page was changed by someone else. Reload to see the latest changes.';
|
||||
if (window.confirm(message)) {
|
||||
window.location.reload();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (result.status >= 400) {
|
||||
const message = (result.data && (result.data.message || result.data.error)) ||
|
||||
'Something went wrong. Please try again.';
|
||||
window.alert(message);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}).then(function (data) {
|
||||
if (!data) return;
|
||||
if (data.revision !== undefined && data.revision !== null) {
|
||||
TT_REVISION = data.revision;
|
||||
}
|
||||
showFlashMessages(data.messages);
|
||||
if (data.redirect) {
|
||||
return refreshPage(data.redirect);
|
||||
}
|
||||
return undefined;
|
||||
}).catch(function (err) {
|
||||
console.error('AJAX form submission failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function submitForm(form) {
|
||||
if (form.hasAttribute('data-ajax')) {
|
||||
submitAjaxForm(form);
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
function checkForRemoteChanges() {
|
||||
return fetchRevision().then(function (rev) {
|
||||
if (TT_REVISION !== null && rev !== TT_REVISION) {
|
||||
TT_REVISION = rev;
|
||||
return refreshPage(window.location.href);
|
||||
}
|
||||
return undefined;
|
||||
}).catch(function () { /* best-effort */ });
|
||||
}
|
||||
|
||||
// data-submit-on-change and data-ajax both funnel through submitForm so a
|
||||
// <select> change uses fetch() when the form is marked, and a plain submit
|
||||
// otherwise. (form.submit() does not fire the submit event, so intercepting
|
||||
// only 'submit' would miss the selects.)
|
||||
document.addEventListener('submit', function (event) {
|
||||
const form = event.target.closest('form[data-ajax]');
|
||||
if (!form || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
submitAjaxForm(form);
|
||||
});
|
||||
|
||||
// Refresh when the user returns to the tab: if someone else changed the data,
|
||||
// this brings the view up to date without a constant polling loop.
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForRemoteChanges();
|
||||
}
|
||||
});
|
||||
window.addEventListener('focus', function () {
|
||||
checkForRemoteChanges();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
fetchRevision();
|
||||
});
|
||||
|
||||
window.TT = window.TT || {};
|
||||
window.TT.refreshPage = refreshPage;
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
"""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 io
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.google_drive import (
|
||||
GoogleDriveStorageError,
|
||||
)
|
||||
from app.google_drive import (
|
||||
delete_file as delete_google_drive_file,
|
||||
)
|
||||
from app.google_drive import (
|
||||
download_file as download_google_drive_file,
|
||||
)
|
||||
from app.google_drive import (
|
||||
upload_file as upload_google_drive_file,
|
||||
)
|
||||
|
||||
#: 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'
|
||||
|
||||
#: Database marker for documents stored remotely. Existing rows continue to
|
||||
#: hold relative or absolute filesystem paths, so switching storage does not
|
||||
#: invalidate contracts already uploaded before the Google Drive move.
|
||||
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
|
||||
|
||||
#: Storage backends deliberately stay explicit. Google Drive is the
|
||||
#: production default; local disk exists only for legacy rows and isolated
|
||||
#: test/development environments.
|
||||
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
|
||||
|
||||
|
||||
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 document_storage_backend():
|
||||
"""Return the configured backend for new document uploads."""
|
||||
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'local').strip().lower()
|
||||
if backend not in {'google_drive', 'local'}:
|
||||
raise ValueError(
|
||||
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
def is_google_drive_path(stored_path):
|
||||
"""Whether a database path references a Google Drive file."""
|
||||
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
|
||||
|
||||
|
||||
def _google_drive_id(stored_path):
|
||||
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
|
||||
if not file_id:
|
||||
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
|
||||
return file_id
|
||||
|
||||
|
||||
def store_uploaded_document(file_storage, relative_path):
|
||||
"""Store an uploaded document and return its durable database reference.
|
||||
|
||||
Local storage retains the relative-path format used by existing rows. A
|
||||
Google Drive upload returns an opaque Drive file identifier prefixed with
|
||||
``gdrive://`` so it cannot be mistaken for a filesystem path.
|
||||
"""
|
||||
if document_storage_backend() == 'local':
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file_storage.save(absolute_path)
|
||||
return relative_path
|
||||
|
||||
file_id = upload_google_drive_file(
|
||||
stream=file_storage.stream,
|
||||
filename=os.path.basename(relative_path),
|
||||
mimetype=file_storage.mimetype or 'application/pdf',
|
||||
)
|
||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||
|
||||
|
||||
def open_document(stored_path):
|
||||
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||
if is_google_drive_path(stored_path):
|
||||
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
|
||||
return document_path(stored_path)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
removed = 0
|
||||
for stored_path in stored_paths:
|
||||
if not stored_path:
|
||||
continue
|
||||
try:
|
||||
if is_google_drive_path(stored_path):
|
||||
delete_google_drive_file(_google_drive_id(stored_path))
|
||||
else:
|
||||
os.remove(document_path(stored_path))
|
||||
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', stored_path)
|
||||
except (GoogleDriveStorageError, 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.',
|
||||
stored_path,
|
||||
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)
|
||||
+376
-101
@@ -1,27 +1,167 @@
|
||||
"""Database backup script for the Team Tryouts application.
|
||||
"""Database and document backup for the Team Tryouts application.
|
||||
|
||||
This module provides a simple backup mechanism for the SQLite database
|
||||
and uploaded contract documents. Designed to be run as a scheduled task
|
||||
(Windows Task Scheduler) or cron job.
|
||||
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 backup.py
|
||||
python app/supporting_scripts/backup.py
|
||||
python app/supporting_scripts/backup.py --verify-only <archive>
|
||||
|
||||
Configuration via environment variables:
|
||||
BACKUP_DIR: Directory to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
||||
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 sqlite3
|
||||
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 ( # noqa: E402 — needs the path above
|
||||
backups_root,
|
||||
document_storage_backend,
|
||||
documents_root,
|
||||
)
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
|
||||
BACKUP_DIR = backups_root()
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
||||
DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db'))
|
||||
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
|
||||
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():
|
||||
@@ -29,55 +169,200 @@ def create_backup_dir():
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def backup_database():
|
||||
"""Backup the SQLite database using sqlite3's built-in backup API.
|
||||
def backup_database(conn):
|
||||
"""Dump the PostgreSQL database.
|
||||
|
||||
Returns:
|
||||
str: Path to the created backup file, or None if failed.
|
||||
"""
|
||||
if not os.path.exists(DATABASE_PATH):
|
||||
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
|
||||
return None
|
||||
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_filename = f'db_backup_{timestamp}.db'
|
||||
backup_path = os.path.join(BACKUP_DIR, backup_filename)
|
||||
backup_path = os.path.join(BACKUP_DIR, f'db_backup_{timestamp}.dump')
|
||||
|
||||
print(f'[INFO] Dumping {describe_target(conn)}')
|
||||
|
||||
try:
|
||||
source = sqlite3.connect(DATABASE_PATH)
|
||||
destination = sqlite3.connect(backup_path)
|
||||
source.backup(destination)
|
||||
source.close()
|
||||
destination.close()
|
||||
print(f'[OK] Database backed up to: {backup_path}')
|
||||
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
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Database backup failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
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():
|
||||
"""Backup the uploaded contract documents directory.
|
||||
"""Archive the uploaded contract documents directory.
|
||||
|
||||
The directory is resolved through `app.storage.documents_root()` — the
|
||||
same function the upload path uses — so that setting DOCUMENTS_ROOT
|
||||
moves both together. Resolved here rather than at import, so that what
|
||||
is backed up depends on the environment the run has, not on the one the
|
||||
module happened to be imported with.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if no documents exist.
|
||||
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.
|
||||
"""
|
||||
if not os.path.exists(DOCUMENTS_DIR):
|
||||
print('[INFO] No documents directory found. Skipping document 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 backup_drive_documents():
|
||||
"""Download every file from the configured Google Drive folder and archive it.
|
||||
|
||||
Called **in addition to** ``backup_documents()`` when the storage backend is
|
||||
``google_drive``. When the backend is ``local`` this function silently
|
||||
skips — local backups already handled by ``backup_documents()``.
|
||||
|
||||
Returns:
|
||||
str | None: Path to the created archive, or None when skipped.
|
||||
|
||||
Raises:
|
||||
BackupError: If Drive is configured but listing or downloading fails.
|
||||
"""
|
||||
if document_storage_backend() != 'google_drive':
|
||||
print('[SKIP] Google Drive is not configured; local documents only.')
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.google_drive import (
|
||||
GoogleDriveStorageError,
|
||||
list_folder_files,
|
||||
)
|
||||
from app.google_drive import (
|
||||
download_file as download_drive_file,
|
||||
)
|
||||
except ImportError:
|
||||
raise BackupError(
|
||||
'Google Drive dependencies are not installed. Install requirements.txt again.'
|
||||
) from None
|
||||
|
||||
print('[INFO] Listing Google Drive contract folder...')
|
||||
try:
|
||||
files = list_folder_files()
|
||||
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
|
||||
raise BackupError(f'Google Drive listing failed: {exc}') from exc
|
||||
|
||||
if not files:
|
||||
print('[INFO] Google Drive folder is empty; no Drive documents to back up.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = f'documents_backup_{timestamp}'
|
||||
archive_path = os.path.join(BACKUP_DIR, archive_basename)
|
||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_drive_{timestamp}')
|
||||
|
||||
download = {}
|
||||
for file_id, name in files:
|
||||
print(f' Downloading {name} ({file_id})...')
|
||||
try:
|
||||
download[name] = download_drive_file(file_id)
|
||||
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
|
||||
raise BackupError(f'Google Drive download failed for {name}: {exc}') from exc
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR)
|
||||
zip_path = f'{archive_path}.zip'
|
||||
print(f'[OK] Documents backed up to: {zip_path}')
|
||||
import zipfile
|
||||
|
||||
zip_path = f'{archive_basename}.zip'
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in download.items():
|
||||
zf.writestr(name, data)
|
||||
except Exception as exc: # noqa: BLE001 — boundary
|
||||
raise BackupError(f'Drive document archive failed: {exc}') from exc
|
||||
|
||||
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
|
||||
raise BackupError('Drive document archiver reported success but produced an empty file.')
|
||||
|
||||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
print(f'[OK] Drive documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Document backup failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
@@ -90,95 +375,85 @@ def cleanup_old_backups():
|
||||
|
||||
for filename in os.listdir(BACKUP_DIR):
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
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 e:
|
||||
print(f'[WARNING] Could not remove {filename}: {e}')
|
||||
except OSError as exc:
|
||||
print(f'[WARNING] Could not remove {filename}: {exc}')
|
||||
|
||||
if removed_count > 0:
|
||||
if removed_count:
|
||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||
else:
|
||||
print('[CLEANUP] No old backups to remove.')
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Verify a database backup by running a quick integrity check.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the backup file to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if backup is valid, False otherwise.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(backup_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('PRAGMA integrity_check')
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
is_valid = result[0] == 'ok'
|
||||
if is_valid:
|
||||
print(f'[OK] Backup integrity verified: {backup_path}')
|
||||
else:
|
||||
print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}')
|
||||
return is_valid
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Backup verification failed: {e}')
|
||||
return False
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
"""Run the full backup process.
|
||||
|
||||
Steps:
|
||||
1. Create backup directory
|
||||
2. Backup database
|
||||
3. Backup documents (if any)
|
||||
4. Verify database backup
|
||||
5. Clean up old backups
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on failure.
|
||||
int: 0 when the database was dumped AND verified, 1 otherwise. The
|
||||
previous version returned 0 even when it had backed up nothing.
|
||||
"""
|
||||
print(f'=== Team Tryouts Backup ===')
|
||||
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
|
||||
|
||||
# 1. Backup database
|
||||
db_backup_path = backup_database()
|
||||
success = True
|
||||
|
||||
# 2. Verify database backup
|
||||
if db_backup_path:
|
||||
if not verify_backup(db_backup_path):
|
||||
success = False
|
||||
|
||||
# 3. Backup documents
|
||||
verified = verify_backup(backup_path)
|
||||
documents_ok = True
|
||||
try:
|
||||
backup_documents()
|
||||
|
||||
# 4. Cleanup old backups
|
||||
except BackupError as exc:
|
||||
documents_ok = False
|
||||
print(f'[ERROR] {exc}')
|
||||
drive_ok = True
|
||||
try:
|
||||
backup_drive_documents()
|
||||
except BackupError as exc:
|
||||
drive_ok = False
|
||||
print(f'[ERROR] {exc}')
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if success:
|
||||
if verified and documents_ok and drive_ok:
|
||||
print('=== Backup completed successfully ===')
|
||||
else:
|
||||
print('=== Backup completed with warnings ===')
|
||||
return 0
|
||||
|
||||
return 0 if success else 1
|
||||
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Obtain a Google Drive refresh token for the application.
|
||||
|
||||
Run this once from a development machine that has a browser. It opens a local
|
||||
OAuth redirect server, asks you to log in, and prints the refresh token.
|
||||
|
||||
Usage:
|
||||
python app/supporting_scripts/get_refresh_token.py
|
||||
|
||||
Requires:
|
||||
pip install google-auth-oauthlib
|
||||
|
||||
Before running:
|
||||
1. Create a Google Cloud project and enable the Drive API.
|
||||
2. Create a **Desktop** OAuth 2.0 client ID.
|
||||
3. Add your email as a test user in the OAuth consent screen.
|
||||
4. Run this script and paste the values when prompted.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _prompt(label, env_name, default=""):
|
||||
"""Ask the user for a value, accepting an env-var fallback or a default."""
|
||||
candidate = os.getenv(env_name) or default
|
||||
hint = f" [{candidate}]" if candidate else ""
|
||||
raw = input(f"{label}{hint}: ").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if candidate:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _validate_client_id(client_id):
|
||||
"""Warn if the client ID doesn't look like a real OAuth client ID."""
|
||||
if client_id == "YOUR_CLIENT_ID.apps.googleusercontent.com":
|
||||
print("[ERROR] You must provide a real client ID from the Google Cloud console.")
|
||||
return False
|
||||
if not client_id.endswith(".apps.googleusercontent.com"):
|
||||
print(
|
||||
f"[WARNING] Client ID should end with '.apps.googleusercontent.com'.\n"
|
||||
f" Got: {client_id!r}\n"
|
||||
f" If this is a placeholder, the OAuth request will fail.\n"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_client_secret(secret):
|
||||
if secret == "YOUR_CLIENT_SECRET":
|
||||
print("[ERROR] You must provide a real client secret from the Google Cloud console.")
|
||||
return False
|
||||
# The GOCSPX- prefix is now used by every client type, so it no longer
|
||||
# tells us anything about whether the client was created as a Desktop app.
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Google Drive Refresh Token — Setup")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# When both env vars are set, skip prompts entirely for headless runs.
|
||||
env_id = os.getenv("GOOGLE_DRIVE_CLIENT_ID")
|
||||
env_secret = os.getenv("GOOGLE_DRIVE_CLIENT_SECRET")
|
||||
if env_id and env_secret:
|
||||
client_id = env_id
|
||||
client_secret = env_secret
|
||||
print(f"Using Client ID from environment: {client_id[:30]}...")
|
||||
else:
|
||||
print("Enter your Google Cloud OAuth credentials.")
|
||||
print("You can also set GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET")
|
||||
print("environment variables and re-run to skip the prompts.")
|
||||
print()
|
||||
|
||||
client_id = _prompt("Client ID", "GOOGLE_DRIVE_CLIENT_ID")
|
||||
client_secret = _prompt("Client Secret", "GOOGLE_DRIVE_CLIENT_SECRET")
|
||||
|
||||
if not _validate_client_id(client_id) or not _validate_client_secret(client_secret):
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("Opening browser to authenticate with Google...")
|
||||
print("(If it doesn't open, copy the URL below into your browser.)")
|
||||
print()
|
||||
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
|
||||
SCOPE = "https://www.googleapis.com/auth/drive.file"
|
||||
|
||||
client_config = {
|
||||
"installed": {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": ["http://localhost"],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
|
||||
flow = InstalledAppFlow.from_client_config(client_config, scopes=[SCOPE])
|
||||
credentials = flow.run_local_server(host="localhost", port=8080, open_browser=True)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUCCESS — copy these values into your .env file:")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("DOCUMENT_STORAGE_BACKEND=google_drive")
|
||||
print(f"GOOGLE_DRIVE_CLIENT_ID={credentials.client_id}")
|
||||
print(f"GOOGLE_DRIVE_CLIENT_SECRET={client_secret}")
|
||||
print(f"GOOGLE_DRIVE_REFRESH_TOKEN={credentials.refresh_token}")
|
||||
print("GOOGLE_DRIVE_FOLDER_ID=<get this from the Drive folder URL>")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
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")}')
|
||||
# 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
|
||||
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
|
||||
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:
|
||||
# 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
|
||||
app = create_app()
|
||||
|
||||
# Inspect configuration only: no schema creation, no Discord bot.
|
||||
app = create_app(
|
||||
{
|
||||
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
|
||||
'AUTO_CREATE_TABLES': False,
|
||||
'ENABLE_DISCORD_BOT': False,
|
||||
}
|
||||
)
|
||||
|
||||
# Check session cookie settings
|
||||
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,7 +421,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}400 Bad Request - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Bad Request{% endblock %}
|
||||
{% 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>
|
||||
<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
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}403 Forbidden - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Access Denied{% endblock %}
|
||||
{% 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>
|
||||
<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
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}404 Not Found - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Page Not Found{% endblock %}
|
||||
{% 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>
|
||||
<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
|
||||
<i class="fas fa-home"></i> {{ _('Return Home') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}429 Too Many Requests - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Rate Limit Exceeded{% endblock %}
|
||||
{% 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>
|
||||
<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
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +1,21 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}500 Server Error - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Internal Server Error{% endblock %}
|
||||
{% 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>
|
||||
<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
|
||||
<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 %}UdeS team manager{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
{# Subresource integrity (QUA-004). Without it, whoever controls the CDN
|
||||
controls what runs on every page of this site — and the CSP names these
|
||||
hosts as allowed, so it would not object.
|
||||
|
||||
What SRI does and does not do: it pins this exact file, so the browser
|
||||
refuses a version that has been altered since. It does not prove the
|
||||
file was honest when the hash was taken. This hash is the one cdnjs
|
||||
publishes for the release, not one derived from the copy we downloaded.
|
||||
|
||||
integrity requires crossorigin. Changing the version means changing
|
||||
the hash, or the asset silently stops loading. #}
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="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>
|
||||
@@ -30,26 +45,26 @@
|
||||
<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 %}
|
||||
@@ -57,14 +72,14 @@
|
||||
<li>
|
||||
<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>
|
||||
<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>
|
||||
<span>{{ _('Manage Teams') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -72,7 +87,13 @@
|
||||
<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>
|
||||
<li>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-cogs"></i>
|
||||
<span>Admin Panel</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -80,7 +101,7 @@
|
||||
<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 %}
|
||||
@@ -88,42 +109,53 @@
|
||||
<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 & One on One</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('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>My Profile</span>
|
||||
<span>{{ _('My Profile') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('auth.logout') }}" class="logout-link">
|
||||
{# 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>
|
||||
</a>
|
||||
<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 %}
|
||||
@@ -134,13 +166,14 @@
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="content" id="page-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +185,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 %}
|
||||
@@ -162,8 +196,25 @@
|
||||
<div class="auth-header">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -108,11 +108,12 @@
|
||||
{# Modal Macro - renders a modal dialog #}
|
||||
{% macro modal(id, title, content, footer_buttons=None) %}
|
||||
<div id="{{ id }}" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideModal('{{ id }}')"></div>
|
||||
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="{{ id }}"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="modal-close" onclick="hideModal('{{ id }}')">×</button>
|
||||
<button class="modal-close" data-action="hide-modal" data-modal-id="{{ id }}"
|
||||
aria-label="{{ _('Close') }}">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ content }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Add Personal Note - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Add Personal Note{% endblock %}
|
||||
{% block title %}{{ _('Add Personal Note') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Add Personal Note') }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<span class="breadcrumb">
|
||||
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
|
||||
@@ -19,7 +19,7 @@
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-sticky-note"></i> Add Personal Note</h3>
|
||||
<h3><i class="fas fa-sticky-note"></i> {{ _('Add Personal Note') }}</h3>
|
||||
{% if context_type == 'tryout' %}
|
||||
<p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p>
|
||||
{% elif context_type == 'match' %}
|
||||
@@ -27,7 +27,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="
|
||||
<form method="POST" data-ajax action="
|
||||
{% if context_type == 'tryout' %}
|
||||
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
||||
{% elif context_type == 'match' %}
|
||||
@@ -44,9 +44,9 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player_id">Player</label>
|
||||
<label for="player_id">{{ _('Player') }}</label>
|
||||
<select name="player_id" id="player_id" class="form-select" required>
|
||||
<option value="">-- Select a player --</option>
|
||||
<option value="">{{ _('-- Select a player --') }}</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}>
|
||||
{{ player.username }}
|
||||
@@ -56,15 +56,15 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Note Content</label>
|
||||
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="Enter your coaching notes..." required></textarea>
|
||||
<label for="content">{{ _('Note Content') }}</label>
|
||||
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="{{ _('Enter your coaching notes...') }}" required></textarea>
|
||||
</div>
|
||||
|
||||
{% if context_type != 'tryout' and context_type != 'match' %}
|
||||
<div class="form-group">
|
||||
<label for="tryout_id">Link to Tryout (Optional)</label>
|
||||
<label for="tryout_id">{{ _('Link to Tryout (Optional)') }}</label>
|
||||
<select name="tryout_id" id="tryout_id" class="form-select">
|
||||
<option value="">-- No tryout --</option>
|
||||
<option value="">{{ _('-- No tryout --') }}</option>
|
||||
{% for t in tryouts %}
|
||||
<option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
@@ -72,9 +72,9 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="match_id">Link to Match (Optional)</label>
|
||||
<label for="match_id">{{ _('Link to Match (Optional)') }}</label>
|
||||
<select name="match_id" id="match_id" class="form-select">
|
||||
<option value="">-- No match --</option>
|
||||
<option value="">{{ _('-- No match --') }}</option>
|
||||
{% for m in matches %}
|
||||
<option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
@@ -82,9 +82,9 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="team_id">Link to Team (Optional)</label>
|
||||
<label for="team_id">{{ _('Link to Team (Optional)') }}</label>
|
||||
<select name="team_id" id="team_id" class="form-select">
|
||||
<option value="">-- No team --</option>
|
||||
<option value="">{{ _('-- No team --') }}</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
{% if team_notes %}
|
||||
<div class="form-group">
|
||||
<label>Team Notes (Reference)</label>
|
||||
<label>{{ _('Team Notes (Reference)') }}</label>
|
||||
<div class="team-notes-reference">
|
||||
{% for note in team_notes %}
|
||||
<div class="note-reference-item">
|
||||
@@ -117,7 +117,7 @@
|
||||
{% endif %}
|
||||
" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Add Note
|
||||
<i class="fas fa-save"></i> {{ _('Add Note') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user