Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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:
|
# branches:
|
||||||
# - main # Optional: Run automatically on pushes to the main branch
|
# - main # Optional: Run automatically on pushes to the main branch
|
||||||
|
|
||||||
|
# OPS-011 — what this workflow now guarantees, and what it still does not.
|
||||||
|
#
|
||||||
|
# Guaranteed:
|
||||||
|
# - nothing is uploaded unless the test suite and the linters pass;
|
||||||
|
# - only files on an explicit allowlist are uploaded, so a new file at the
|
||||||
|
# repository root does not reach production by default. That is how
|
||||||
|
# clear_db.py — a script that DROPs every table and recreates
|
||||||
|
# admin/password — got there in the first place;
|
||||||
|
# - after the upload, /health is polled until it answers healthy, and the
|
||||||
|
# job fails loudly if it does not. Before, a half-uploaded tree was a
|
||||||
|
# green deployment.
|
||||||
|
#
|
||||||
|
# NOT guaranteed — the switch is not atomic. Files are mirrored in place, so
|
||||||
|
# for the length of the transfer production runs a mixture of two versions.
|
||||||
|
# Closing that needs a release-directory layout, which has three
|
||||||
|
# prerequisites, two of which cannot be done from here:
|
||||||
|
#
|
||||||
|
# 1. the Pterodactyl startup command must run the app from `current/`
|
||||||
|
# rather than from the server root, and the server must be restarted on
|
||||||
|
# switch — a panel change;
|
||||||
|
# 2. `documents/`, `logs/` and `.env` must live beside the releases, not
|
||||||
|
# inside one. DOCUMENTS_ROOT exists for this (app/storage.py);
|
||||||
|
# 3. contract paths must be relative to that root, so the switch does not
|
||||||
|
# strand them. Done: new rows are relative, old absolute ones still
|
||||||
|
# resolve.
|
||||||
|
#
|
||||||
|
# docs/deployment.md carries the design and the rollback procedure.
|
||||||
|
|
||||||
|
# Least privilege (CI-003). This job never writes back to the repository; it
|
||||||
|
# holds the SSH key to the production node, which makes it the most valuable
|
||||||
|
# job in either forge to compromise.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Actions pinned to a commit, version in the comment. A tag is a moving
|
||||||
|
# pointer, and moving `v4` here means running arbitrary code in the job that
|
||||||
|
# holds that key. If the Gitea runner ever fails to resolve a commit ref, it
|
||||||
|
# fails on the checkout step — loudly, like the `@v7` that did not exist.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-to-sftp:
|
deploy-to-sftp:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# Was @v7, which does not exist (latest major is v5): the workflow
|
||||||
|
# failed on its very first step.
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Install lftp and ssh
|
- name: Set up Python
|
||||||
run: sudo apt-get update && sudo apt-get install -y lftp openssh-client
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
|
# The gate. This runner is not the GitHub one, so a green CI over
|
||||||
|
# there proves nothing about what is about to be shipped from here:
|
||||||
|
# the deploy is triggered by hand, on whatever the branch holds.
|
||||||
|
- name: Refuse to deploy a broken tree
|
||||||
|
run: |
|
||||||
|
python -m pytest -q
|
||||||
|
python -m ruff check .
|
||||||
|
python -m ruff format --check .
|
||||||
|
|
||||||
|
# An allowlist, not a list of exclusions. The previous form mirrored
|
||||||
|
# the whole working tree minus nine globs, so every file added to the
|
||||||
|
# repository shipped to production unless someone remembered to
|
||||||
|
# exclude it. This inverts the default: a new top-level file has to be
|
||||||
|
# named here to reach the server.
|
||||||
|
- name: Assemble the release payload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p payload
|
||||||
|
cp -r app payload/
|
||||||
|
cp requirements.txt wsgi.py payload/
|
||||||
|
# Compiled catalogues are versioned deliberately: the deployment is
|
||||||
|
# a file mirror with no build step (docs/translations.md).
|
||||||
|
find payload -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||||
|
find payload -name '*.pyc' -delete
|
||||||
|
echo "Shipping $(find payload -type f | wc -l) files:"
|
||||||
|
find payload -maxdepth 2 -type d | sort
|
||||||
|
|
||||||
- name: Set up SSH Private Key
|
- name: Set up SSH Private Key
|
||||||
env:
|
env:
|
||||||
@@ -29,14 +102,51 @@ jobs:
|
|||||||
|
|
||||||
- name: Push files via SFTP with progress
|
- name: Push files via SFTP with progress
|
||||||
run: |
|
run: |
|
||||||
# The mirror command below uses the -R (reverse) flag
|
# --delete is deliberately NOT used. Uploaded contracts, logs and the
|
||||||
# to push from local './' to remote './'
|
# server's own .env live under the deployment root and are absent
|
||||||
# Connection is made using 'open' inside the execution block to enforce SSH key usage
|
# from the repository; deleting anything not present locally would
|
||||||
|
# destroy them. Stale files therefore still accumulate — that is the
|
||||||
|
# other half of what the release-directory layout would fix.
|
||||||
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
|
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
|
||||||
set sftp:auto-confirm yes; \
|
set sftp:auto-confirm yes; \
|
||||||
set net:max-retries 5; \
|
set net:max-retries 5; \
|
||||||
set net:timeout 30; \
|
set net:timeout 30; \
|
||||||
set cmd:fail-exit yes; \
|
set cmd:fail-exit yes; \
|
||||||
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
|
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
|
||||||
mirror -R --verbose --parallel=4 ./ ./; \
|
mirror -R --verbose --parallel=4 ./payload/ ./; \
|
||||||
quit"
|
quit"
|
||||||
|
|
||||||
|
# Without this a deployment that left the site 500ing reported success,
|
||||||
|
# and the first person to hear about it was a user. /health checks the
|
||||||
|
# database connection and reports whether the Discord bot thread is
|
||||||
|
# alive (OPS-012).
|
||||||
|
# HEALTH_URL is carried as a secret rather than as a variable. It is
|
||||||
|
# not secret — it is the public site — but `secrets` is the context
|
||||||
|
# this runner is already known to support, and a smoke test that fails
|
||||||
|
# to run because of an unsupported expression is worse than none.
|
||||||
|
- name: Smoke test
|
||||||
|
if: ${{ secrets.HEALTH_URL != '' }}
|
||||||
|
env:
|
||||||
|
HEALTH_URL: ${{ secrets.HEALTH_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The app is restarted by the panel, not by this workflow, so the
|
||||||
|
# first few probes are expected to fail or answer from the old
|
||||||
|
# process. Two minutes, then give up.
|
||||||
|
for attempt in $(seq 1 24); do
|
||||||
|
body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body=''
|
||||||
|
if echo "$body" | grep -q '"status": *"healthy"'; then
|
||||||
|
echo "Healthy after ${attempt} attempt(s):"
|
||||||
|
echo "$body"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "attempt ${attempt}: not healthy yet"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Warn when no health check is configured
|
||||||
|
if: ${{ secrets.HEALTH_URL == '' }}
|
||||||
|
run: |
|
||||||
|
echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https://<host>/health in the repository secrets."
|
||||||
|
|||||||
+54
-26
@@ -2,7 +2,7 @@ name: CI - Security & Lint
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [main, master, 'audit/**']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
workflow_dispatch: # Allow manual triggers
|
workflow_dispatch: # Allow manual triggers
|
||||||
@@ -11,84 +11,112 @@ concurrency:
|
|||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# Least privilege: nothing here writes back to the repository.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Third-party actions are pinned to a commit, with the version in a comment
|
||||||
|
# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a
|
||||||
|
# job that holds this repository's token. The comment is what makes the pin
|
||||||
|
# maintainable — a bare 40-character hash tells a reader nothing about
|
||||||
|
# whether it is current. Dependabot updates both together.
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: '3.12'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
security-audit:
|
security-audit:
|
||||||
name: Security Audit
|
name: Security Audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install pip-audit
|
||||||
run: pip install pip-audit
|
run: pip install pip-audit==2.9.0
|
||||||
|
|
||||||
- name: Scan for vulnerable dependencies
|
# Previously: `pip-audit --require-hashes --no-deps || pip-audit`.
|
||||||
run: pip-audit --require-hashes --no-deps || pip-audit
|
# Neither form named the requirements file, so the fallback audited the
|
||||||
|
# runner's environment — which contained pip-audit and nothing else.
|
||||||
|
# The job passed green while checking none of the application's
|
||||||
|
# dependencies. -r makes it audit what the application actually pins.
|
||||||
|
- name: Scan declared dependencies for known vulnerabilities
|
||||||
|
run: pip-audit -r requirements.txt
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint with Ruff
|
name: Lint with Ruff
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
- name: Install ruff
|
- name: Install ruff
|
||||||
run: pip install ruff
|
run: pip install ruff==0.14.4
|
||||||
|
|
||||||
|
# Rule selection and per-file ignores live in pyproject.toml. Before it
|
||||||
|
# existed, this step ran ruff's bare defaults with no configuration.
|
||||||
- name: Run ruff linter
|
- name: Run ruff linter
|
||||||
run: ruff check . --output-format=github
|
run: ruff check . --output-format=github
|
||||||
|
|
||||||
- name: Run ruff formatter check
|
# Enabled now that the repository has been formatted once, in its own
|
||||||
|
# commit (QUA-002). Reaching this step before that would have failed on
|
||||||
|
# 72 of 76 files for reasons unrelated to correctness.
|
||||||
|
- name: Check formatting
|
||||||
run: ruff format --check .
|
run: ruff format --check .
|
||||||
|
|
||||||
security-scan:
|
security-scan:
|
||||||
name: Security Scan
|
name: Security Scan
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|
||||||
- name: Install app dependencies
|
- name: Install app dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
|
# The path was `security_scan.py`, but the script lives under
|
||||||
|
# app/supporting_scripts/. The step had therefore failed on every run
|
||||||
|
# since the file was moved.
|
||||||
- name: Run security scan
|
- name: Run security scan
|
||||||
env:
|
env:
|
||||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||||
|
DATABASE_URL: 'sqlite:///:memory:'
|
||||||
FLASK_DEBUG: 'false'
|
FLASK_DEBUG: 'false'
|
||||||
run: python security_scan.py --skip-http
|
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [security-audit, lint]
|
needs: [security-audit, lint]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Previously an `echo` guarded by continue-on-error: the job reported
|
||||||
|
# success without executing anything. The suite needs no environment
|
||||||
|
# variables and no database server: create_app() takes its configuration
|
||||||
|
# as an argument and the fixtures use a temporary SQLite file.
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||||
echo "No tests configured yet. Add tests to the project."
|
|
||||||
# python -m pytest tests/ --cov=. --cov-report=xml
|
|
||||||
continue-on-error: true
|
|
||||||
|
|||||||
+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/
|
*.key
|
||||||
*.db
|
|
||||||
|
|
||||||
documents/
|
|
||||||
|
|
||||||
__pycache__/
|
|
||||||
*.cpython-313.pyc
|
|
||||||
*.cpython-312.pyc
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
.pytest_cache/
|
|
||||||
.coverage
|
|
||||||
|
|
||||||
htmlcov/
|
|
||||||
.DS_Store
|
|
||||||
*.log
|
|
||||||
|
|
||||||
.certs/
|
|
||||||
*.pem
|
*.pem
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
.certs/
|
||||||
|
id_rsa
|
||||||
|
id_ed25519
|
||||||
|
known_hosts
|
||||||
|
credentials.json
|
||||||
|
service-account*.json
|
||||||
|
|
||||||
docs/
|
# =============================================================================
|
||||||
*.html
|
# Assistants IA — Claude
|
||||||
|
# =============================================================================
|
||||||
|
.claude/
|
||||||
|
.claude.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
CLAUDE.md
|
||||||
|
CLAUDE.local.md
|
||||||
|
.anthropic/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Assistants IA — ChatGPT / OpenAI
|
||||||
|
# =============================================================================
|
||||||
|
.chatgpt/
|
||||||
|
.openai/
|
||||||
|
.codex/
|
||||||
|
AGENTS.md
|
||||||
|
chatgpt-*.md
|
||||||
|
openai-*.md
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Assistants IA — autres outils
|
||||||
|
# =============================================================================
|
||||||
|
.cursor/
|
||||||
|
.cursorrules
|
||||||
|
.cursorignore
|
||||||
|
.windsurf/
|
||||||
|
.windsurfrules
|
||||||
|
.aider*
|
||||||
|
.continue/
|
||||||
|
.clinerules
|
||||||
|
.roo/
|
||||||
|
.gemini/
|
||||||
|
GEMINI.md
|
||||||
|
.github/copilot-instructions.md
|
||||||
|
.copilot/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Notes de travail et suivi générés par assistant IA
|
||||||
|
# =============================================================================
|
||||||
|
# Rapports d'audit, plans, brouillons et notes de session : documents de
|
||||||
|
# travail, non destinés à être versionnés dans le dépôt applicatif.
|
||||||
|
.ai/
|
||||||
|
audit/
|
||||||
|
*.audit.md
|
||||||
|
NOTES-IA.md
|
||||||
|
TODO-IA.md
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Python
|
||||||
|
# =============================================================================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environnements virtuels
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Outils
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
coverage_html_report/
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Données applicatives — ne jamais versionner
|
||||||
|
# =============================================================================
|
||||||
|
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||||
|
# voir l'historique du commit `4fd27ba`).
|
||||||
|
# Données PostgreSQL locales — gérées par app/local_postgres.py
|
||||||
|
instance/pgdata/
|
||||||
|
instance/pg.log
|
||||||
instance/
|
instance/
|
||||||
*.db
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Contrats téléversés (données personnelles)
|
||||||
|
documents/
|
||||||
|
|
||||||
|
# Sauvegardes produites par app/supporting_scripts/backup.py
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Journaux applicatifs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# État d'exécution du bot Discord
|
||||||
|
discord_pending.json
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Éditeurs et systèmes d'exploitation
|
||||||
|
# =============================================================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# NOTE — règles retirées volontairement
|
||||||
|
# =============================================================================
|
||||||
|
# `docs/` et `*.html` figuraient ici et ignoraient TOUT fichier .html du
|
||||||
|
# dépôt, y compris les templates Jinja2 de app/templates/. Un nouveau
|
||||||
|
# template était donc invisible pour git : l'application fonctionnait en
|
||||||
|
# local et cassait en production avec une TemplateNotFound, sans que
|
||||||
|
# `git status` ne signale quoi que ce soit.
|
||||||
|
#
|
||||||
|
# Les rapports de couverture HTML, qui étaient vraisemblablement la cible
|
||||||
|
# de ces règles, sont couverts ci-dessus par `htmlcov/` et
|
||||||
|
# `coverage_html_report/`.
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Internationalisation
|
||||||
|
# =============================================================================
|
||||||
|
# Le gabarit de catalogue est entierement regenerable :
|
||||||
|
# pybabel extract -F babel.cfg -k _l -o messages.pot .
|
||||||
|
# Les catalogues .po (sources de traduction) et .mo (compiles, lus a
|
||||||
|
# l'execution) sont eux versionnes : le deploiement est un simple miroir de
|
||||||
|
# fichiers, sans etape de compilation.
|
||||||
|
messages.pot
|
||||||
|
|||||||
@@ -1,90 +1,153 @@
|
|||||||
# Plateforme centralisée de tryouts
|
# Plateforme centralisée de tryouts
|
||||||
|
|
||||||
## Security Configuration
|
Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
|
||||||
|
évaluations, gestion des équipes, disponibilités, contrats, et notifications
|
||||||
|
Discord.
|
||||||
|
|
||||||
### Required Environment Variables
|
Le site est servi **en français**, l'anglais reste accessible par le sélecteur
|
||||||
|
de la barre latérale (voir `docs/translations.md`).
|
||||||
|
|
||||||
Before deploying, create a `.env` file which integrates everything in the .env.exemple.
|
---
|
||||||
Ensure you follow the comments of the exemple if you are to use this tool in production.
|
|
||||||
|
|
||||||
|
## Démarrer
|
||||||
|
|
||||||
### Security Features Implemented
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
cp app/.env.example .env # puis remplir SECRET_KEY et DATABASE_URL
|
||||||
|
.venv/Scripts/python run.py # développement, http://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
|
`SECRET_KEY` et `DATABASE_URL` sont **obligatoires** : `create_app()` refuse
|
||||||
- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled
|
de démarrer sans eux. `DATABASE_URL` doit pointer sur PostgreSQL ; le pilote
|
||||||
- **CSRF Protection**: Enabled by default on all forms
|
psycopg 3 est nommé automatiquement si l'URL n'en nomme pas.
|
||||||
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
|
|
||||||
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
|
|
||||||
- **Open Redirect Prevention**: URL validation on login redirect
|
|
||||||
- **Authorization Checks**: Proper ownership validation on all sensitive operations
|
|
||||||
- **nginx**: reverse-proxy and load balancer
|
|
||||||
- **Waitress WSGI**: Production ready WSGI
|
|
||||||
|
|
||||||
### When true in .env:
|
Production : `python wsgi.py` (Waitress derrière nginx). Voir
|
||||||
- **Forces HTTPS only**
|
`docs/deployment.md`.
|
||||||
- **Forcer secure cookies**
|
|
||||||
|
|
||||||
## App details
|
Ce sont les **deux seuls** points d'entrée.
|
||||||
|
|
||||||
### Code
|
## Vérifier
|
||||||
|
|
||||||
- Full python backend using flask
|
```bash
|
||||||
- statics are pure HTML and CSS
|
.venv/Scripts/python -m pytest # suite complète
|
||||||
- Some js to add logic to styling and showing certain pages/cards
|
.venv/Scripts/python -m ruff check . # lint
|
||||||
|
.venv/Scripts/python -m ruff format --check .
|
||||||
|
```
|
||||||
|
|
||||||
### Functionalities
|
Les trois tournent en CI et y sont bloquants.
|
||||||
|
|
||||||
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role.
|
---
|
||||||
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
|
|
||||||
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
|
|
||||||
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
|
|
||||||
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
|
|
||||||
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
|
|
||||||
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
|
|
||||||
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
|
|
||||||
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
|
|
||||||
|
|
||||||
|
## Ce que fait l'application
|
||||||
|
|
||||||
## Discord Integration
|
- **Comptes et rôles** — cinq rôles : président (`admin`), gérant
|
||||||
|
(`manager`), coach, joueur (`player`), recruteur (`scout`). Le président
|
||||||
|
attribue les rôles.
|
||||||
|
- **Sélections** — organisation des tryouts, trois formats de match
|
||||||
|
(équipe contre équipe, joueur contre joueur, scrim), évaluation des
|
||||||
|
joueurs sur dix critères.
|
||||||
|
- **Équipes** — effectifs de la saison, matchs et entraînements. Le
|
||||||
|
formulaire d'entraînement affiche les disponibilités des joueurs.
|
||||||
|
- **Disponibilités** — créneaux hebdomadaires des joueurs, créneaux
|
||||||
|
réservables des coachs.
|
||||||
|
- **Notes** — un coach écrit des notes d'équipe (visibles par l'équipe) et
|
||||||
|
des notes nominatives (visibles par le joueur concerné).
|
||||||
|
- **Un-à-un** — un joueur demande une séance à son coach ; le coach répond
|
||||||
|
depuis le site ou par une réaction sur le message privé Discord.
|
||||||
|
- **Contrats** — dépôt d'un contrat par le staff, signature par le joueur.
|
||||||
|
|
||||||
The application supports sending Discord direct messages to coaches when players request One on One sessions,
|
## Comment c'est construit
|
||||||
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
|
|
||||||
|
|
||||||
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request.
|
Backend Python 3.12 / Flask, rendu serveur en Jinja2, CSS et JavaScript
|
||||||
Same thing with **matches** and **practices**, the players can react or answer on the platform.
|
maison, sans framework front. Base PostgreSQL via SQLAlchemy. Bot Discord
|
||||||
|
(`discord.py`) dans un fil du même processus que le serveur web.
|
||||||
|
|
||||||
### Setup Instructions
|
`docs/architecture.md` contient les diagrammes (classes, paquets, flux
|
||||||
|
d'une requête).
|
||||||
|
|
||||||
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
|
---
|
||||||
|
|
||||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
## Sécurité
|
||||||
2. Create a new application
|
|
||||||
3. Go to the "Bot" tab and create a bot user
|
|
||||||
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
|
|
||||||
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
|
|
||||||
|
|
||||||
#### 2. Add Bot to your server
|
En place et vérifié par des tests :
|
||||||
|
|
||||||
For the bot to send DMs:
|
- **Limitation de débit** sur la connexion (10 requêtes/minute par IP).
|
||||||
1. Each user must have the bot added to their Discord server OR be friends with the bot
|
- **Cookies de session** `HttpOnly`, `SameSite=Lax`, `Secure`, avec
|
||||||
2. Users need to add their Discord User ID to their profile:
|
expiration effective.
|
||||||
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
|
- **CSRF** sur tous les formulaires, y compris la déconnexion (en POST).
|
||||||
- Right-click on their profile → Copy ID
|
- **HTTPS** forcé en production, **HSTS**.
|
||||||
- Enter this numeric ID in the "Discord User ID" field in their profile settings
|
- **CSP sans `unsafe-inline`** sur `script-src` : aucun gestionnaire
|
||||||
|
d'événement en ligne, chaque bloc `<script>` porte un nonce par requête.
|
||||||
|
- **Redirections** validées (rien ne sort du site).
|
||||||
|
- **Validation** par schémas marshmallow sur les formulaires de compte, avec
|
||||||
|
politique de mot de passe.
|
||||||
|
- **Autorisation** centralisée dans `app/permissions.py`.
|
||||||
|
- **Journal d'authentification** (`logs/auth.log`) : connexions, échecs,
|
||||||
|
changements de rôle, suppressions de compte.
|
||||||
|
- **Téléversements** vérifiés par extension *et* par signature de fichier.
|
||||||
|
|
||||||
|
Ce qui **n'est pas** fait, pour que personne ne s'y fie :
|
||||||
|
|
||||||
### How It Works
|
- **Aucune migration de schéma.** `db.create_all()` crée les tables
|
||||||
|
manquantes et ne modifie jamais une table existante : une colonne ajoutée
|
||||||
|
à un modèle est absente de la production.
|
||||||
|
- **Les secrets de l'historique git ne sont pas révoqués** — jeton du bot,
|
||||||
|
mot de passe PostgreSQL, `SECRET_KEY`.
|
||||||
|
- **`trusted_proxy='*'`** reste le défaut : l'en-tête `X-Forwarded-For` est
|
||||||
|
accepté de n'importe quelle source, donc la limitation par IP est
|
||||||
|
contournable. C'est désormais la variable `TRUSTED_PROXY` plutôt qu'une
|
||||||
|
constante — `docs/deployment.md` donne la valeur pour chaque topologie.
|
||||||
|
- **Les comptes créés par le formulaire d'inscription sont actifs
|
||||||
|
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
|
||||||
|
de produit en attente, voir `docs/roles-and-permissions.md`.
|
||||||
|
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
|
||||||
|
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
|
||||||
|
snowflake et l'application refuse les nouvelles collisions. Les doublons
|
||||||
|
historiques doivent être relevés puis corrigés avant la contrainte
|
||||||
|
`UNIQUE` (`schema_report.py --check-discord-identities`).
|
||||||
|
|
||||||
When a player submits a One on One request:
|
`docs/security-checklist.md` détaille la liste avant mise en production.
|
||||||
1. The system checks if the coach has a Discord User ID configured
|
|
||||||
2. If configured, a direct message is sent to the coach via the Discord bot
|
|
||||||
|
|
||||||
### Message Format
|
### Documentation
|
||||||
|
|
||||||
The Discord DM includes:
|
| Document | Pour |
|
||||||
- Player name
|
|---|---|
|
||||||
- Team name
|
| `docs/deployment.md` | Installer, déployer, revenir en arrière |
|
||||||
- Requested date and time slot
|
| `docs/roles-and-permissions.md` | Qui peut faire quoi, et où c'est décidé |
|
||||||
- Discussion points (if provided)
|
| `docs/database-restore.md` | Sauvegarder et restaurer |
|
||||||
- Link to the application for approval/rejection
|
| `docs/database-schema.md` | Sortir de `create_all()` : relevé, Alembic, migrations |
|
||||||
- Two provided reactions to accept or refuse via discord
|
| `docs/incident-runbook.md` | Quand quelque chose ne va pas |
|
||||||
|
| `docs/architecture.md` | Diagrammes |
|
||||||
|
| `docs/translations.md` | Ajouter ou corriger une traduction |
|
||||||
|
| `docs/security-checklist.md` | Avant une mise en production |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intégration Discord
|
||||||
|
|
||||||
|
Messages privés au coach lors d'une demande d'un-à-un, aux joueurs à la
|
||||||
|
création d'un match ou d'un entraînement les concernant, et rappel 24 h
|
||||||
|
avant un match. Les réponses se font par réaction sur le message ou depuis
|
||||||
|
le site.
|
||||||
|
|
||||||
|
L'état des messages en attente de réponse est dans `discord_pending.json`,
|
||||||
|
**non versionné** : c'est de l'état d'exécution, propre à chaque serveur.
|
||||||
|
|
||||||
|
### Mise en place
|
||||||
|
|
||||||
|
Le bot du club existe déjà ; ce qui suit ne concerne qu'une nouvelle
|
||||||
|
installation.
|
||||||
|
|
||||||
|
1. Créer une application sur le [portail développeur
|
||||||
|
Discord](https://discord.com/developers/applications), puis un bot.
|
||||||
|
2. Copier le jeton dans `DISCORD_BOT_TOKEN`.
|
||||||
|
3. Activer **Message Content Intent** dans les *Privileged Gateway Intents*.
|
||||||
|
C'est le seul intent privilégié demandé : il sert à lire le motif d'un
|
||||||
|
refus écrit en réponse au message.
|
||||||
|
4. Chaque personne doit partager un serveur avec le bot (ou l'avoir en ami)
|
||||||
|
pour recevoir un message privé, et renseigner son identifiant Discord
|
||||||
|
dans son profil (Discord → Paramètres → Avancés → Mode développeur, puis
|
||||||
|
clic droit sur son profil → Copier l'identifiant).
|
||||||
|
|
||||||
|
`/health` indique si le bot tourne et s'il est connecté.
|
||||||
|
|||||||
@@ -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
|
import os
|
||||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
import secrets
|
||||||
from flask_cors import CORS
|
|
||||||
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
|
||||||
from sqlalchemy import text
|
|
||||||
from werkzeug.exceptions import HTTPException
|
|
||||||
import markupsafe
|
import markupsafe
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
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()
|
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):
|
def nl2br(value):
|
||||||
"""Convert newlines to HTML line breaks.
|
"""Convert newlines to HTML line breaks.
|
||||||
@@ -26,13 +76,103 @@ def nl2br(value):
|
|||||||
Markup: HTML-safe string with line breaks.
|
Markup: HTML-safe string with line breaks.
|
||||||
"""
|
"""
|
||||||
if value:
|
if value:
|
||||||
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
|
# Markup('<br>').join() escapes each segment before joining.
|
||||||
|
# Markup('<br>'.join(...)) would mark attacker-controlled text as safe.
|
||||||
|
return markupsafe.Markup('<br>').join(str(value).splitlines())
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def normalise_database_url(url):
|
||||||
|
"""Name the PostgreSQL driver explicitly in a connection URL.
|
||||||
|
|
||||||
|
`postgresql://…` does not mean "whichever driver is installed": it means
|
||||||
|
psycopg2, which SQLAlchemy imports at create_engine() time. requirements
|
||||||
|
.txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean
|
||||||
|
install starting against the URL Render hands out — and the one this
|
||||||
|
project's own documentation shows — raises
|
||||||
|
|
||||||
|
ModuleNotFoundError: No module named 'psycopg2'
|
||||||
|
|
||||||
|
before the first request. Anything with a driver already spelled out
|
||||||
|
(`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so
|
||||||
|
naming psycopg2 stays possible for an environment that has it.
|
||||||
|
|
||||||
|
`postgres://` is the legacy alias several hosts still emit; SQLAlchemy
|
||||||
|
dropped it in 1.4.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Value of DATABASE_URL, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: The URL, with a driver named when it was PostgreSQL.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
scheme, separator, rest = url.partition('://')
|
||||||
|
if not separator or '+' in scheme:
|
||||||
|
return url
|
||||||
|
if scheme in ('postgres', 'postgresql'):
|
||||||
|
return f'postgresql+psycopg://{rest}'
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def build_csp(*, allow_inline_script, nonce=None):
|
||||||
|
"""Assemble the Content-Security-Policy header.
|
||||||
|
|
||||||
|
Two mutually exclusive modes, and they really are exclusive.
|
||||||
|
|
||||||
|
Under CSP level 3, a browser that understands nonces **ignores
|
||||||
|
'unsafe-inline' entirely as soon as a nonce is present**. Emitting both
|
||||||
|
would therefore not be a gentle transition: it would drop every inline
|
||||||
|
script and every onclick attribute at once, in modern browsers only.
|
||||||
|
The switch has to be atomic, which is why one flag drives it.
|
||||||
|
|
||||||
|
While allow_inline_script is true no nonce is emitted at all, so adding
|
||||||
|
nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless.
|
||||||
|
|
||||||
|
Flipping the flag requires every inline event handler to be gone first.
|
||||||
|
A nonce cannot authorise an onclick attribute — nonces apply to script
|
||||||
|
elements, never to handler attributes. See tests/test_csp.py, which
|
||||||
|
tracks how many are left.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allow_inline_script: Keep 'unsafe-inline' in script-src.
|
||||||
|
nonce: Per-request nonce, used only when inline script is not allowed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The header value.
|
||||||
|
"""
|
||||||
|
if allow_inline_script:
|
||||||
|
script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||||
|
else:
|
||||||
|
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
|
||||||
|
|
||||||
|
return '; '.join(
|
||||||
|
[
|
||||||
|
"default-src 'self'",
|
||||||
|
f'script-src {script_src}',
|
||||||
|
# style-src is a separate migration: inline style="" attributes are
|
||||||
|
# spread across the templates and are not an XSS vector on their own.
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
|
||||||
|
"font-src 'self' https://cdnjs.cloudflare.com",
|
||||||
|
"img-src 'self' data: https://cdn.discordapp.com",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config=None):
|
||||||
"""Create and configure the Flask application.
|
"""Create and configure the Flask application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Optional mapping of configuration overrides, applied after the
|
||||||
|
environment defaults and before validation. This is what makes the
|
||||||
|
factory usable from tests: pass a throwaway database URI, a dummy
|
||||||
|
secret, and turn off the Discord bot, without touching os.environ.
|
||||||
|
|
||||||
Initializes Flask with:
|
Initializes Flask with:
|
||||||
- Secret key for session security
|
- Secret key for session security
|
||||||
- Database configuration
|
- Database configuration
|
||||||
@@ -52,30 +192,103 @@ def create_app():
|
|||||||
Flask: Configured Flask application instance.
|
Flask: Configured Flask application instance.
|
||||||
"""
|
"""
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Cache-busting stamps for static files, filled lazily by the url_defaults
|
||||||
|
# hook below. Per application instance, so the test suite does not carry
|
||||||
|
# one app's mtimes into the next.
|
||||||
|
_static_stamps = {}
|
||||||
|
|
||||||
|
# --- defaults from the environment ------------------------------------
|
||||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
app.config['WTF_CSRF_ENABLED'] = True
|
||||||
|
app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '')
|
||||||
|
app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Now false: every inline event handler has been replaced by a
|
||||||
|
# data-action attribute dispatched from main.js, so script-src no longer
|
||||||
|
# needs 'unsafe-inline'. Inline <script> blocks carry a per-request
|
||||||
|
# nonce. The escape hatch remains for a deployment that hits an
|
||||||
|
# overlooked handler — but leaving it on gives up the protection that
|
||||||
|
# would have blocked SEC-XSS-001.
|
||||||
|
app.config['CSP_ALLOW_INLINE_SCRIPT'] = (
|
||||||
|
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'false').lower() == 'true'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Internationalisation. French is the site's primary language.
|
||||||
|
app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE
|
||||||
|
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), 'translations'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Side effects of create_app(), both on by default so that production and
|
||||||
|
# development behave exactly as before. Tests turn them off.
|
||||||
|
app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
|
||||||
|
app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Where the rate limiter keeps its counters (SEC-WEB-004).
|
||||||
|
#
|
||||||
|
# `memory://` is what Flask-Limiter falls back to when nothing is set,
|
||||||
|
# and it is the correct choice here: Waitress serves this application
|
||||||
|
# from one process, so one set of counters in that process is all there
|
||||||
|
# is to share. Naming it changes nothing at runtime and two things
|
||||||
|
# otherwise — it stops being an accident, and it becomes settable to a
|
||||||
|
# Redis URI on the day the deployment gains a second process, which is
|
||||||
|
# the day in-memory counters would start letting through N times the
|
||||||
|
# configured limit without anyone noticing.
|
||||||
|
#
|
||||||
|
# What this does not fix: the counters are keyed on an IP address that
|
||||||
|
# is forgeable while TRUSTED_PROXY is unresolved (OPS-002). Shared
|
||||||
|
# storage for a forgeable key buys nothing, which is why that one is
|
||||||
|
# the prerequisite and not this.
|
||||||
|
app.config['RATELIMIT_STORAGE_URI'] = os.getenv('RATELIMIT_STORAGE_URI', 'memory://')
|
||||||
|
|
||||||
|
# --- caller overrides win ---------------------------------------------
|
||||||
|
if config:
|
||||||
|
app.config.update(config)
|
||||||
|
|
||||||
|
# --- validation, after overrides so tests can supply their own ---------
|
||||||
if not app.config['SECRET_KEY']:
|
if not app.config['SECRET_KEY']:
|
||||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
|
||||||
if not app.config['SQLALCHEMY_DATABASE_URI']:
|
if not app.config['SQLALCHEMY_DATABASE_URI']:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
|
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
|
||||||
)
|
)
|
||||||
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)
|
# File upload size limit (16 MB)
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||||
|
|
||||||
# Secure session cookie settings
|
# Secure session cookie settings
|
||||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
app.config['SESSION_COOKIE_SECURE'] = (
|
||||||
|
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||||
|
)
|
||||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||||
|
|
||||||
# Configure CORS - restrict to specific origins in production
|
# Configure CORS - restrict to specific origins in production
|
||||||
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
allowed_origins = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',')
|
||||||
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
||||||
|
|
||||||
|
# CORS is only configured when origins are named explicitly.
|
||||||
|
#
|
||||||
|
# The previous else-branch called CORS(app, supports_credentials=True)
|
||||||
|
# with no origins argument. flask-cors then defaults to '*' and, because
|
||||||
|
# credentials are allowed, echoes back whatever Origin the caller sent
|
||||||
|
# together with Access-Control-Allow-Credentials: true — the opposite of
|
||||||
|
# the "allow all (development) or none (production)" the comment claimed.
|
||||||
|
#
|
||||||
|
# Exploitation was blocked by SESSION_COOKIE_SAMESITE = 'Lax', which stops
|
||||||
|
# the browser attaching the session cookie to a cross-site fetch. That is
|
||||||
|
# a single setting standing between a misconfiguration and a cross-origin
|
||||||
|
# data leak. This application renders server-side HTML on one origin and
|
||||||
|
# needs no CORS policy at all.
|
||||||
if allowed_origins:
|
if allowed_origins:
|
||||||
CORS(
|
CORS(
|
||||||
app,
|
app,
|
||||||
@@ -84,33 +297,159 @@ def create_app():
|
|||||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
max_age=3600, # Cache preflight for 1 hour
|
max_age=3600, # Cache preflight for 1 hour
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# When no origins specified, allow all (development) or none (production)
|
|
||||||
# In production with a reverse proxy, CORS is handled at the Nginx level
|
|
||||||
CORS(
|
|
||||||
app,
|
|
||||||
supports_credentials=True,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
||||||
max_age=3600,
|
|
||||||
)
|
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
limiter.init_app(app)
|
limiter.init_app(app)
|
||||||
|
|
||||||
|
# Said once, at startup, because the failure mode is silent: counters in
|
||||||
|
# process memory are lost on every restart and are not shared, so a
|
||||||
|
# second worker would double every limit and nothing would report it.
|
||||||
|
if app.config['RATELIMIT_STORAGE_URI'].startswith('memory://'):
|
||||||
|
app.logger.info(
|
||||||
|
'Rate limiting counters are held in process memory. Correct for a '
|
||||||
|
'single-process deployment; set RATELIMIT_STORAGE_URI to a shared '
|
||||||
|
'backend before running more than one worker (SEC-WEB-004).'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app.logger.info(
|
||||||
|
'Rate limiting counters are held in a shared backend (%s).',
|
||||||
|
app.config['RATELIMIT_STORAGE_URI'].split('://', 1)[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
babel.init_app(app, locale_selector=i18n.select_locale)
|
||||||
|
|
||||||
|
# Exposed to every template so the language switcher can render itself
|
||||||
|
# without each view having to pass the list along.
|
||||||
|
@app.before_request
|
||||||
|
def generate_csp_nonce():
|
||||||
|
# Only meaningful once inline script is disallowed; generated
|
||||||
|
# unconditionally so templates can carry nonce="" beforehand.
|
||||||
|
g.csp_nonce = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def assign_request_id():
|
||||||
|
"""Give this request a name, so its log lines can be found (OBS-005).
|
||||||
|
|
||||||
|
Every record emitted while handling it carries this id — see
|
||||||
|
RequestIdFilter — which is what turns "an error happened around
|
||||||
|
14:32" into the six lines that led to it. It goes back in
|
||||||
|
X-Request-Id and onto the 500 page, so that a report of "it broke
|
||||||
|
when I clicked save" is enough to find the trace.
|
||||||
|
|
||||||
|
Generated here, never taken from an inbound header: with no trusted
|
||||||
|
proxy settled (OPS-002), an accepted header lets any caller write
|
||||||
|
arbitrary text — newlines included — into the log file.
|
||||||
|
"""
|
||||||
|
g.request_id = secrets.token_hex(8)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def expose_request_id(response):
|
||||||
|
response.headers['X-Request-Id'] = g.get('request_id', '-')
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.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
|
# Configure structured logging
|
||||||
from app.logging_config import configure_logging
|
from app.logging_config import configure_logging
|
||||||
|
|
||||||
configure_logging(app)
|
configure_logging(app)
|
||||||
|
|
||||||
|
from app.routes.admin import admin_bp
|
||||||
from app.routes.auth import auth_bp
|
from app.routes.auth import auth_bp
|
||||||
from app.routes.tryouts import tryouts_bp
|
|
||||||
from app.routes.evaluations import evaluations_bp
|
from app.routes.evaluations import evaluations_bp
|
||||||
from app.routes.users import users_bp
|
|
||||||
from app.routes.main import main_bp
|
from app.routes.main import main_bp
|
||||||
from app.routes.teams import teams_bp
|
|
||||||
from app.routes.matches import matches_bp
|
from app.routes.matches import matches_bp
|
||||||
from app.routes.team_matches import team_matches_bp
|
from app.routes.team_matches import team_matches_bp
|
||||||
|
from app.routes.teams import teams_bp
|
||||||
|
from app.routes.tryouts import tryouts_bp
|
||||||
|
from app.routes.users import users_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(tryouts_bp)
|
app.register_blueprint(tryouts_bp)
|
||||||
@@ -120,6 +459,7 @@ def create_app():
|
|||||||
app.register_blueprint(teams_bp)
|
app.register_blueprint(teams_bp)
|
||||||
app.register_blueprint(matches_bp)
|
app.register_blueprint(matches_bp)
|
||||||
app.register_blueprint(team_matches_bp)
|
app.register_blueprint(team_matches_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
# Register custom Jinja filters
|
# Register custom Jinja filters
|
||||||
app.jinja_env.filters['nl2br'] = nl2br
|
app.jinja_env.filters['nl2br'] = nl2br
|
||||||
@@ -137,25 +477,20 @@ def create_app():
|
|||||||
HSTS is only sent in production (non-debug) to avoid breaking
|
HSTS is only sent in production (non-debug) to avoid breaking
|
||||||
local development over plain HTTP.
|
local development over plain HTTP.
|
||||||
"""
|
"""
|
||||||
|
# X-XSS-Protection is deliberately not set: the auditor it addressed
|
||||||
|
# has been removed from every current browser, and its last versions
|
||||||
|
# introduced vulnerabilities of their own. CSP frame-ancestors and
|
||||||
|
# X-Frame-Options cover the remaining ground.
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
response.headers['X-Frame-Options'] = 'DENY'
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
||||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
response.headers['Permissions-Policy'] = (
|
response.headers['Permissions-Policy'] = (
|
||||||
'camera=(), microphone=(), geolocation=(), '
|
'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
|
||||||
'interest-cohort=(), payment=(), usb=()'
|
|
||||||
)
|
)
|
||||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||||
response.headers['Content-Security-Policy'] = (
|
response.headers['Content-Security-Policy'] = build_csp(
|
||||||
"default-src 'self'; "
|
allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'],
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
nonce=g.get('csp_nonce'),
|
||||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
|
||||||
"font-src 'self' https://cdnjs.cloudflare.com; "
|
|
||||||
"img-src 'self' data: https://cdn.discordapp.com; "
|
|
||||||
"connect-src 'self'; "
|
|
||||||
"frame-ancestors 'none'; "
|
|
||||||
"base-uri 'self'; "
|
|
||||||
"form-action 'self'"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only enable HSTS when HTTPS is actually being used
|
# Only enable HSTS when HTTPS is actually being used
|
||||||
@@ -177,11 +512,17 @@ def create_app():
|
|||||||
|
|
||||||
Respects the X-Forwarded-Proto header from reverse proxies.
|
Respects the X-Forwarded-Proto header from reverse proxies.
|
||||||
Can be disabled via FORCE_HTTPS environment variable.
|
Can be disabled via FORCE_HTTPS environment variable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response | None: A redirect, or None to let the request through.
|
||||||
"""
|
"""
|
||||||
if not app.debug:
|
if not app.debug and app.config['FORCE_HTTPS']:
|
||||||
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
|
already_secure = (
|
||||||
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true':
|
request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||||
|
)
|
||||||
|
if not already_secure:
|
||||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||||
|
return None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Health Check Endpoint
|
# Health Check Endpoint
|
||||||
@@ -202,20 +543,77 @@ def create_app():
|
|||||||
'version': '1.0.0',
|
'version': '1.0.0',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The bot runs in a daemon thread inside this process. When it dies
|
||||||
|
# the site keeps serving pages and every notification stops, with
|
||||||
|
# nothing to see from outside — which is how it stayed unnoticed.
|
||||||
|
# Reported, not fatal: a club without Discord reminders is degraded,
|
||||||
|
# not down, and a 503 here would take the site out of the load
|
||||||
|
# balancer for it (OPS-012).
|
||||||
|
if app.config['ENABLE_DISCORD_BOT']:
|
||||||
|
from app.discord_bot import bot_status
|
||||||
|
|
||||||
|
health_data['discord_bot'] = bot_status()
|
||||||
|
|
||||||
# Check database connectivity
|
# Check database connectivity
|
||||||
try:
|
try:
|
||||||
db.session.execute(text('SELECT 1'))
|
db.session.execute(text('SELECT 1'))
|
||||||
health_data['database'] = 'connected'
|
health_data['database'] = 'connected'
|
||||||
except Exception as e:
|
except Exception:
|
||||||
|
# Never echo the driver error: it routinely carries the host,
|
||||||
|
# database name and user of the connection string, and /health
|
||||||
|
# is unauthenticated.
|
||||||
|
app.logger.error('Health check: database unreachable', exc_info=True)
|
||||||
health_data['status'] = 'unhealthy'
|
health_data['status'] = 'unhealthy'
|
||||||
health_data['database'] = f'error: {str(e)}'
|
health_data['database'] = 'error'
|
||||||
return jsonify(health_data), 503
|
return jsonify(health_data), 503
|
||||||
|
|
||||||
return jsonify(health_data), 200
|
return jsonify(health_data), 200
|
||||||
|
|
||||||
|
@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
|
# Custom Error Handlers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
#
|
||||||
|
# STD-09. The seven handlers below each carried their own copy of a list
|
||||||
|
# of URL prefixes, and the copies had drifted: three of them checked
|
||||||
|
# `/users/coach-availability`, four did not. Worse, every copy was
|
||||||
|
# missing the same five endpoints — `/matches/api/…` and
|
||||||
|
# `/team-matches/api/…` — so an error on any of those answered a
|
||||||
|
# `fetch()` with an HTML error page. The browser then failed to parse it
|
||||||
|
# as JSON and the page simply did nothing: on the calendar, the tryout
|
||||||
|
# and team selects stayed empty with no message anywhere. A session that
|
||||||
|
# expired mid-page produced exactly that, because the 401 handler
|
||||||
|
# redirects to an HTML login form.
|
||||||
|
#
|
||||||
|
# The mechanism now lives in wants_json_response() / app/api.py, and a
|
||||||
|
# test walks the URL map to prove no jsonify-returning view is missed.
|
||||||
|
#
|
||||||
|
# The handler below is the one that actually mattered. `@login_required`
|
||||||
|
# never reaches the 401 handler: Flask-Login intercepts first and calls
|
||||||
|
# its own unauthorized callback, which redirects. So every one of the
|
||||||
|
# sixteen JSON endpoints answered an expired session with a 302 to an
|
||||||
|
# HTML login form, whatever the prefix list said — and the page's
|
||||||
|
# `fetch()` threw parsing it. Rewriting the prefix list alone would have
|
||||||
|
# left this untouched and looked like a fix.
|
||||||
|
@login_manager.unauthorized_handler
|
||||||
|
def handle_unauthorized():
|
||||||
|
"""What an unauthenticated request gets: a redirect, or a 401 in JSON."""
|
||||||
|
if wants_json_response():
|
||||||
|
return jsonify({'error': 'Unauthorized', 'message': 'Your session has expired.'}), 401
|
||||||
|
flash(_('Please log in to access this page.'), 'warning')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
def bad_request(error):
|
def bad_request(error):
|
||||||
"""Handle 400 Bad Request errors.
|
"""Handle 400 Bad Request errors.
|
||||||
@@ -226,28 +624,21 @@ def create_app():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Rendered error page or JSON for API requests.
|
Response: Rendered error page or JSON for API requests.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
if wants_json_response():
|
||||||
request.path.startswith('/users/coach-availability') or \
|
|
||||||
request.path.startswith('/users/api/'):
|
|
||||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||||
return render_template('errors/400.html', error=error), 400
|
return render_template('errors/400.html', error=error), 400
|
||||||
|
|
||||||
@app.errorhandler(401)
|
@app.errorhandler(401)
|
||||||
def unauthorized(error):
|
def unauthorized(error):
|
||||||
"""Handle 401 Unauthorized errors.
|
"""Handle an explicit abort(401).
|
||||||
|
|
||||||
Args:
|
Rarely reached: `@login_required` is intercepted by Flask-Login
|
||||||
error: The error object.
|
before Flask's error handling, and answered by handle_unauthorized
|
||||||
|
above. This covers code that aborts with 401 itself, and gives the
|
||||||
Returns:
|
same answer — the two used to differ, and the flash message here was
|
||||||
Response: Redirect to login for pages, JSON for API.
|
the one string in the application that had never been translated.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
return handle_unauthorized()
|
||||||
request.path.startswith('/users/api/'):
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
from flask import flash as _flash
|
|
||||||
_flash('Please log in to access this page.', 'warning')
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
|
|
||||||
@app.errorhandler(403)
|
@app.errorhandler(403)
|
||||||
def forbidden(error):
|
def forbidden(error):
|
||||||
@@ -259,8 +650,7 @@ def create_app():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Rendered error page or JSON for API requests.
|
Response: Rendered error page or JSON for API requests.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
if wants_json_response():
|
||||||
request.path.startswith('/users/api/'):
|
|
||||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||||
return render_template('errors/403.html', error=error), 403
|
return render_template('errors/403.html', error=error), 403
|
||||||
|
|
||||||
@@ -274,8 +664,7 @@ def create_app():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Rendered error page or JSON for API requests.
|
Response: Rendered error page or JSON for API requests.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
if wants_json_response():
|
||||||
request.path.startswith('/users/api/'):
|
|
||||||
return jsonify({'error': 'Not found'}), 404
|
return jsonify({'error': 'Not found'}), 404
|
||||||
return render_template('errors/404.html', error=error), 404
|
return render_template('errors/404.html', error=error), 404
|
||||||
|
|
||||||
@@ -289,12 +678,10 @@ def create_app():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: JSON error for API or rendered page.
|
Response: JSON error for API or rendered page.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
if wants_json_response():
|
||||||
request.path.startswith('/users/api/'):
|
return jsonify(
|
||||||
return jsonify({
|
{'error': 'Too many requests', 'message': 'Please try again later.'}
|
||||||
'error': 'Too many requests',
|
), 429
|
||||||
'message': 'Please try again later.'
|
|
||||||
}), 429
|
|
||||||
return render_template('errors/429.html', error=error), 429
|
return render_template('errors/429.html', error=error), 429
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
@@ -315,13 +702,21 @@ def create_app():
|
|||||||
# Roll back any failed database session
|
# Roll back any failed database session
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
# The id is the only thing that connects a user saying "it broke when
|
||||||
request.path.startswith('/users/api/'):
|
# I clicked save" to the stack trace in errors.log. It identifies one
|
||||||
return jsonify({
|
# request and nothing else — no session, no account, nothing an
|
||||||
|
# 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',
|
'error': 'Internal server error',
|
||||||
'message': 'An unexpected error occurred. Please try again later.'
|
'message': 'An unexpected error occurred. Please try again later.',
|
||||||
}), 500
|
'request_id': request_id,
|
||||||
return render_template('errors/500.html'), 500
|
}
|
||||||
|
), 500
|
||||||
|
return render_template('errors/500.html', request_id=request_id), 500
|
||||||
|
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def handle_http_exception(error):
|
def handle_http_exception(error):
|
||||||
@@ -333,13 +728,10 @@ def create_app():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: JSON error for API, re-raises for others.
|
Response: JSON error for API, re-raises for others.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or \
|
if wants_json_response():
|
||||||
request.path.startswith('/users/api/'):
|
return jsonify(
|
||||||
return jsonify({
|
{'error': error.name, 'message': error.description, 'code': error.code}
|
||||||
'error': error.name,
|
), error.code
|
||||||
'message': error.description,
|
|
||||||
'code': error.code
|
|
||||||
}), error.code
|
|
||||||
return error
|
return error
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -347,25 +739,40 @@ def create_app():
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
||||||
|
|
||||||
|
# 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()
|
db.create_all()
|
||||||
|
|
||||||
# Start the Discord bot for notifications
|
# Start the Discord bot for notifications
|
||||||
|
if app.config['ENABLE_DISCORD_BOT']:
|
||||||
try:
|
try:
|
||||||
from app.discord_bot import start_bot
|
from app.discord_bot import start_bot
|
||||||
|
|
||||||
start_bot(flask_app=app)
|
start_bot(flask_app=app)
|
||||||
except Exception as e:
|
except Exception: # the site must come up even if the bot cannot
|
||||||
app.logger.warning('Could not start Discord bot: %s', e)
|
# With the message alone, the two ways this fails — a bad token
|
||||||
|
# and a broken import in discord_bot — read identically, and
|
||||||
|
# neither is diagnosable from one line. Notifications are down
|
||||||
|
# either way, so the traceback is the whole value of the log.
|
||||||
|
# Error, not warning: a club that receives no reminders has lost
|
||||||
|
# a feature, and the old level put that next to the deprecation
|
||||||
|
# notices.
|
||||||
|
app.logger.error(
|
||||||
|
'Could not start the Discord bot. The site is up; no notification '
|
||||||
|
'will be sent until this is fixed.',
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
# No __main__ block here on purpose. There used to be one, and with run.py
|
||||||
# Only used for development - production uses wsgi.py (Waitress)
|
# and wsgi.py that made three ways to start the application, each with its
|
||||||
app = create_app()
|
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
|
||||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
|
||||||
if debug_mode:
|
# module defines the factory; run.py starts it for development, wsgi.py for
|
||||||
app.logger.warning(
|
# production (ARCH-007).
|
||||||
'Running in DEBUG mode with Flask built-in server. '
|
|
||||||
'This is NOT suitable for production. Use wsgi.py instead.'
|
|
||||||
)
|
|
||||||
app.run(debug=debug_mode, host='0.0.0.0', port=10000)
|
|
||||||
|
|||||||
+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_babel import Babel
|
||||||
from flask_login import LoginManager
|
|
||||||
from flask_wtf.csrf import CSRFProtect
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
# Database and extension initialization
|
# Database and extension initialization
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
@@ -12,11 +13,16 @@ login_manager.login_view = 'auth.login'
|
|||||||
login_manager.login_message_category = 'info'
|
login_manager.login_message_category = 'info'
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
# Rate limiter for brute-force protection
|
# Internationalisation. French is the primary language of the site; English
|
||||||
limiter = Limiter(
|
# stays available. See app/i18n.py for how a locale is chosen.
|
||||||
key_func=get_remote_address,
|
babel = Babel()
|
||||||
default_limits=["200 per day", "50 per hour"]
|
|
||||||
)
|
# Rate limiter for brute-force protection.
|
||||||
|
#
|
||||||
|
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
|
||||||
|
# app.config, which create_app fills from the environment and defaults to
|
||||||
|
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
|
||||||
|
limiter = Limiter(key_func=get_remote_address, default_limits=["1000 per day", "200 per hour"])
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password):
|
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 logging
|
||||||
import os
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
import re
|
import re
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
from app.storage import logs_root
|
||||||
|
|
||||||
|
#: Value used when a record is emitted outside a request — startup, the
|
||||||
|
#: Discord bot thread, the scheduler. Short and obviously not an id, so a
|
||||||
|
#: grep for one never matches it by accident.
|
||||||
|
NO_REQUEST = '-'
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdFilter(logging.Filter):
|
||||||
|
"""Stamp every record with the id of the request that produced it.
|
||||||
|
|
||||||
|
Without this, a 500 in errors.log and the six lines in app.log that led
|
||||||
|
to it are related only by their timestamps, which is not a relation when
|
||||||
|
the server is handling more than one request at a time (OBS-005).
|
||||||
|
|
||||||
|
The id is generated per request and never read from an inbound header.
|
||||||
|
Accepting one would be convenient for tracing across nginx, and it would
|
||||||
|
also let any caller write arbitrary text — newlines included — into the
|
||||||
|
log file, which is how a log gets forged rather than read. There is no
|
||||||
|
trusted proxy to take it from while OPS-002 is open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
record.request_id = NO_REQUEST
|
||||||
|
try:
|
||||||
|
from flask import g, has_request_context
|
||||||
|
|
||||||
|
if has_request_context():
|
||||||
|
record.request_id = g.get('request_id', NO_REQUEST)
|
||||||
|
except Exception: # noqa: BLE001 — logging must never be the thing that fails
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class SensitiveDataFilter(logging.Filter):
|
class SensitiveDataFilter(logging.Filter):
|
||||||
@@ -24,14 +57,30 @@ class SensitiveDataFilter(logging.Filter):
|
|||||||
|
|
||||||
# Patterns to redact
|
# Patterns to redact
|
||||||
SENSITIVE_PATTERNS = [
|
SENSITIVE_PATTERNS = [
|
||||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'),
|
(
|
||||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'),
|
re.compile(
|
||||||
|
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
|
||||||
|
),
|
||||||
|
'[REDACTED]',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
|
||||||
|
),
|
||||||
|
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
|
||||||
|
),
|
||||||
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
||||||
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
||||||
]
|
]
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
"""Apply redaction to the log record's message.
|
"""Apply redaction to the log record's fully rendered message.
|
||||||
|
|
||||||
|
The record is rendered first (msg % args) and the result stored back
|
||||||
|
as msg with args cleared. Redacting record.msg alone would miss almost
|
||||||
|
everything: this codebase logs with %s placeholders, so the sensitive
|
||||||
|
value lives in record.args while record.msg holds only the format
|
||||||
|
string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
record: The log record to filter.
|
record: The log record to filter.
|
||||||
@@ -39,14 +88,21 @@ class SensitiveDataFilter(logging.Filter):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: Always True (never drops records, only redacts).
|
bool: Always True (never drops records, only redacts).
|
||||||
"""
|
"""
|
||||||
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
try:
|
||||||
msg = record.msg
|
rendered = record.getMessage()
|
||||||
|
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:
|
for pattern, replacement in self.SENSITIVE_PATTERNS:
|
||||||
if callable(replacement):
|
rendered = pattern.sub(replacement, rendered)
|
||||||
msg = pattern.sub(replacement, msg)
|
|
||||||
else:
|
record.msg = rendered
|
||||||
msg = pattern.sub(replacement, msg)
|
record.args = ()
|
||||||
record.msg = msg
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +119,12 @@ def configure_logging(app):
|
|||||||
Args:
|
Args:
|
||||||
app: The Flask application instance to configure logging for.
|
app: The Flask application instance to configure logging for.
|
||||||
"""
|
"""
|
||||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
# Anchored on the project, not on the working directory (OBS-006). The
|
||||||
|
# old form put the logs wherever the process happened to be started
|
||||||
|
# from, so a service restarted by hand from another directory quietly
|
||||||
|
# began writing somewhere else — and the file you go looking at when
|
||||||
|
# something is wrong is the one that must not move.
|
||||||
|
log_dir = logs_root()
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
|
||||||
# Remove default Flask handlers to avoid duplicate logging
|
# Remove default Flask handlers to avoid duplicate logging
|
||||||
@@ -76,11 +137,17 @@ def configure_logging(app):
|
|||||||
|
|
||||||
# Create the sensitive data filter
|
# Create the sensitive data filter
|
||||||
sensitive_filter = SensitiveDataFilter()
|
sensitive_filter = SensitiveDataFilter()
|
||||||
|
request_id_filter = RequestIdFilter()
|
||||||
|
|
||||||
# Formatter with timestamp, level, module, and message
|
# Formatter with timestamp, level, module, request id, and message.
|
||||||
|
#
|
||||||
|
# request_id comes from RequestIdFilter, which is attached to every
|
||||||
|
# handler below. A handler that formats with this string and does not
|
||||||
|
# carry the filter raises on its first record — so if one is ever added,
|
||||||
|
# add the filter with it.
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
|
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
datefmt='%Y-%m-%d %H:%M:%S',
|
||||||
)
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -89,11 +156,12 @@ def configure_logging(app):
|
|||||||
error_handler = RotatingFileHandler(
|
error_handler = RotatingFileHandler(
|
||||||
os.path.join(log_dir, 'errors.log'),
|
os.path.join(log_dir, 'errors.log'),
|
||||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||||
backupCount=10
|
backupCount=10,
|
||||||
)
|
)
|
||||||
error_handler.setLevel(logging.ERROR)
|
error_handler.setLevel(logging.ERROR)
|
||||||
error_handler.setFormatter(formatter)
|
error_handler.setFormatter(formatter)
|
||||||
error_handler.addFilter(sensitive_filter)
|
error_handler.addFilter(sensitive_filter)
|
||||||
|
error_handler.addFilter(request_id_filter)
|
||||||
app.logger.addHandler(error_handler)
|
app.logger.addHandler(error_handler)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -102,11 +170,12 @@ def configure_logging(app):
|
|||||||
auth_handler = RotatingFileHandler(
|
auth_handler = RotatingFileHandler(
|
||||||
os.path.join(log_dir, 'auth.log'),
|
os.path.join(log_dir, 'auth.log'),
|
||||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||||
backupCount=5
|
backupCount=5,
|
||||||
)
|
)
|
||||||
auth_handler.setLevel(logging.INFO)
|
auth_handler.setLevel(logging.INFO)
|
||||||
auth_handler.setFormatter(formatter)
|
auth_handler.setFormatter(formatter)
|
||||||
auth_handler.addFilter(sensitive_filter)
|
auth_handler.addFilter(sensitive_filter)
|
||||||
|
auth_handler.addFilter(request_id_filter)
|
||||||
|
|
||||||
# Create a named logger specifically for auth events
|
# Create a named logger specifically for auth events
|
||||||
auth_logger = logging.getLogger('team_tryouts.auth')
|
auth_logger = logging.getLogger('team_tryouts.auth')
|
||||||
@@ -120,22 +189,41 @@ def configure_logging(app):
|
|||||||
app_handler = RotatingFileHandler(
|
app_handler = RotatingFileHandler(
|
||||||
os.path.join(log_dir, 'app.log'),
|
os.path.join(log_dir, 'app.log'),
|
||||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||||
backupCount=10
|
backupCount=10,
|
||||||
)
|
)
|
||||||
app_handler.setLevel(log_level)
|
app_handler.setLevel(log_level)
|
||||||
app_handler.setFormatter(formatter)
|
app_handler.setFormatter(formatter)
|
||||||
app_handler.addFilter(sensitive_filter)
|
app_handler.addFilter(sensitive_filter)
|
||||||
|
app_handler.addFilter(request_id_filter)
|
||||||
app.logger.addHandler(app_handler)
|
app.logger.addHandler(app_handler)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# 4. Console Handler (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 = logging.StreamHandler()
|
||||||
console_handler.setLevel(log_level)
|
console_handler.setLevel(logging.DEBUG if debug_mode else log_level)
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
console_handler.addFilter(sensitive_filter)
|
console_handler.addFilter(sensitive_filter)
|
||||||
|
console_handler.addFilter(request_id_filter)
|
||||||
app.logger.addHandler(console_handler)
|
app.logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# 5. Package logger ('app.*') — notably app.discord_bot
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Modules using logging.getLogger(__name__) resolve to 'app.<module>'.
|
||||||
|
# Without handlers here their INFO records were dropped entirely and
|
||||||
|
# WARNING+ fell through to Python's lastResort handler, unformatted.
|
||||||
|
package_logger = logging.getLogger('app')
|
||||||
|
package_logger.setLevel(log_level)
|
||||||
|
package_logger.propagate = False
|
||||||
|
for handler in (error_handler, app_handler, console_handler):
|
||||||
|
if handler not in package_logger.handlers:
|
||||||
|
package_logger.addHandler(handler)
|
||||||
|
|
||||||
# Log startup information
|
# Log startup information
|
||||||
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
||||||
app.logger.info('Application startup')
|
app.logger.info('Application startup')
|
||||||
@@ -151,3 +239,35 @@ def get_auth_logger():
|
|||||||
logging.Logger: Logger for authentication events.
|
logging.Logger: Logger for authentication events.
|
||||||
"""
|
"""
|
||||||
return logging.getLogger('team_tryouts.auth')
|
return logging.getLogger('team_tryouts.auth')
|
||||||
|
|
||||||
|
|
||||||
|
def log_auth_event(event, **fields):
|
||||||
|
"""Record a security-relevant event to auth.log.
|
||||||
|
|
||||||
|
The handler, its rotation and its redaction filter were configured from
|
||||||
|
the start, but get_auth_logger was never imported anywhere: auth.log was
|
||||||
|
created and stayed empty. No login, failure, lockout, role change or
|
||||||
|
account deletion left any trace.
|
||||||
|
|
||||||
|
Fields are emitted as `key=value` pairs, ordered, so the file stays
|
||||||
|
greppable without pulling in a JSON logging dependency.
|
||||||
|
|
||||||
|
Note on `ip`: it is taken from request.remote_addr, which reflects
|
||||||
|
X-Forwarded-For. As long as Waitress runs with trusted_proxy='*'
|
||||||
|
(SEC-WEB-002), that value is attacker-controlled and must be read as an
|
||||||
|
indication rather than as evidence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Dotted event name, e.g. 'login.success'.
|
||||||
|
**fields: Additional context. Never pass a secret: values are
|
||||||
|
recorded verbatim apart from the redaction filter's patterns.
|
||||||
|
"""
|
||||||
|
from flask import has_request_context, request
|
||||||
|
|
||||||
|
parts = [f'event={event}']
|
||||||
|
if has_request_context():
|
||||||
|
parts.append(f'ip={request.remote_addr}')
|
||||||
|
parts.append(f'path={request.path}')
|
||||||
|
parts.extend(f'{key}={value}' for key, value in fields.items())
|
||||||
|
|
||||||
|
get_auth_logger().info(' '.join(parts))
|
||||||
|
|||||||
@@ -94,3 +94,6 @@ from app.models.contract import Contract
|
|||||||
from app.models.team_note import TeamNote
|
from app.models.team_note import TeamNote
|
||||||
from app.models.personal_note import PersonalNote
|
from app.models.personal_note import PersonalNote
|
||||||
from app.models.one_on_one_request import OneOnOneRequest
|
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
|
from app.extensions import db
|
||||||
|
|
||||||
|
org_team_coaches = db.Table(
|
||||||
org_team_coaches = db.Table('org_team_coaches',
|
'org_team_coaches',
|
||||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
db.Column(
|
||||||
primary_key=True),
|
'org_team_id',
|
||||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
db.Integer,
|
||||||
primary_key=True),
|
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
db.Column(
|
||||||
|
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
org_team_managers = db.Table('org_team_managers',
|
org_team_managers = db.Table(
|
||||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
'org_team_managers',
|
||||||
primary_key=True),
|
db.Column(
|
||||||
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
'org_team_id',
|
||||||
primary_key=True),
|
db.Integer,
|
||||||
|
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
db.Column(
|
||||||
|
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
tryout_coaches = db.Table('tryout_coaches',
|
tryout_coaches = db.Table(
|
||||||
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'),
|
'tryout_coaches',
|
||||||
primary_key=True),
|
db.Column(
|
||||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||||
primary_key=True),
|
),
|
||||||
|
db.Column(
|
||||||
|
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
tryout_managers = db.Table(
|
||||||
|
'tryout_managers',
|
||||||
|
db.Column(
|
||||||
|
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
|
db.Column(
|
||||||
|
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,17 +67,13 @@ PLATFORM_CODES = {
|
|||||||
'Epic': 'epic',
|
'Epic': 'epic',
|
||||||
}
|
}
|
||||||
|
|
||||||
PLATFORM_DEFAULTS = {
|
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
|
||||||
'Apex Legends': 'pc',
|
|
||||||
'Rainbow Six Siege': 'ubi',
|
|
||||||
'Rocket League': 'epic'
|
|
||||||
}
|
|
||||||
|
|
||||||
TRN_URLS = {
|
TRN_URLS = {
|
||||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||||
'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}',
|
'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}',
|
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
||||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{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}',
|
'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.)
|
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
|
||||||
automatically because SQLAlchemy resolves the identity column.
|
automatically because SQLAlchemy resolves the identity column.
|
||||||
|
|
||||||
|
Returns None for deactivated accounts so that disabling a user also
|
||||||
|
invalidates the sessions they already hold. Flask-Login only consults
|
||||||
|
is_active when login_user() is called, never when restoring a session
|
||||||
|
from the cookie, so the check has to happen here.
|
||||||
"""
|
"""
|
||||||
|
from app.extensions import db
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
return User.query.get(int(user_id))
|
|
||||||
|
try:
|
||||||
|
pk = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = db.session.get(User, pk)
|
||||||
|
if user is None or not user.is_active_account:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|||||||
@@ -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."""
|
"""Availability models — BaseAvailability and its concrete subclasses."""
|
||||||
|
|
||||||
from app.models.availability.base import BaseAvailability
|
from app.models.availability.base import BaseAvailability
|
||||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
|
||||||
from app.models.availability.coach_availability import CoachAvailability
|
from app.models.availability.coach_availability import CoachAvailability
|
||||||
|
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||||
|
|
||||||
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BaseAvailability(db.Model):
|
class BaseAvailability(db.Model):
|
||||||
"""Shared schema for player disponibilities and coach availabilities."""
|
"""Shared schema for player disponibilities and coach availabilities."""
|
||||||
|
|
||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
day_of_week = db.Column(db.Integer, nullable=False)
|
day_of_week = db.Column(db.Integer, nullable=False)
|
||||||
start_time = db.Column(db.Time, nullable=False)
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
end_time = db.Column(db.Time, nullable=False)
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Coach availability in 30-minute time blocks for One on One sessions."""
|
"""Coach availability in 30-minute time blocks for One on One sessions."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.availability.base import BaseAvailability
|
from app.models.availability.base import BaseAvailability
|
||||||
|
|
||||||
|
|
||||||
class CoachAvailability(BaseAvailability):
|
class CoachAvailability(BaseAvailability):
|
||||||
"""Coach availability in 30-minute blocks for One on One sessions."""
|
"""Coach availability in 30-minute blocks for One on One sessions."""
|
||||||
|
|
||||||
__tablename__ = 'coach_availabilities'
|
__tablename__ = 'coach_availabilities'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Player availability in 30-minute time blocks."""
|
"""Player availability in 30-minute time blocks."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.availability.base import BaseAvailability
|
from app.models.availability.base import BaseAvailability
|
||||||
|
|
||||||
|
|
||||||
class PlayerDisponibility(BaseAvailability):
|
class PlayerDisponibility(BaseAvailability):
|
||||||
"""Player availability in 30-minute blocks."""
|
"""Player availability in 30-minute blocks."""
|
||||||
|
|
||||||
__tablename__ = 'player_disponibilities'
|
__tablename__ = 'player_disponibilities'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
|||||||
@@ -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."""
|
"""Contract documents for players to sign."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Contract(db.Model):
|
class Contract(db.Model):
|
||||||
"""Contract documents for players to sign."""
|
"""Contract documents for players to sign."""
|
||||||
|
|
||||||
__tablename__ = 'contracts'
|
__tablename__ = 'contracts'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
@@ -20,7 +22,7 @@ class Contract(db.Model):
|
|||||||
|
|
||||||
status = db.Column(db.String(20), default='pending')
|
status = db.Column(db.String(20), default='pending')
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
signed_at = db.Column(db.DateTime, nullable=True)
|
signed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
||||||
@@ -28,23 +30,37 @@ class Contract(db.Model):
|
|||||||
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
|
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
|
||||||
|
|
||||||
def can_view(self, user):
|
def can_view(self, user):
|
||||||
|
"""Whether this user may read the contract and download its files.
|
||||||
|
|
||||||
|
Two defects used to sit in the coach branch:
|
||||||
|
|
||||||
|
- `not self.team_id` acted as a wildcard, so any coach listed in the
|
||||||
|
legacy OrgTeam.coach_id column could read every contract with no
|
||||||
|
team attached — and upload_contract leaves team_id null whenever
|
||||||
|
the player belongs to no team.
|
||||||
|
- the lookup went through OrgTeam.coach_id only, so a coach attached
|
||||||
|
through the many-to-many relationship saw nothing at all.
|
||||||
|
|
||||||
|
Access now follows the same rule as everywhere else: the coach and
|
||||||
|
the player must actually work together.
|
||||||
|
"""
|
||||||
if user.id == self.player_id:
|
if user.id == self.player_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
from app.models.user_model.admin import Admin
|
from app.models.user_model.admin import Admin
|
||||||
from app.models.user_model.manager import Manager
|
|
||||||
from app.models.user_model.coach import Coach
|
from app.models.user_model.coach import Coach
|
||||||
|
from app.models.user_model.manager import Manager
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
from app.models.org_team.org_team import OrgTeam
|
from app.permissions import coach_can_access_player
|
||||||
|
|
||||||
if isinstance(user, Admin):
|
if isinstance(user, Admin):
|
||||||
return True
|
return True
|
||||||
if isinstance(user, Manager):
|
if isinstance(user, Manager):
|
||||||
player = User.query.get(self.player_id)
|
player = db.session.get(User, self.player_id)
|
||||||
if player and player.get_org_teams():
|
if player and player.get_org_teams():
|
||||||
return True
|
return True
|
||||||
if isinstance(user, Coach):
|
if isinstance(user, Coach):
|
||||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
return coach_can_access_player(user, self.player_id)
|
||||||
if org_team and (not self.team_id or self.team_id == org_team.id):
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_upload_signed(self, user):
|
def can_upload_signed(self, user):
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Player evaluation record."""
|
"""Player evaluation record."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Evaluation(db.Model):
|
class Evaluation(db.Model):
|
||||||
"""Player evaluation record."""
|
"""Player evaluation record."""
|
||||||
|
|
||||||
__tablename__ = 'evaluations'
|
__tablename__ = 'evaluations'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
@@ -22,9 +24,55 @@ class Evaluation(db.Model):
|
|||||||
overall_score = db.Column(db.Float, nullable=True)
|
overall_score = db.Column(db.Float, nullable=True)
|
||||||
comments = db.Column(db.Text, nullable=True)
|
comments = db.Column(db.Text, nullable=True)
|
||||||
position_recommendation = db.Column(db.String(50), nullable=True)
|
position_recommendation = db.Column(db.String(50), nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: The nine criteria, in the order the form shows them. The overall score
|
||||||
|
#: is their mean; a criterion left blank is left out of the mean rather
|
||||||
|
#: than counted as a zero, which is why this list exists rather than the
|
||||||
|
#: route summing nine named variables (ARCH-005, QUA-003).
|
||||||
|
CRITERIA = (
|
||||||
|
'mecanics_score',
|
||||||
|
'cohesion_score',
|
||||||
|
'communication_score',
|
||||||
|
'gamesense_score',
|
||||||
|
'versatility_score',
|
||||||
|
'discipline_score',
|
||||||
|
'analysis_score',
|
||||||
|
'sport_ethics_score',
|
||||||
|
'mental_score',
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def overall_from(cls, scores):
|
||||||
|
"""Mean of the criteria that were actually filled in.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scores: Mapping of criterion name to score or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float | None: None when nothing was scored — which is not the
|
||||||
|
same as zero, and must not become one. A player nobody could
|
||||||
|
assess has no overall score; a player who scored zero on
|
||||||
|
everything cannot exist, the scale starts at one.
|
||||||
|
"""
|
||||||
|
given = [scores.get(name) for name in cls.CRITERIA]
|
||||||
|
given = [score for score in given if score is not None]
|
||||||
|
if not given:
|
||||||
|
return None
|
||||||
|
return sum(given) / len(given)
|
||||||
|
|
||||||
|
def apply_scores(self, scores):
|
||||||
|
"""Write these criteria onto the record and recompute the overall.
|
||||||
|
|
||||||
|
Every criterion is assigned, including the ones left blank: an edit
|
||||||
|
that clears a score has to clear it, and the mean has to be the mean
|
||||||
|
of what is on the record afterwards.
|
||||||
|
"""
|
||||||
|
for name in self.CRITERIA:
|
||||||
|
setattr(self, name, scores.get(name))
|
||||||
|
self.overall_score = self.overall_from(scores)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Match models — BaseMatch and its concrete subclasses."""
|
"""Match models — BaseMatch and its concrete subclasses."""
|
||||||
|
|
||||||
from app.models.match_model.base import BaseMatch
|
from app.models.match_model.base import BaseMatch
|
||||||
from app.models.match_model.match import Match
|
from app.models.match_model.match import Match
|
||||||
from app.models.match_model.team_match import TeamMatch
|
from app.models.match_model.team_match import TeamMatch
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BaseMatch(db.Model):
|
class BaseMatch(db.Model):
|
||||||
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
||||||
|
|
||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
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)
|
start_time = db.Column(db.Time, nullable=True)
|
||||||
end_time = db.Column(db.Time, nullable=True)
|
end_time = db.Column(db.Time, nullable=True)
|
||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
status = db.Column(db.String(20), default='scheduled')
|
status = db.Column(db.String(20), default='scheduled')
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Match / scrimmage within a tryout."""
|
"""Match / scrimmage within a tryout."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.match_model.base import BaseMatch
|
from app.models.match_model.base import BaseMatch
|
||||||
|
|
||||||
|
|
||||||
class Match(BaseMatch):
|
class Match(BaseMatch):
|
||||||
"""Match / scrimmage within a tryout."""
|
"""Match / scrimmage within a tryout."""
|
||||||
|
|
||||||
__tablename__ = 'matches'
|
__tablename__ = 'matches'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
@@ -16,7 +18,13 @@ class Match(BaseMatch):
|
|||||||
tryout = db.relationship('Tryout', backref='matches')
|
tryout = db.relationship('Tryout', backref='matches')
|
||||||
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
|
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
|
||||||
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
|
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
|
||||||
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
|
# delete-orphan: without it, SQLAlchemy tries to detach participants by
|
||||||
|
# setting match_id to NULL, which the NOT NULL column refuses — so
|
||||||
|
# deleting any match that had participants raised IntegrityError.
|
||||||
|
# TeamMatch.participants already declared this; Match did not.
|
||||||
|
participants = db.relationship(
|
||||||
|
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
|
||||||
|
)
|
||||||
|
|
||||||
def get_participating_players(self):
|
def get_participating_players(self):
|
||||||
return [p.player_id for p in self.participants.all()]
|
return [p.player_id for p in self.participants.all()]
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.match_model.base import BaseMatch
|
from app.models.match_model.base import BaseMatch
|
||||||
|
|
||||||
|
|
||||||
class TeamMatch(BaseMatch):
|
class TeamMatch(BaseMatch):
|
||||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||||
|
|
||||||
__tablename__ = 'team_matches'
|
__tablename__ = 'team_matches'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
@@ -13,8 +15,8 @@ class TeamMatch(BaseMatch):
|
|||||||
org_team = db.relationship('OrgTeam', backref='team_matches')
|
org_team = db.relationship('OrgTeam', backref='team_matches')
|
||||||
creator = db.relationship('User', backref='created_team_matches')
|
creator = db.relationship('User', backref='created_team_matches')
|
||||||
participants = db.relationship(
|
participants = db.relationship(
|
||||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic',
|
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
|
||||||
cascade='all, delete-orphan')
|
)
|
||||||
|
|
||||||
def get_confirmed_count(self):
|
def get_confirmed_count(self):
|
||||||
all_p = self.participants.all()
|
all_p = self.participants.all()
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
"""Request from player to coach for a One on One session."""
|
"""Request from player to coach for a One on One session."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class OneOnOneRequest(db.Model):
|
class OneOnOneRequest(db.Model):
|
||||||
"""Request from player to coach for a One on One session."""
|
"""Request from player to coach for a One on One session."""
|
||||||
|
|
||||||
__tablename__ = 'one_on_one_requests'
|
__tablename__ = 'one_on_one_requests'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
coach_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)
|
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)
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
end_time = db.Column(db.Time, nullable=False)
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
points = db.Column(db.Text, nullable=True)
|
points = db.Column(db.Text, nullable=True)
|
||||||
status = db.Column(db.String(20), default='pending')
|
status = db.Column(db.String(20), default='pending')
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
responded_at = db.Column(db.DateTime, nullable=True)
|
responded_at = db.Column(db.DateTime, nullable=True)
|
||||||
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
||||||
coach_rejection_message = db.Column(db.Text, nullable=True)
|
coach_rejection_message = db.Column(db.Text, nullable=True)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Organisation team models."""
|
"""Organisation team models."""
|
||||||
|
|
||||||
from app.models.org_team.org_team import OrgTeam
|
from app.models.org_team.org_team import OrgTeam
|
||||||
from app.models.org_team.team_player import TeamPlayer
|
from app.models.org_team.team_player import TeamPlayer
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models._associations import org_team_coaches, org_team_managers
|
from app.models._associations import org_team_coaches, org_team_managers
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class OrgTeam(db.Model):
|
class OrgTeam(db.Model):
|
||||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||||
|
|
||||||
__tablename__ = 'org_teams'
|
__tablename__ = 'org_teams'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
|
||||||
creator = db.relationship('User', foreign_keys=[created_by])
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
coaches = db.relationship(
|
coaches = db.relationship(
|
||||||
'User', secondary=org_team_coaches, lazy='dynamic',
|
'User',
|
||||||
backref=db.backref('coached_org_teams', lazy='dynamic'))
|
secondary=org_team_coaches,
|
||||||
|
lazy='dynamic',
|
||||||
|
backref=db.backref('coached_org_teams', lazy='dynamic'),
|
||||||
|
)
|
||||||
managers = db.relationship(
|
managers = db.relationship(
|
||||||
'User', secondary=org_team_managers, lazy='dynamic',
|
'User',
|
||||||
backref=db.backref('managed_org_teams', lazy='dynamic'))
|
secondary=org_team_managers,
|
||||||
|
lazy='dynamic',
|
||||||
|
backref=db.backref('managed_org_teams', lazy='dynamic'),
|
||||||
|
)
|
||||||
|
|
||||||
coach = db.relationship(
|
coach = db.relationship(
|
||||||
'User', foreign_keys=[coach_id],
|
'User',
|
||||||
|
foreign_keys=[coach_id],
|
||||||
backref=db.backref('coached_org_team_legacy', uselist=False),
|
backref=db.backref('coached_org_team_legacy', uselist=False),
|
||||||
viewonly=True)
|
viewonly=True,
|
||||||
|
)
|
||||||
manager = db.relationship(
|
manager = db.relationship(
|
||||||
'User', foreign_keys=[manager_id],
|
'User',
|
||||||
|
foreign_keys=[manager_id],
|
||||||
backref=db.backref('managed_org_team_legacy', uselist=False),
|
backref=db.backref('managed_org_team_legacy', uselist=False),
|
||||||
viewonly=True)
|
viewonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
def get_coaches(self):
|
def get_coaches(self):
|
||||||
coach_list = self.coaches.all()
|
coach_list = self.coaches.all()
|
||||||
@@ -49,5 +61,7 @@ class OrgTeam(db.Model):
|
|||||||
return [tp.player for tp in self.team_players]
|
return [tp.player for tp in self.team_players]
|
||||||
|
|
||||||
def get_players_with_status(self):
|
def get_players_with_status(self):
|
||||||
return [{'player': tp.player, 'status': tp.status,
|
return [
|
||||||
'position': tp.position} for tp in self.team_players]
|
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
||||||
|
for tp in self.team_players
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
"""Many-to-many junction: player to org-team."""
|
"""Many-to-many junction: player to org-team."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TeamPlayer(db.Model):
|
class TeamPlayer(db.Model):
|
||||||
"""Many-to-many: player to org-team."""
|
"""Many-to-many: player to org-team."""
|
||||||
|
|
||||||
__tablename__ = 'team_players'
|
__tablename__ = 'team_players'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||||
position = db.Column(db.String(50), nullable=True)
|
position = db.Column(db.String(50), nullable=True)
|
||||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
||||||
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Participant models — BaseParticipant and its concrete subclasses."""
|
"""Participant models — BaseParticipant and its concrete subclasses."""
|
||||||
|
|
||||||
from app.models.participant.base import BaseParticipant
|
from app.models.participant.base import BaseParticipant
|
||||||
from app.models.participant.match_participant import MatchParticipant
|
from app.models.participant.match_participant import MatchParticipant
|
||||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Abstract base class for match participant models."""
|
"""Abstract base class for match participant models."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BaseParticipant(db.Model):
|
class BaseParticipant(db.Model):
|
||||||
"""Shared schema for match participants."""
|
"""Shared schema for match participants."""
|
||||||
|
|
||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Participant in a tryout-scoped match."""
|
"""Participant in a tryout-scoped match."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.participant.base import BaseParticipant
|
from app.models.participant.base import BaseParticipant
|
||||||
|
|
||||||
|
|
||||||
class MatchParticipant(BaseParticipant):
|
class MatchParticipant(BaseParticipant):
|
||||||
"""Participant in a tryout-scoped match."""
|
"""Participant in a tryout-scoped match."""
|
||||||
|
|
||||||
__tablename__ = 'match_participants'
|
__tablename__ = 'match_participants'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Participant in a regular-season team match."""
|
"""Participant in a regular-season team match."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.participant.base import BaseParticipant
|
from app.models.participant.base import BaseParticipant
|
||||||
|
|
||||||
|
|
||||||
class TeamMatchParticipant(BaseParticipant):
|
class TeamMatchParticipant(BaseParticipant):
|
||||||
"""Participant in a regular-season team match."""
|
"""Participant in a regular-season team match."""
|
||||||
|
|
||||||
__tablename__ = 'team_match_participants'
|
__tablename__ = 'team_match_participants'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
|
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
"""Personal notes from coach to individual player."""
|
"""Personal notes from coach to individual player."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class PersonalNote(db.Model):
|
class PersonalNote(db.Model):
|
||||||
"""Personal notes from coach to individual player."""
|
"""Personal notes from coach to individual player."""
|
||||||
|
|
||||||
__tablename__ = 'personal_notes'
|
__tablename__ = 'personal_notes'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|
||||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
||||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tryout-specific temporary team models."""
|
"""Tryout-specific temporary team models."""
|
||||||
|
|
||||||
from app.models.team.team import Team
|
from app.models.team.team import Team
|
||||||
from app.models.team.team_member import TeamMember
|
from app.models.team.team_member import TeamMember
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Team(db.Model):
|
class Team(db.Model):
|
||||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||||
|
|
||||||
__tablename__ = 'teams'
|
__tablename__ = 'teams'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
name = db.Column(db.String(100), nullable=False)
|
name = db.Column(db.String(100), nullable=False)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
creator = db.relationship('User', backref='created_teams')
|
creator = db.relationship('User', backref='created_teams')
|
||||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
"""Link between a player and a tryout-specific team."""
|
"""Link between a player and a tryout-specific team."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(db.Model):
|
class TeamMember(db.Model):
|
||||||
"""Link between a player and a tryout-specific team."""
|
"""Link between a player and a tryout-specific team."""
|
||||||
|
|
||||||
__tablename__ = 'team_members'
|
__tablename__ = 'team_members'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
position = db.Column(db.String(50), nullable=True)
|
position = db.Column(db.String(50), nullable=True)
|
||||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
"""Team improvement notes from coach."""
|
"""Team improvement notes from coach."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TeamNote(db.Model):
|
class TeamNote(db.Model):
|
||||||
"""Team improvement notes from coach."""
|
"""Team improvement notes from coach."""
|
||||||
|
|
||||||
__tablename__ = 'team_notes'
|
__tablename__ = 'team_notes'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|
||||||
team = db.relationship('OrgTeam', backref='team_notes')
|
team = db.relationship('OrgTeam', backref='team_notes')
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tryout models."""
|
"""Tryout models."""
|
||||||
|
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,68 @@
|
|||||||
"""Tryout event for player evaluations and team formation."""
|
"""Tryout event for player evaluations and team formation."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models._associations import tryout_coaches
|
from app.models._associations import tryout_coaches, tryout_managers
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Tryout(db.Model):
|
class Tryout(db.Model):
|
||||||
"""Tryout event for player evaluations and team formation."""
|
"""Tryout event for player evaluations and team formation."""
|
||||||
|
|
||||||
__tablename__ = 'tryouts'
|
__tablename__ = 'tryouts'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
game = db.Column(db.String(50), nullable=False)
|
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)
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
status = db.Column(db.String(20), default='upcoming')
|
status = db.Column(db.String(20), default='upcoming')
|
||||||
max_players = db.Column(db.Integer, nullable=True)
|
max_players = db.Column(db.Integer, nullable=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
manager_id = db.Column(
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
) # 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')
|
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
manager = db.relationship(
|
||||||
|
'User', foreign_keys=[manager_id], backref='managed_tryouts'
|
||||||
|
) # deprecated
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||||
|
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||||
target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
|
target_org_team = db.relationship(
|
||||||
|
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_ended(self):
|
def is_ended(self):
|
||||||
"""Tryout is considered ended after its end_date passes.
|
"""Tryout is considered ended after its end_date passes.
|
||||||
Falls back to date if end_date is not set."""
|
Falls back to date if end_date is not set."""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
today = date_type.today()
|
today = date_type.today()
|
||||||
if self.end_date is not None:
|
if self.end_date is not None:
|
||||||
return self.end_date < today
|
return self.end_date < today
|
||||||
return self.date < today
|
return self.date < today
|
||||||
|
|
||||||
|
def get_managers(self):
|
||||||
|
"""Managers attached to this tryout, both legacy and many-to-many."""
|
||||||
|
manager_list = list(self.managers)
|
||||||
|
if not manager_list and self.manager:
|
||||||
|
return [self.manager]
|
||||||
|
return manager_list
|
||||||
|
|
||||||
|
def get_coaches(self):
|
||||||
|
"""Coaches attached to this tryout, both legacy and many-to-many."""
|
||||||
|
coach_list = list(self.coaches)
|
||||||
|
if not coach_list and self.coach:
|
||||||
|
return [self.coach]
|
||||||
|
return coach_list
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""Registration linking a player to a tryout."""
|
"""Registration linking a player to a tryout."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TryoutRegistration(db.Model):
|
class TryoutRegistration(db.Model):
|
||||||
"""Registration linking a player to a tryout."""
|
"""Registration linking a player to a tryout."""
|
||||||
|
|
||||||
__tablename__ = 'tryout_registrations'
|
__tablename__ = 'tryout_registrations'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
registered_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
status = db.Column(db.String(20), default='registered')
|
status = db.Column(db.String(20), default='registered')
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Store gamertag per game for each user."""
|
"""Store gamertag per game for each user."""
|
||||||
from app.extensions import db
|
|
||||||
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models._constants import PLATFORM_CODES, PLATFORM_DEFAULTS, TRN_URLS
|
||||||
|
|
||||||
|
|
||||||
class UserGamertag(db.Model):
|
class UserGamertag(db.Model):
|
||||||
"""Store gamertag per game for each user."""
|
"""Store gamertag per game for each user."""
|
||||||
|
|
||||||
__tablename__ = 'user_gamertags'
|
__tablename__ = 'user_gamertags'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
@@ -15,9 +18,7 @@ class UserGamertag(db.Model):
|
|||||||
|
|
||||||
user = db.relationship('User', backref='gamertags')
|
user = db.relationship('User', backref='gamertags')
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
|
||||||
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_trn_url(self):
|
def get_trn_url(self):
|
||||||
if self.game not in TRN_URLS:
|
if self.game not in TRN_URLS:
|
||||||
@@ -34,11 +35,11 @@ class UserGamertag(db.Model):
|
|||||||
platform.lower().replace(' ', '-') if platform else '',
|
platform.lower().replace(' ', '-') if platform else '',
|
||||||
)
|
)
|
||||||
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
||||||
elif '{platform}' in url and '{username}' in url:
|
if '{platform}' in url and '{username}' in url:
|
||||||
return url.format(
|
return url.format(
|
||||||
platform=platform.lower().replace(' ', '-') if platform else '',
|
platform=platform.lower().replace(' ', '-') if platform else '',
|
||||||
username=encoded_gamertag,
|
username=encoded_gamertag,
|
||||||
)
|
)
|
||||||
elif '{username}' in url:
|
if '{username}' in url:
|
||||||
return url.format(username=encoded_gamertag)
|
return url.format(username=encoded_gamertag)
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
||||||
from app.models.user_model.user import User
|
|
||||||
from app.models.user_model.admin import Admin
|
from app.models.user_model.admin import Admin
|
||||||
from app.models.user_model.manager import Manager
|
|
||||||
from app.models.user_model.coach import Coach
|
from app.models.user_model.coach import Coach
|
||||||
|
from app.models.user_model.manager import Manager
|
||||||
from app.models.user_model.player import Player
|
from app.models.user_model.player import Player
|
||||||
from app.models.user_model.scout import Scout
|
from app.models.user_model.scout import Scout
|
||||||
|
from app.models.user_model.user import User
|
||||||
|
|
||||||
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
|
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Admin / President — full access to everything."""
|
"""Admin / President — full access to everything."""
|
||||||
|
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
|
|
||||||
|
|
||||||
class Admin(User):
|
class Admin(User):
|
||||||
"""President / super-admin — full access to everything."""
|
"""President / super-admin — full access to everything."""
|
||||||
|
|
||||||
__mapper_args__ = {'polymorphic_identity': 'admin'}
|
__mapper_args__ = {'polymorphic_identity': 'admin'}
|
||||||
|
|
||||||
def can_evaluate(self):
|
def can_evaluate(self):
|
||||||
@@ -29,4 +31,5 @@ class Admin(User):
|
|||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
return Tryout.query.order_by(Tryout.date).all()
|
return Tryout.query.order_by(Tryout.date).all()
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
"""Coach — evaluates, schedules matches, manages their own org teams."""
|
||||||
|
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
from app.extensions import db
|
|
||||||
|
|
||||||
|
|
||||||
class Coach(User):
|
class Coach(User):
|
||||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
"""Coach — evaluates, schedules matches, manages their own org teams.
|
||||||
|
|
||||||
|
Every question about *which* teams or tryouts belong to this coach is
|
||||||
|
delegated to ``app.permissions``. Two of the three methods below used to
|
||||||
|
answer it themselves, each considering a different subset of the two
|
||||||
|
ways a coach can be attached to a team: ``can_manage_this_tryout``
|
||||||
|
ignored the legacy ``coach_id`` column when checking the target team,
|
||||||
|
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
|
||||||
|
by that column therefore saw an empty calendar (ARCH-002).
|
||||||
|
"""
|
||||||
|
|
||||||
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||||
|
|
||||||
def can_evaluate(self):
|
def can_evaluate(self):
|
||||||
@@ -17,38 +27,16 @@ class Coach(User):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_manage_this_tryout(self, tryout):
|
def can_manage_this_tryout(self, tryout):
|
||||||
from app.models.org_team.org_team import OrgTeam
|
from app.permissions import coach_manages_tryout
|
||||||
if tryout.target_org_team_id:
|
|
||||||
is_coach_of_target = OrgTeam.query.filter(
|
return coach_manages_tryout(self, tryout)
|
||||||
OrgTeam.id == tryout.target_org_team_id,
|
|
||||||
OrgTeam.coaches.any(id=self.id),
|
|
||||||
).first() is not None
|
|
||||||
if is_coach_of_target:
|
|
||||||
return True
|
|
||||||
# Check many-to-many coaches relationship
|
|
||||||
if any(c.id == self.id for c in tryout.coaches):
|
|
||||||
return True
|
|
||||||
# Backward compat: check deprecated coach_id
|
|
||||||
if tryout.coach_id == self.id:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
if org_team.coaches.filter_by(id=self.id).first():
|
if org_team.coaches.filter_by(id=self.id).first():
|
||||||
return True
|
return True
|
||||||
if org_team.coach_id == self.id:
|
return org_team.coach_id == self.id
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.permissions import coach_tryouts
|
||||||
from app.models._associations import tryout_coaches
|
|
||||||
team_ids = [t.id for t in self.coached_org_teams.all()]
|
return coach_tryouts(self)
|
||||||
conditions = []
|
|
||||||
if team_ids:
|
|
||||||
conditions.append(Tryout.target_org_team_id.in_(team_ids))
|
|
||||||
# Check many-to-many coaches
|
|
||||||
conditions.append(Tryout.coaches.any(id=self.id))
|
|
||||||
# Backward compat: check deprecated coach_id
|
|
||||||
conditions.append(Tryout.coach_id == self.id)
|
|
||||||
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||||
|
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
|
|
||||||
|
|
||||||
class Manager(User):
|
class Manager(User):
|
||||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||||
|
|
||||||
__mapper_args__ = {'polymorphic_identity': 'manager'}
|
__mapper_args__ = {'polymorphic_identity': 'manager'}
|
||||||
|
|
||||||
def can_evaluate(self):
|
def can_evaluate(self):
|
||||||
@@ -19,14 +21,28 @@ class Manager(User):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_manage_this_tryout(self, tryout):
|
def can_manage_this_tryout(self, tryout):
|
||||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
return (
|
||||||
|
tryout.created_by == self.id
|
||||||
|
or tryout.manager_id == self.id
|
||||||
|
or any(m.id == self.id for m in tryout.managers)
|
||||||
|
)
|
||||||
|
|
||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
return Tryout.query.filter(
|
|
||||||
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)
|
from app.models.tryout.tryout import Tryout
|
||||||
).order_by(Tryout.date).all()
|
|
||||||
|
return (
|
||||||
|
Tryout.query.filter(
|
||||||
|
or_(
|
||||||
|
Tryout.created_by == self.id,
|
||||||
|
Tryout.manager_id == self.id,
|
||||||
|
Tryout.managers.any(id=self.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Tryout.date)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
"""Player — registers for tryouts, manages their own profile."""
|
"""Player — registers for tryouts, manages their own profile."""
|
||||||
|
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
|
|
||||||
|
|
||||||
class Player(User):
|
class Player(User):
|
||||||
"""Player — registers for tryouts, manages their own profile."""
|
"""Player — registers for tryouts, manages their own profile."""
|
||||||
|
|
||||||
__mapper_args__ = {'polymorphic_identity': 'player'}
|
__mapper_args__ = {'polymorphic_identity': 'player'}
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
|
||||||
from app.models.match_model.match import Match
|
from app.models.match_model.match import Match
|
||||||
from app.models.participant.match_participant import MatchParticipant
|
from app.models.participant.match_participant import MatchParticipant
|
||||||
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
# tryouts they registered for
|
# tryouts they registered for
|
||||||
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
||||||
tryouts = Tryout.query.filter(
|
tryouts = (
|
||||||
Tryout.id.in_(player_tryout_ids)
|
Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
|
||||||
).order_by(Tryout.date).all() if player_tryout_ids else []
|
if player_tryout_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
# plus tryouts where they participate in a match
|
# plus tryouts where they participate in a match
|
||||||
player_matches = Match.query.join(MatchParticipant).filter(
|
player_matches = (
|
||||||
|
Match.query.join(MatchParticipant)
|
||||||
|
.filter(
|
||||||
MatchParticipant.player_id == self.id,
|
MatchParticipant.player_id == self.id,
|
||||||
).all()
|
)
|
||||||
extra_ids = set(m.tryout_id for m in player_matches)
|
.all()
|
||||||
extra = Tryout.query.filter(
|
)
|
||||||
|
extra_ids = {m.tryout_id for m in player_matches}
|
||||||
|
extra = (
|
||||||
|
Tryout.query.filter(
|
||||||
Tryout.id.in_(extra_ids),
|
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}
|
all_ids = {t.id for t in tryouts}
|
||||||
return tryouts + [t for t in extra if t.id not in all_ids]
|
return tryouts + [t for t in extra if t.id not in all_ids]
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Scout — view-only access to tryouts and evaluations."""
|
"""Scout — view-only access to tryouts and evaluations."""
|
||||||
|
|
||||||
from app.models.user_model.user import User
|
from app.models.user_model.user import User
|
||||||
|
|
||||||
|
|
||||||
class Scout(User):
|
class Scout(User):
|
||||||
"""Scout — view-only access to tryouts and evaluations."""
|
"""Scout — view-only access to tryouts and evaluations."""
|
||||||
|
|
||||||
__mapper_args__ = {'polymorphic_identity': 'scout'}
|
__mapper_args__ = {'polymorphic_identity': 'scout'}
|
||||||
|
|
||||||
def can_evaluate(self):
|
def can_evaluate(self):
|
||||||
@@ -11,4 +13,5 @@ class Scout(User):
|
|||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
return Tryout.query.order_by(Tryout.date).all()
|
return Tryout.query.order_by(Tryout.date).all()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Base User model — shared fields and polymorphic configuration."""
|
"""Base User model — shared fields and polymorphic configuration."""
|
||||||
from app.extensions import db
|
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from datetime import datetime
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
@@ -10,6 +12,7 @@ class User(UserMixin, db.Model):
|
|||||||
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
||||||
or Scout so that `polymorphic_identity` is set correctly.
|
or Scout so that `polymorphic_identity` is set correctly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
|
|
||||||
# --- columns -----------------------------------------------------------
|
# --- columns -----------------------------------------------------------
|
||||||
@@ -21,7 +24,7 @@ class User(UserMixin, db.Model):
|
|||||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
phone = db.Column(db.String(20), nullable=True)
|
phone = db.Column(db.String(20), nullable=True)
|
||||||
is_active_account = db.Column(db.Boolean, default=True)
|
is_active_account = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||||
locked_until = db.Column(db.DateTime, nullable=True)
|
locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
@@ -40,16 +43,26 @@ class User(UserMixin, db.Model):
|
|||||||
|
|
||||||
# --- relationships (defined once on the base) --------------------------
|
# --- relationships (defined once on the base) --------------------------
|
||||||
evaluations_given = db.relationship(
|
evaluations_given = db.relationship(
|
||||||
'Evaluation', foreign_keys='Evaluation.evaluator_id',
|
'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
|
||||||
backref='evaluator', lazy='dynamic')
|
)
|
||||||
evaluations_received = db.relationship(
|
evaluations_received = db.relationship(
|
||||||
'Evaluation', foreign_keys='Evaluation.player_id',
|
'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
|
||||||
backref='player', lazy='dynamic')
|
)
|
||||||
tryout_registrations = db.relationship(
|
tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
|
||||||
'TryoutRegistration', backref='player', lazy='dynamic')
|
|
||||||
team_assignments = db.relationship(
|
team_assignments = db.relationship(
|
||||||
'TeamMember', foreign_keys='TeamMember.player_id',
|
'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
|
||||||
backref='player_ref', lazy='dynamic')
|
)
|
||||||
|
|
||||||
|
# --- Flask-Login integration -------------------------------------------
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Whether Flask-Login should accept this account.
|
||||||
|
|
||||||
|
UserMixin returns True unconditionally, which meant a deactivated
|
||||||
|
account kept any session it already held. Binding this to
|
||||||
|
is_active_account makes deactivation take effect on the next request.
|
||||||
|
"""
|
||||||
|
return bool(self.is_active_account)
|
||||||
|
|
||||||
# --- shared helper methods ---------------------------------------------
|
# --- shared helper methods ---------------------------------------------
|
||||||
def get_games_list(self):
|
def get_games_list(self):
|
||||||
@@ -60,8 +73,9 @@ class User(UserMixin, db.Model):
|
|||||||
|
|
||||||
def get_gamertags(self):
|
def get_gamertags(self):
|
||||||
"""Return gamertags as a dict keyed by game."""
|
"""Return gamertags as a dict keyed by game."""
|
||||||
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
|
return {
|
||||||
for gt in self.gamertags}
|
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
|
||||||
|
}
|
||||||
|
|
||||||
def get_org_teams(self):
|
def get_org_teams(self):
|
||||||
"""Return all OrgTeams this player belongs to."""
|
"""Return all OrgTeams this player belongs to."""
|
||||||
|
|||||||
+47
-11
@@ -110,7 +110,8 @@ http {
|
|||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
# X-XSS-Protection intentionally omitted: deprecated, removed from
|
||||||
|
# current browsers, and harmful in its last implementations.
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
@@ -118,8 +119,17 @@ http {
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Proxy to Waitress (Flask)
|
# Proxy to Waitress (Flask)
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# 10000 is `PORT`'s default in wsgi.py, which is what serves this
|
||||||
|
# application in production. This line said 5000 — run.py's default,
|
||||||
|
# the development server — so anyone installing this file as shipped
|
||||||
|
# got 502 Bad Gateway on every page, from a configuration that looks
|
||||||
|
# entirely reasonable (STD-06).
|
||||||
|
#
|
||||||
|
# If PORT is set in the server's .env, this must match it.
|
||||||
|
# tests/test_nginx_config.py fails if this drifts from wsgi.py again.
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:10000;
|
||||||
|
|
||||||
# Proxy headers
|
# Proxy headers
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -142,15 +152,41 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Static Files (served directly by Nginx for performance)
|
# Static Files (PERF-006)
|
||||||
# Uncomment and adjust path if you want Nginx to serve static files
|
#
|
||||||
|
# 59 KB of CSS and JS on every page load, previously proxied through
|
||||||
|
# Waitress. Nginx serves them from disk instead.
|
||||||
|
#
|
||||||
|
# ADJUST THIS ONE PATH to the deployment's checkout, absolute, forward
|
||||||
|
# slashes even on Windows. Nginx resolves a relative path against its
|
||||||
|
# own install prefix, not against this file. The trailing slash on both
|
||||||
|
# the location and the alias is required: without it /static/css/x.css
|
||||||
|
# resolves one directory too high.
|
||||||
|
#
|
||||||
|
# `immutable` is safe here and only here: url_for('static', …) appends
|
||||||
|
# ?v=<mtime> (see version_static_urls in app/app.py), so a deployed file
|
||||||
|
# is requested under a new URL and the cached copy of the old one is
|
||||||
|
# never asked for again. Removing that stamp and leaving this block
|
||||||
|
# gives every visitor a month-old stylesheet.
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# location /static/ {
|
location /static/ {
|
||||||
# alias C:/path/to/team-tryouts/static/;
|
alias C:/team-tryouts/app/static/;
|
||||||
# expires 30d;
|
expires 30d;
|
||||||
# add_header Cache-Control "public, immutable";
|
access_log off;
|
||||||
# access_log off;
|
|
||||||
# }
|
# These three are repeated on purpose. In nginx, add_header is
|
||||||
|
# inherited from the enclosing block ONLY when the current block
|
||||||
|
# declares none of its own — one add_header here silently drops
|
||||||
|
# every security header set at server level. Dropping nosniff on
|
||||||
|
# the JavaScript is the one that matters.
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
# A missing static file must 404, not fall through to Flask: the
|
||||||
|
# fallthrough would hide a broken deploy behind a working page.
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
@@ -161,7 +197,7 @@ http {
|
|||||||
|
|
||||||
# location /auth/login {
|
# location /auth/login {
|
||||||
# limit_req zone=login burst=5 nodelay;
|
# limit_req zone=login burst=5 nodelay;
|
||||||
# proxy_pass http://127.0.0.1:5000;
|
# proxy_pass http://127.0.0.1:10000;
|
||||||
# }
|
# }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Bounding what a list view loads (MNT-14).
|
||||||
|
|
||||||
|
Every list view ran `.all()` on its table and handed the whole thing to a
|
||||||
|
template. The audit rated the impact as nil — correctly, at the scale of a
|
||||||
|
student club — and recommended choosing the pattern now rather than
|
||||||
|
retro-fitting one later. This is that pattern, in one place, so that the
|
||||||
|
next list added to the application has something to copy.
|
||||||
|
|
||||||
|
Two decisions worth stating, because both are the kind that get made twice
|
||||||
|
differently otherwise.
|
||||||
|
|
||||||
|
**`error_out=False`.** Page numbers arrive in the URL, so `?page=999` is a
|
||||||
|
thing a person can type or a stale bookmark can hold. Flask-SQLAlchemy's
|
||||||
|
default answers it with a 404, which is a confusing thing to show someone
|
||||||
|
who has simply gone one page too far. An empty page is honest and the
|
||||||
|
controls take them back.
|
||||||
|
|
||||||
|
**A cap on `per_page`.** It is also a URL parameter, and without a ceiling
|
||||||
|
`?per_page=100000` re-creates by hand exactly the unbounded query this
|
||||||
|
module exists to prevent — the sort of thing that turns a listing into a
|
||||||
|
cheap way to make the server work hard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import request, url_for
|
||||||
|
|
||||||
|
#: Rows per page when nothing asks otherwise.
|
||||||
|
DEFAULT_PER_PAGE = 50
|
||||||
|
|
||||||
|
#: Ceiling on the `per_page` query parameter. Generous enough that anyone
|
||||||
|
#: wanting "everything" on one screen gets it for any realistic table, low
|
||||||
|
#: enough that the query stays bounded.
|
||||||
|
MAX_PER_PAGE = 200
|
||||||
|
|
||||||
|
|
||||||
|
def paginate(query, per_page=DEFAULT_PER_PAGE):
|
||||||
|
"""Return one page of `query`, honouring `?page=` and `?per_page=`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: A SQLAlchemy query, already ordered. Ordering matters: a
|
||||||
|
paginated query without ORDER BY may return the same row on two
|
||||||
|
pages and never return another.
|
||||||
|
per_page: Default page size for this listing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
flask_sqlalchemy.pagination.Pagination
|
||||||
|
"""
|
||||||
|
page = request.args.get('page', 1, type=int) or 1
|
||||||
|
requested = request.args.get('per_page', per_page, type=int) or per_page
|
||||||
|
size = max(1, min(requested, MAX_PER_PAGE))
|
||||||
|
return query.paginate(page=max(1, page), per_page=size, error_out=False)
|
||||||
|
|
||||||
|
|
||||||
|
def page_url(page):
|
||||||
|
"""URL of the current listing at another page number.
|
||||||
|
|
||||||
|
Rebuilt from the live request rather than composed in the template,
|
||||||
|
because the part that gets forgotten is the rest of the query string:
|
||||||
|
the evaluations list carries `sort` and `order`, the team matches list
|
||||||
|
carries `team_id`. A pagination link that drops them silently resets the
|
||||||
|
view the person was looking at.
|
||||||
|
"""
|
||||||
|
args = request.args.to_dict()
|
||||||
|
args.pop('page', None)
|
||||||
|
return url_for(request.endpoint, page=page, **(request.view_args or {}), **args)
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Shared access-control rules — the single point of truth (ARCH-002).
|
||||||
|
|
||||||
|
Authorisation used to live inline in eight route modules, and the same
|
||||||
|
question could get a different answer depending on which URL you reached.
|
||||||
|
This module now holds the rules themselves; routes and models call it.
|
||||||
|
|
||||||
|
The subtlety it hides from callers: a coach or a manager can be attached to
|
||||||
|
a team two different ways.
|
||||||
|
|
||||||
|
OrgTeam.coach_id the original single-coach column
|
||||||
|
OrgTeam.coaches the many-to-many relationship added later
|
||||||
|
|
||||||
|
Both are still populated. Reading only ``coach_id`` — which most of
|
||||||
|
users.py did — silently locked out every coach who was not the first one on
|
||||||
|
their team, and reading ``.first()`` on top of it locked a coach out of
|
||||||
|
every team but one. Both defects were live in production. Every function
|
||||||
|
here considers both attachment routes and every team, so the fix applies
|
||||||
|
once instead of at each call site.
|
||||||
|
|
||||||
|
ARCH-001 will collapse the two columns into one for good; that needs a data
|
||||||
|
migration, so until then this module is what makes the duplication
|
||||||
|
harmless.
|
||||||
|
|
||||||
|
Functions take the acting user explicitly rather than reading
|
||||||
|
``current_user``: it keeps them callable from models, from the Discord bot,
|
||||||
|
and from tests without a request context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Team attachment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def coach_org_teams(coach):
|
||||||
|
"""Organisation teams a coach is attached to, ordered by name.
|
||||||
|
|
||||||
|
Considers the many-to-many relationship *and* the legacy column, so the
|
||||||
|
second coach of a team is not treated as belonging to nothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The user to inspect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[OrgTeam]: Possibly empty.
|
||||||
|
"""
|
||||||
|
from app.models import OrgTeam
|
||||||
|
|
||||||
|
return (
|
||||||
|
OrgTeam.query.filter(
|
||||||
|
db.or_(
|
||||||
|
OrgTeam.coaches.any(id=coach.id),
|
||||||
|
OrgTeam.coach_id == coach.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(OrgTeam.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def coach_org_team_ids(coach):
|
||||||
|
"""IDs of the organisation teams a coach is attached to.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The user to inspect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[int]: Team IDs, possibly empty.
|
||||||
|
"""
|
||||||
|
return [team.id for team in coach_org_teams(coach)]
|
||||||
|
|
||||||
|
|
||||||
|
def manager_org_teams(manager):
|
||||||
|
"""Organisation teams a manager is attached to, ordered by name.
|
||||||
|
|
||||||
|
Symmetric to :func:`coach_org_teams`: ``manager_id`` and ``managers``
|
||||||
|
carry the same duplication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manager: The user to inspect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[OrgTeam]: Possibly empty.
|
||||||
|
"""
|
||||||
|
from app.models import OrgTeam
|
||||||
|
|
||||||
|
return (
|
||||||
|
OrgTeam.query.filter(
|
||||||
|
db.or_(
|
||||||
|
OrgTeam.managers.any(id=manager.id),
|
||||||
|
OrgTeam.manager_id == manager.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(OrgTeam.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def attached_org_teams(user):
|
||||||
|
"""Teams the user is personally attached to, whatever their role.
|
||||||
|
|
||||||
|
A president is attached to none in particular — they administer them
|
||||||
|
all — so this returns an empty list for them. Callers that want "the
|
||||||
|
teams to display" want :func:`visible_org_teams` instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[OrgTeam]: Possibly empty.
|
||||||
|
"""
|
||||||
|
from app.models import Coach, Manager, Player
|
||||||
|
|
||||||
|
if isinstance(user, Coach):
|
||||||
|
return coach_org_teams(user)
|
||||||
|
if isinstance(user, Manager):
|
||||||
|
return manager_org_teams(user)
|
||||||
|
if isinstance(user, Player):
|
||||||
|
return user.get_org_teams()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def visible_org_teams(user):
|
||||||
|
"""Organisation teams the user may see, ordered by name.
|
||||||
|
|
||||||
|
A president sees every team; a coach or a manager sees the ones they
|
||||||
|
are attached to; a player sees the ones they play on; anyone else sees
|
||||||
|
none.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[OrgTeam]: Possibly empty.
|
||||||
|
"""
|
||||||
|
from app.models import Admin, OrgTeam
|
||||||
|
|
||||||
|
if isinstance(user, Admin):
|
||||||
|
return OrgTeam.query.order_by(OrgTeam.name).all()
|
||||||
|
return attached_org_teams(user)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_org_team(user, org_team):
|
||||||
|
"""Whether the user may administer this organisation team.
|
||||||
|
|
||||||
|
Delegates to the polymorphic model method, which is the role-level
|
||||||
|
rule; this wrapper exists so route code has one name to call and never
|
||||||
|
has to know which subclass it is holding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
org_team: The team concerned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
return bool(org_team) and user.can_manage_this_org_team(org_team)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reach over players
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def org_team_player_ids(team_ids):
|
||||||
|
"""IDs of the players placed on any of these teams.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_ids: Team primary keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[int]: Player IDs, possibly empty, without duplicates.
|
||||||
|
"""
|
||||||
|
from app.models import TeamPlayer
|
||||||
|
|
||||||
|
if not team_ids:
|
||||||
|
return []
|
||||||
|
rows = TeamPlayer.query.filter(TeamPlayer.org_team_id.in_(team_ids)).all()
|
||||||
|
return list({row.player_id for row in rows})
|
||||||
|
|
||||||
|
|
||||||
|
def coach_player_ids(coach):
|
||||||
|
"""IDs of the players on *all* of a coach's teams.
|
||||||
|
|
||||||
|
The routes this replaces looked at one team — the first row matching
|
||||||
|
the legacy column — so a coach of two teams could act on half of their
|
||||||
|
squad and no more.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The acting coach.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[int]: Player IDs, possibly empty.
|
||||||
|
"""
|
||||||
|
return org_team_player_ids(coach_org_team_ids(coach))
|
||||||
|
|
||||||
|
|
||||||
|
def coach_can_access_player(coach, player_id):
|
||||||
|
"""Whether a coach may read or write information about a player.
|
||||||
|
|
||||||
|
True when the player sits on one of the coach's teams, or takes part in
|
||||||
|
a tryout the coach manages. Anything else means the two have no working
|
||||||
|
relationship, and a note or a contract about that player is none of the
|
||||||
|
coach's business.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The acting coach.
|
||||||
|
player_id: Primary key of the player concerned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
from app.models import Match, MatchParticipant, TeamPlayer, TryoutRegistration
|
||||||
|
|
||||||
|
if not player_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
team_ids = coach_org_team_ids(coach)
|
||||||
|
if team_ids:
|
||||||
|
on_team = TeamPlayer.query.filter(
|
||||||
|
TeamPlayer.player_id == player_id,
|
||||||
|
TeamPlayer.org_team_id.in_(team_ids),
|
||||||
|
).first()
|
||||||
|
if on_team:
|
||||||
|
return True
|
||||||
|
|
||||||
|
tryout_ids = coach_tryout_ids(coach, team_ids=team_ids)
|
||||||
|
if not tryout_ids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
registered = TryoutRegistration.query.filter(
|
||||||
|
TryoutRegistration.player_id == player_id,
|
||||||
|
TryoutRegistration.tryout_id.in_(tryout_ids),
|
||||||
|
).first()
|
||||||
|
if registered:
|
||||||
|
return True
|
||||||
|
|
||||||
|
plays_a_match = (
|
||||||
|
MatchParticipant.query.join(Match)
|
||||||
|
.filter(
|
||||||
|
MatchParticipant.player_id == player_id,
|
||||||
|
Match.tryout_id.in_(tryout_ids),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return plays_a_match is not None
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_player_contract(user, player_id):
|
||||||
|
"""Whether the user may upload or replace a contract for this player.
|
||||||
|
|
||||||
|
Presidents and managers may do so for anyone. A coach may do so for the
|
||||||
|
players on their teams — the working relationship a contract implies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
player_id: Primary key of the player concerned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
from app.models import Admin, Coach, Manager
|
||||||
|
|
||||||
|
if not player_id:
|
||||||
|
return False
|
||||||
|
if isinstance(user, (Admin, Manager)):
|
||||||
|
return True
|
||||||
|
if isinstance(user, Coach):
|
||||||
|
return player_id in coach_player_ids(user)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tryouts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def coach_tryouts(coach, team_ids=None):
|
||||||
|
"""Tryouts a coach manages, ordered by date.
|
||||||
|
|
||||||
|
A coach reaches a tryout through any of the three routes the model
|
||||||
|
supports: it targets one of their teams, they are named on the
|
||||||
|
many-to-many relationship, or the deprecated ``coach_id`` points at
|
||||||
|
them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The acting coach.
|
||||||
|
team_ids: Pre-computed team IDs, to avoid querying twice when the
|
||||||
|
caller already has them.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Tryout]: Possibly empty.
|
||||||
|
"""
|
||||||
|
from app.models import Tryout
|
||||||
|
|
||||||
|
if team_ids is None:
|
||||||
|
team_ids = coach_org_team_ids(coach)
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
Tryout.coaches.any(id=coach.id),
|
||||||
|
Tryout.coach_id == coach.id,
|
||||||
|
]
|
||||||
|
if team_ids:
|
||||||
|
conditions.append(Tryout.target_org_team_id.in_(team_ids))
|
||||||
|
|
||||||
|
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
|
||||||
|
|
||||||
|
|
||||||
|
def coach_tryout_ids(coach, team_ids=None):
|
||||||
|
"""IDs of the tryouts a coach manages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The acting coach.
|
||||||
|
team_ids: Pre-computed team IDs, see :func:`coach_tryouts`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[int]: Possibly empty.
|
||||||
|
"""
|
||||||
|
return [tryout.id for tryout in coach_tryouts(coach, team_ids=team_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
def coach_manages_tryout(coach, tryout):
|
||||||
|
"""Whether a coach manages this particular tryout.
|
||||||
|
|
||||||
|
Same three routes as :func:`coach_tryouts`, asked about one row.
|
||||||
|
Written as a membership test rather than a query so that a tryout not
|
||||||
|
yet flushed to the database still answers correctly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach: The acting coach.
|
||||||
|
tryout: The tryout concerned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
if tryout is None:
|
||||||
|
return False
|
||||||
|
if tryout.coach_id == coach.id:
|
||||||
|
return True
|
||||||
|
if any(c.id == coach.id for c in tryout.coaches):
|
||||||
|
return True
|
||||||
|
if tryout.target_org_team_id:
|
||||||
|
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
||||||
|
return False
|
||||||
@@ -0,0 +1,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
|
This module handles user authentication including login with account lockout
|
||||||
protection, logout with session clearing, and new user registration with
|
protection, logout with session clearing, and new user registration with
|
||||||
password policy enforcement and CAPTCHA verification.
|
password policy enforcement and sign-up screening.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
import secrets
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
import time
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from datetime import timedelta
|
||||||
from app.extensions import db, hash_password, check_password, limiter
|
from urllib.parse import urlencode, urlparse
|
||||||
from app.models import User, Player, ESPORT_GAMES
|
|
||||||
from app.validators import RegisterSchema, LoginSchema
|
|
||||||
from marshmallow import ValidationError
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
import requests
|
|
||||||
|
|
||||||
# Account lockout settings
|
import requests
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from flask_login import current_user, login_required, login_user, logout_user
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
|
from app.extensions import check_password, db, hash_password, limiter
|
||||||
|
from app.forms import form_gamertags
|
||||||
|
from app.i18n import LOCALE_SESSION_KEY
|
||||||
|
from app.logging_config import log_auth_event
|
||||||
|
from app.models import ESPORT_GAMES, Player, User
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
||||||
|
|
||||||
|
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||||
|
DISCORD_STATE_KEY = 'discord_oauth_state'
|
||||||
|
#: Whether the OAuth result should create a registration draft or relink the
|
||||||
|
#: signed-in account. Kept server-side and covered by the same signed session
|
||||||
|
#: as the anti-forgery state.
|
||||||
|
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
|
||||||
|
|
||||||
|
# Failed-attempt tracking. The tally is kept for the audit trail and for the
|
||||||
|
# cool-off marker below; it no longer refuses a correct password (SEC-018).
|
||||||
MAX_LOGIN_ATTEMPTS = 5
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
LOCKOUT_DURATION_MINUTES = 15
|
LOCKOUT_DURATION_MINUTES = 15
|
||||||
|
#: Ceiling on the doubling cool-off window.
|
||||||
|
MAX_LOCKOUT_MINUTES = 240
|
||||||
|
|
||||||
|
#: Hash of a value nobody can submit. Verifying against it when the username
|
||||||
|
#: is unknown makes that path cost the same scrypt work as a real one, so the
|
||||||
|
#: response time stops telling a caller which usernames exist (SEC-017).
|
||||||
|
_ABSENT_USER_HASH = None
|
||||||
|
|
||||||
|
#: Session key recording when the registration form was handed out.
|
||||||
|
REGISTRATION_ISSUED_KEY = 'registration_form_issued_at'
|
||||||
|
|
||||||
|
#: Name of the honeypot input. Plausible enough that a form-filler wants it,
|
||||||
|
#: absent from the visible form. Hidden by .honeypot in style.css — not by an
|
||||||
|
#: inline style, so that the rule survives a tightening of style-src.
|
||||||
|
REGISTRATION_HONEYPOT_FIELD = 'website'
|
||||||
|
|
||||||
|
#: Floor on how long a genuine registration takes. Eleven fields and a
|
||||||
|
#: password typed twice; three seconds is generous.
|
||||||
|
MIN_REGISTRATION_SECONDS = 3
|
||||||
|
|
||||||
# Discord OAuth2 configuration
|
# Discord OAuth2 configuration
|
||||||
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
|
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
|
||||||
@@ -40,53 +75,124 @@ DISCORD_PLATFORM_TO_GAMES = {
|
|||||||
def is_safe_url(url):
|
def is_safe_url(url):
|
||||||
"""Validate that a URL is safe for redirection (same origin).
|
"""Validate that a URL is safe for redirection (same origin).
|
||||||
|
|
||||||
|
Accepts an absolute URL on this host, or a path beginning with exactly
|
||||||
|
one slash. Everything else is refused, including the two forms that
|
||||||
|
read differently to urlparse and to a browser:
|
||||||
|
|
||||||
|
/\\evil.com several browsers normalise the backslash to a slash,
|
||||||
|
turning this into the protocol-relative //evil.com.
|
||||||
|
urlparse reports no netloc at all, so the old check
|
||||||
|
let it through and the redirect left the site.
|
||||||
|
/\\n//evil.com control characters are stripped before parsing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: The URL to validate.
|
url: The URL to validate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the URL is safe (relative or same origin).
|
bool: True if the URL is safe.
|
||||||
"""
|
"""
|
||||||
if not url:
|
if not url:
|
||||||
return False
|
return False
|
||||||
|
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
|
||||||
|
return False
|
||||||
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
# Allow relative URLs (no netloc) or same-origin URLs
|
if parsed.netloc:
|
||||||
return not parsed.netloc or parsed.netloc == request.host
|
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
|
||||||
|
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
|
||||||
|
# not paths on this site.
|
||||||
|
return url.startswith('/')
|
||||||
|
|
||||||
|
|
||||||
def generate_captcha():
|
def _absent_user_hash():
|
||||||
"""Generate a simple math CAPTCHA challenge.
|
"""A hash to verify against when the submitted username does not exist.
|
||||||
|
|
||||||
Creates a random addition problem and stores the answer in the session.
|
check_password() used to be reached only when a user row was found, so
|
||||||
|
an unknown username answered as fast as the database lookup, and a known
|
||||||
Returns:
|
one as slowly as scrypt. The gap is measurable and enumerates accounts.
|
||||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
Computed once per process, from a random secret, so no submitted password
|
||||||
|
can ever match it.
|
||||||
"""
|
"""
|
||||||
import random
|
global _ABSENT_USER_HASH
|
||||||
a = random.randint(1, 10)
|
if _ABSENT_USER_HASH is None:
|
||||||
b = random.randint(1, 10)
|
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
|
||||||
captcha_id = str(uuid.uuid4())
|
return _ABSENT_USER_HASH
|
||||||
session['captcha_id'] = captcha_id
|
|
||||||
session['captcha_answer'] = a + b
|
|
||||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_captcha(user_answer):
|
def cooloff_minutes(failed_attempts):
|
||||||
"""Verify the CAPTCHA answer from the session.
|
"""Length of the cool-off window earned by this many failed attempts.
|
||||||
|
|
||||||
|
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_answer: The user's submitted answer (string or int).
|
failed_attempts: Consecutive failures recorded on the account.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the answer matches the stored CAPTCHA, False otherwise.
|
int: Minutes.
|
||||||
"""
|
"""
|
||||||
try:
|
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
|
||||||
expected = session.pop('captcha_answer', None)
|
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
|
||||||
session.pop('captcha_id', None)
|
|
||||||
if expected is None:
|
|
||||||
return False
|
def issue_registration_challenge():
|
||||||
return int(user_answer) == expected
|
"""Mark that the registration form has been handed out, and when.
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
Kept in the signed session rather than in a form field, so that the
|
||||||
|
timestamp is not something the submitter can choose. Left in place across
|
||||||
|
a failed submission: someone correcting a typo should not be told to slow
|
||||||
|
down, and a robot has already paid for the round trip by then.
|
||||||
|
"""
|
||||||
|
session.setdefault(REGISTRATION_ISSUED_KEY, time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def check_registration_challenge(form):
|
||||||
|
"""Say why this registration should be refused, or None to accept.
|
||||||
|
|
||||||
|
What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008).
|
||||||
|
|
||||||
|
`a + b = ?` with both operands between 1 and 10 has nineteen possible
|
||||||
|
answers and is solvable by reading the string. It stopped no automated
|
||||||
|
registration whatsoever. What it did do was add a step for every human,
|
||||||
|
including anyone using a screen reader, in exchange for an appearance of
|
||||||
|
protection — which is worse than no protection, because it gets counted
|
||||||
|
as one.
|
||||||
|
|
||||||
|
The audit's alternative was a real CAPTCHA service. That means a third
|
||||||
|
party, an API key, a request on every page load, and putting a foreign
|
||||||
|
script back into script-src — undoing the CSP work that closed
|
||||||
|
SEC-WEB-001. Disproportionate for a club site.
|
||||||
|
|
||||||
|
So: two checks that cost the visitor nothing.
|
||||||
|
|
||||||
|
- a honeypot field, hidden in the stylesheet, that a form-filling
|
||||||
|
robot completes and a person never sees;
|
||||||
|
- a minimum dwell time between being handed the form and sending it
|
||||||
|
back. Eleven fields and a password typed twice do not get filled in
|
||||||
|
under three seconds, and a POST with no issued form at all never
|
||||||
|
fetched the page.
|
||||||
|
|
||||||
|
Be clear about the ceiling: this stops commodity spam, not somebody who
|
||||||
|
looks at the form for five minutes. The thing that would actually gate
|
||||||
|
registration is staff activation of new accounts, which does not exist —
|
||||||
|
`is_active_account` defaults to True. That is a product decision, not one
|
||||||
|
to slip in here.
|
||||||
|
|
||||||
|
The session-forgery angle in the constat is moot: with SECRET_KEY
|
||||||
|
compromised (SEC-001) an attacker forges a logged-in session for any
|
||||||
|
account and has no reason to register at all.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: a short reason for the log, or None to let it through.
|
||||||
|
"""
|
||||||
|
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
|
||||||
|
return 'honeypot'
|
||||||
|
|
||||||
|
issued_at = session.get(REGISTRATION_ISSUED_KEY)
|
||||||
|
if issued_at is None:
|
||||||
|
return 'no-form-issued'
|
||||||
|
if time.time() - issued_at < MIN_REGISTRATION_SECONDS:
|
||||||
|
return 'too-fast'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
@@ -95,16 +201,17 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
|||||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
@limiter.limit("10 per minute")
|
@limiter.limit("10 per minute")
|
||||||
def login():
|
def login():
|
||||||
"""Handle user login authentication with account lockout protection.
|
"""Handle user login authentication.
|
||||||
|
|
||||||
GET: Render the login form.
|
GET: Render the login form.
|
||||||
POST: Authenticate user credentials with lockout check and audit logging.
|
POST: Authenticate user credentials, with audit logging.
|
||||||
|
|
||||||
Account lockout: After 5 consecutive failed attempts, the account is
|
Failed attempts are counted and open a cool-off window, recorded in
|
||||||
locked for 15 minutes. Successful login resets the counter.
|
``locked_until`` and in the authentication log. The window does not
|
||||||
|
refuse correct credentials: when it did, five wrong guesses against a
|
||||||
Redirects authenticated users to dashboard. Validates credentials and checks
|
known username took that account out of service for fifteen minutes,
|
||||||
account status before login.
|
repeatably, and on a president's account that meant no administration
|
||||||
|
at all. Guess rate is bounded by the rate limit on this view.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: Login form or redirect to dashboard/next page.
|
Response: Login form or redirect to dashboard/next page.
|
||||||
@@ -120,80 +227,124 @@ def login():
|
|||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
for field, messages in err.messages.items():
|
for field, messages in err.messages.items():
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
flash(f'{field}: {msg}', 'danger')
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||||
return render_template('pages/login.html')
|
return render_template('pages/login.html')
|
||||||
|
|
||||||
username = validated['username']
|
username = validated['username']
|
||||||
password = validated['password']
|
password = validated['password']
|
||||||
user = User.query.filter_by(username=username).first()
|
user = User.query.filter_by(username=username).first()
|
||||||
|
|
||||||
# Check if account is locked
|
# Verified before anything else is decided, and on both branches.
|
||||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
# Reaching this only when a row exists made the response time a
|
||||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
# reliable oracle for which usernames are registered (SEC-017).
|
||||||
flash(
|
credentials_ok = check_password(
|
||||||
f'Account is locked due to too many failed attempts. '
|
user.password_hash if user else _absent_user_hash(), password
|
||||||
f'Please try again in {remaining} minute(s).',
|
|
||||||
'danger'
|
|
||||||
)
|
)
|
||||||
return render_template('pages/login.html')
|
|
||||||
|
|
||||||
if user and check_password(user.password_hash, password):
|
if user and credentials_ok:
|
||||||
if not user.is_active_account:
|
if not user.is_active_account:
|
||||||
flash('This account has been deactivated.', 'danger')
|
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
|
||||||
|
flash(_('This account has been deactivated.'), 'danger')
|
||||||
return render_template('pages/login.html')
|
return render_template('pages/login.html')
|
||||||
|
|
||||||
# Reset failed login attempts on successful login
|
# Correct credentials clear the tally, cool-off window included.
|
||||||
|
# The window used to refuse them too, which is what turned it
|
||||||
|
# into a way to lock a known account out at will (SEC-018).
|
||||||
user.failed_login_attempts = 0
|
user.failed_login_attempts = 0
|
||||||
user.locked_until = None
|
user.locked_until = None
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Clear old session data and preserve CSRF token to prevent
|
# Clear old session data and preserve CSRF token to prevent
|
||||||
# session fixation attacks (Flask-Login rotates the session ID)
|
# session fixation attacks (Flask-Login rotates the session ID).
|
||||||
_csrf_token = session.get('csrf_token')
|
#
|
||||||
|
# The language choice is carried across too. Someone who reads the
|
||||||
|
# login page in English and signs in would otherwise be dropped
|
||||||
|
# back into French — the preference lives in the session, and
|
||||||
|
# clearing it discards a decision the user just made.
|
||||||
|
_preserved = {
|
||||||
|
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
|
||||||
|
}
|
||||||
session.clear()
|
session.clear()
|
||||||
if _csrf_token:
|
session.update(_preserved)
|
||||||
session['csrf_token'] = _csrf_token
|
|
||||||
|
# Mark the session permanent so PERMANENT_SESSION_LIFETIME applies.
|
||||||
|
# Without this, Flask emits a browser-session cookie with no expiry
|
||||||
|
# and the configured lifetime is silently ignored.
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
|
||||||
|
|
||||||
# Validate redirect URL to prevent open redirect vulnerability
|
# Validate redirect URL to prevent open redirect vulnerability
|
||||||
next_page = request.args.get('next')
|
next_page = request.args.get('next')
|
||||||
if next_page and not is_safe_url(next_page):
|
if next_page and not is_safe_url(next_page):
|
||||||
next_page = None
|
next_page = None
|
||||||
flash(f'Welcome back, {user.username}!', 'success')
|
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
||||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||||
else:
|
# One message for every failure. The old code said "N attempts
|
||||||
# Track failed login attempt
|
# 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:
|
if user:
|
||||||
user.failed_login_attempts += 1
|
user.failed_login_attempts += 1
|
||||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
log_auth_event(
|
||||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
'login.failure',
|
||||||
flash(
|
username=username,
|
||||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
user_id=user.id,
|
||||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
attempts=user.failed_login_attempts,
|
||||||
'danger'
|
|
||||||
)
|
)
|
||||||
else:
|
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||||
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
|
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||||
flash(
|
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
|
||||||
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
|
log_auth_event(
|
||||||
'danger'
|
'account.throttled',
|
||||||
|
username=username,
|
||||||
|
user_id=user.id,
|
||||||
|
minutes=minutes,
|
||||||
|
attempts=user.failed_login_attempts,
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
else:
|
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')
|
return render_template('pages/login.html')
|
||||||
|
|
||||||
|
|
||||||
|
def _rerender_registration(form_data):
|
||||||
|
"""Re-render the registration form after a refusal.
|
||||||
|
|
||||||
|
Was copied out four times, near-identically (ARCH-005). Dropping the two
|
||||||
|
password fields is the part that must not be forgotten in the fifth copy:
|
||||||
|
echoing a password back into the HTML puts it in the browser's cache and
|
||||||
|
in any proxy log along the way.
|
||||||
|
"""
|
||||||
|
form_data = dict(form_data)
|
||||||
|
form_data.pop('password', None)
|
||||||
|
form_data.pop('confirm_password', None)
|
||||||
|
return render_template(
|
||||||
|
'pages/register.html',
|
||||||
|
esport_games=ESPORT_GAMES,
|
||||||
|
honeypot_field=REGISTRATION_HONEYPOT_FIELD,
|
||||||
|
form_data=form_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||||
@limiter.limit("20 per hour")
|
@limiter.limit("20 per hour")
|
||||||
def register():
|
def register():
|
||||||
"""Handle new player registration with CAPTCHA and password policy.
|
"""Handle new player registration.
|
||||||
|
|
||||||
GET: Render the registration form with E-Sports games list and CAPTCHA.
|
GET: Render the registration form with the E-Sports games list.
|
||||||
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
|
POST: Screen the submission (see check_registration_challenge), validate
|
||||||
and create a new player account.
|
every input against RegisterSchema, and create a new player account.
|
||||||
|
|
||||||
Only players can register through this form. Validates username/email
|
Only players can register through this form. Validates username/email
|
||||||
uniqueness and password confirmation.
|
uniqueness and password confirmation.
|
||||||
@@ -209,20 +360,24 @@ def register():
|
|||||||
form_data = dict(request.form)
|
form_data = dict(request.form)
|
||||||
form_data['games'] = request.form.getlist('games')
|
form_data['games'] = request.form.getlist('games')
|
||||||
|
|
||||||
# Validate CAPTCHA first
|
# Once Discord has authenticated the identity, neither its display
|
||||||
captcha_answer = request.form.get('captcha_answer', '')
|
# name nor its snowflake is input data anymore. Remove any client
|
||||||
if not verify_captcha(captcha_answer):
|
# copies before validation as well as before persistence: otherwise a
|
||||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
# forged, malformed hidden value can still make the verified flow fail.
|
||||||
captcha = generate_captcha()
|
discord_oauth = session.get('discord_oauth') or {}
|
||||||
# Clear password fields only on CAPTCHA failure
|
if discord_oauth.get('id'):
|
||||||
form_data.pop('password', None)
|
form_data.pop('discord_username', None)
|
||||||
form_data.pop('confirm_password', None)
|
form_data.pop('discord_user_id', None)
|
||||||
return render_template(
|
|
||||||
'pages/register.html',
|
refusal = check_registration_challenge(request.form)
|
||||||
esport_games=ESPORT_GAMES,
|
if refusal is not None:
|
||||||
captcha=captcha,
|
# Logged, because this is the only place abuse of the sign-up
|
||||||
form_data=form_data,
|
# form becomes visible at all. Deliberately vague to the sender:
|
||||||
)
|
# naming the honeypot tells whoever tripped it how to avoid it.
|
||||||
|
log_auth_event('account.registration_refused', reason=refusal)
|
||||||
|
flash(_('Your registration could not be processed. Please try again.'), 'danger')
|
||||||
|
issue_registration_challenge()
|
||||||
|
return _rerender_registration(form_data)
|
||||||
|
|
||||||
# Validate input with marshmallow schema
|
# Validate input with marshmallow schema
|
||||||
register_schema = RegisterSchema()
|
register_schema = RegisterSchema()
|
||||||
@@ -231,17 +386,8 @@ def register():
|
|||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
for field, messages in err.messages.items():
|
for field, messages in err.messages.items():
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
flash(f'{field}: {msg}', 'danger')
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||||
captcha = generate_captcha()
|
return _rerender_registration(form_data)
|
||||||
# Clear password fields on validation failure
|
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
|
||||||
return render_template(
|
|
||||||
'pages/register.html',
|
|
||||||
esport_games=ESPORT_GAMES,
|
|
||||||
captcha=captcha,
|
|
||||||
form_data=form_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
username = validated['username']
|
username = validated['username']
|
||||||
email = validated['email']
|
email = validated['email']
|
||||||
@@ -249,33 +395,41 @@ def register():
|
|||||||
full_name = validated['full_name']
|
full_name = validated['full_name']
|
||||||
phone = validated.get('phone')
|
phone = validated.get('phone')
|
||||||
selected_games = validated.get('games', [])
|
selected_games = validated.get('games', [])
|
||||||
discord_username = validated.get('discord_username')
|
try:
|
||||||
discord_user_id = validated.get('discord_user_id')
|
submitted_gamertags = form_gamertags(selected_games)
|
||||||
|
except ValidationError as err:
|
||||||
|
for field, messages in err.messages.items():
|
||||||
|
for msg in messages:
|
||||||
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||||
|
return _rerender_registration(form_data)
|
||||||
|
# The OAuth identity is server-side state. It used to be copied into
|
||||||
|
# hidden inputs and read back from request.form, which let anyone
|
||||||
|
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||||
|
# A manual registration may still provide a display name, but never a
|
||||||
|
# Discord snowflake: that identifier is an authentication factor for
|
||||||
|
# bot reactions and must come from Discord itself.
|
||||||
|
discord_user_id = discord_oauth.get('id')
|
||||||
|
if discord_user_id:
|
||||||
|
discord_user_id = str(discord_user_id)
|
||||||
|
discord_username = (
|
||||||
|
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
|
||||||
|
)
|
||||||
league_os_profile = validated.get('league_os_profile')
|
league_os_profile = validated.get('league_os_profile')
|
||||||
|
|
||||||
if User.query.filter_by(username=username).first():
|
if User.query.filter_by(username=username).first():
|
||||||
flash('Username already exists.', 'danger')
|
flash(_('Username already exists.'), 'danger')
|
||||||
captcha = generate_captcha()
|
return _rerender_registration(form_data)
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
|
||||||
return render_template(
|
|
||||||
'pages/register.html',
|
|
||||||
esport_games=ESPORT_GAMES,
|
|
||||||
captcha=captcha,
|
|
||||||
form_data=form_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
if User.query.filter_by(email=email).first():
|
if User.query.filter_by(email=email).first():
|
||||||
flash('Email already registered.', 'danger')
|
flash(_('Email already registered.'), 'danger')
|
||||||
captcha = generate_captcha()
|
return _rerender_registration(form_data)
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
# The database constraint belongs to DB-002, after production has
|
||||||
return render_template(
|
# been backed up and deduplicated. Refuse new duplicates now instead
|
||||||
'pages/register.html',
|
# of leaving the critical impersonation path open until then.
|
||||||
esport_games=ESPORT_GAMES,
|
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
|
||||||
captcha=captcha,
|
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||||
form_data=form_data,
|
return _rerender_registration(form_data)
|
||||||
)
|
|
||||||
|
|
||||||
hashed_password = hash_password(password)
|
hashed_password = hash_password(password)
|
||||||
user = Player(
|
user = Player(
|
||||||
@@ -291,36 +445,37 @@ def register():
|
|||||||
league_os_profile=league_os_profile,
|
league_os_profile=league_os_profile,
|
||||||
)
|
)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
# flush, not commit: the id is needed for the gamertag rows below,
|
||||||
|
# and signing up is one operation. Committing here made it two, so a
|
||||||
|
# failure while writing the gamertags left an account whose declared
|
||||||
|
# games were silently absent (ARCH-006).
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# Create UserGamertag records for each selected game
|
# Create UserGamertag records for each selected game
|
||||||
from app.models import UserGamertag
|
from app.models import UserGamertag
|
||||||
for game in selected_games:
|
|
||||||
field_name = f'gamertag_{game}'
|
for game, gamertag_data in submitted_gamertags.items():
|
||||||
gamertag_value = request.form.get(field_name, '').strip()
|
|
||||||
if gamertag_value:
|
|
||||||
gamertag = UserGamertag(
|
gamertag = UserGamertag(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
game=game,
|
game=game,
|
||||||
gamertag=gamertag_value,
|
gamertag=gamertag_data['gamertag'],
|
||||||
|
platform=gamertag_data['platform'],
|
||||||
)
|
)
|
||||||
db.session.add(gamertag)
|
db.session.add(gamertag)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Clear Discord OAuth data from session after successful registration
|
# Clear Discord OAuth data from session after successful registration
|
||||||
session.pop('discord_oauth', None)
|
session.pop('discord_oauth', None)
|
||||||
|
session.pop(REGISTRATION_ISSUED_KEY, None)
|
||||||
|
|
||||||
flash('Your account has been created! You can now log in.', 'success')
|
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||||
|
|
||||||
|
flash(_('Your account has been created! You can now log in.'), 'success')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
# GET request — render empty form
|
# GET request — render empty form
|
||||||
captcha = generate_captcha()
|
issue_registration_challenge()
|
||||||
return render_template(
|
return _rerender_registration({})
|
||||||
'pages/register.html',
|
|
||||||
esport_games=ESPORT_GAMES,
|
|
||||||
captcha=captcha,
|
|
||||||
form_data={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/discord/login')
|
@auth_bp.route('/discord/login')
|
||||||
@@ -333,18 +488,31 @@ def discord_login():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Redirect to Discord authorization URL.
|
Response: Redirect to Discord authorization URL.
|
||||||
"""
|
"""
|
||||||
if not DISCORD_CLIENT_ID:
|
purpose = 'profile' if current_user.is_authenticated else 'registration'
|
||||||
flash('Discord OAuth2 is not configured.', 'danger')
|
session[DISCORD_PURPOSE_KEY] = purpose
|
||||||
return redirect(url_for('auth.register'))
|
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
|
||||||
|
|
||||||
|
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||||
|
# raise inside the query builder rather than report a configuration error.
|
||||||
|
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||||
|
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
||||||
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
|
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||||
|
# attacker could have the victim's browser consume an authorization code
|
||||||
|
# obtained for the attacker's own Discord account, silently binding that
|
||||||
|
# identity to the victim's registration form.
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
session[DISCORD_STATE_KEY] = state
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'client_id': DISCORD_CLIENT_ID,
|
'client_id': DISCORD_CLIENT_ID,
|
||||||
'redirect_uri': DISCORD_REDIRECT_URI,
|
'redirect_uri': DISCORD_REDIRECT_URI,
|
||||||
'response_type': 'code',
|
'response_type': 'code',
|
||||||
'scope': 'identify connections',
|
'scope': 'identify connections',
|
||||||
|
'state': state,
|
||||||
}
|
}
|
||||||
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
|
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
|
||||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
|
|
||||||
return redirect(auth_url)
|
return redirect(auth_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -352,18 +520,41 @@ def discord_login():
|
|||||||
def discord_callback():
|
def discord_callback():
|
||||||
"""Handle the OAuth2 callback from Discord.
|
"""Handle the OAuth2 callback from Discord.
|
||||||
|
|
||||||
Exchanges the authorization code for an access token, then fetches
|
Exchanges the authorization code for an access token, then fetches the
|
||||||
the user's profile (/users/@me) and connections (/users/@me/connections).
|
user's profile. During registration, connected game accounts are also
|
||||||
Results are stored in the session and the user is redirected back to
|
loaded into server-side draft state. For a signed-in profile relink, the
|
||||||
the registration form where fields will be pre-filled.
|
verified identity is written directly without passing through a form.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: Redirect to registration page.
|
Response: Redirect to the registration form or profile editor.
|
||||||
"""
|
"""
|
||||||
|
# The state is consumed whatever happens next: a token is single-use, and
|
||||||
|
# leaving it in the session would allow a replay.
|
||||||
|
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
|
||||||
|
if purpose == 'profile' and current_user.is_authenticated:
|
||||||
|
return_endpoint = 'users.edit_profile'
|
||||||
|
elif purpose == 'profile':
|
||||||
|
return_endpoint = 'auth.login'
|
||||||
|
else:
|
||||||
|
return_endpoint = 'auth.register'
|
||||||
|
|
||||||
|
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||||
|
received_state = request.args.get('state', '')
|
||||||
|
|
||||||
|
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||||
|
flash(
|
||||||
|
_(
|
||||||
|
'Discord authorization could not be verified. '
|
||||||
|
'Please start the connection again from this page.'
|
||||||
|
),
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
code = request.args.get('code')
|
code = request.args.get('code')
|
||||||
if not code:
|
if not code:
|
||||||
flash('Discord authorization failed. No code received.', 'danger')
|
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
# Exchange the authorization code for an access token
|
# Exchange the authorization code for an access token
|
||||||
token_data = {
|
token_data = {
|
||||||
@@ -385,13 +576,13 @@ def discord_callback():
|
|||||||
token_response.raise_for_status()
|
token_response.raise_for_status()
|
||||||
token_json = token_response.json()
|
token_json = token_response.json()
|
||||||
access_token = token_json.get('access_token')
|
access_token = token_json.get('access_token')
|
||||||
except requests.RequestException as e:
|
except requests.RequestException:
|
||||||
flash(f'Failed to connect to Discord. Please try again.', 'danger')
|
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
flash('Failed to obtain Discord access token.', 'danger')
|
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||||
|
|
||||||
@@ -405,8 +596,44 @@ def discord_callback():
|
|||||||
user_response.raise_for_status()
|
user_response.raise_for_status()
|
||||||
user_data = user_response.json()
|
user_data = user_response.json()
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
flash('Failed to fetch Discord user profile.', 'danger')
|
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
|
discord_user_id = user_data.get('id')
|
||||||
|
try:
|
||||||
|
if not discord_user_id:
|
||||||
|
raise ValidationError('missing Discord user id')
|
||||||
|
discord_user_id = str(discord_user_id)
|
||||||
|
validate_discord_user_id(discord_user_id)
|
||||||
|
except ValidationError:
|
||||||
|
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||||
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
|
if purpose == 'profile':
|
||||||
|
# If the session expired while Discord was open, do not turn a profile
|
||||||
|
# relink into registration state for an anonymous browser.
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
flash(_('Please log in to connect your Discord account.'), 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
clash = User.query.filter(
|
||||||
|
User.discord_user_id == discord_user_id,
|
||||||
|
User.id != current_user.id,
|
||||||
|
).first()
|
||||||
|
if clash:
|
||||||
|
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||||
|
return redirect(url_for('users.edit_profile'))
|
||||||
|
|
||||||
|
current_user.discord_user_id = discord_user_id
|
||||||
|
current_user.discord_username = user_data.get('username') or None
|
||||||
|
db.session.commit()
|
||||||
|
log_auth_event(
|
||||||
|
'account.discord_linked',
|
||||||
|
username=current_user.username,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
flash(_('Discord account connected!'), 'success')
|
||||||
|
return redirect(url_for('users.edit_profile'))
|
||||||
|
|
||||||
# Fetch the user's connected gaming accounts
|
# Fetch the user's connected gaming accounts
|
||||||
connections = []
|
connections = []
|
||||||
@@ -445,29 +672,40 @@ def discord_callback():
|
|||||||
|
|
||||||
# Store in session for the registration form to use
|
# Store in session for the registration form to use
|
||||||
session['discord_oauth'] = {
|
session['discord_oauth'] = {
|
||||||
'id': user_data.get('id'),
|
'id': discord_user_id,
|
||||||
'username': user_data.get('username'),
|
'username': user_data.get('username'),
|
||||||
'avatar': user_data.get('avatar'),
|
'avatar': user_data.get('avatar'),
|
||||||
'gamertag_suggestions': gamertag_suggestions,
|
'gamertag_suggestions': gamertag_suggestions,
|
||||||
'auto_select_games': auto_select_games,
|
'auto_select_games': auto_select_games,
|
||||||
}
|
}
|
||||||
|
|
||||||
flash('Discord account connected! Your profile has been pre-filled.', 'success')
|
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for('auth.register'))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/logout')
|
@auth_bp.route('/logout', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
"""Log out the current user and clear the session.
|
"""Log out the current user and clear the session.
|
||||||
|
|
||||||
|
POST, not GET: a GET route is not covered by CSRF protection, so any
|
||||||
|
page on the internet could sign a user out with an <img> tag pointing
|
||||||
|
here. A nuisance rather than a compromise, but it costs one form to
|
||||||
|
close (SEC-019).
|
||||||
|
|
||||||
Clears the user session and regenerates session ID to prevent
|
Clears the user session and regenerates session ID to prevent
|
||||||
session fixation/replay after logout.
|
session fixation/replay after logout.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: Redirect to login page with logout message.
|
Response: Redirect to login page with logout message.
|
||||||
"""
|
"""
|
||||||
|
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
|
||||||
logout_user()
|
logout_user()
|
||||||
|
# Same reasoning as at login: the language is a display preference, not
|
||||||
|
# session state belonging to the account being signed out.
|
||||||
|
_locale = session.get(LOCALE_SESSION_KEY)
|
||||||
session.clear()
|
session.clear()
|
||||||
flash('You have been logged out.', 'info')
|
if _locale:
|
||||||
|
session[LOCALE_SESSION_KEY] = _locale
|
||||||
|
flash(_('You have been logged out.'), 'info')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
+400
-179
@@ -3,129 +3,327 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
import math
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from app.extensions import db
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
from app.models import (
|
from flask_babel import gettext as _
|
||||||
Admin, Coach, Manager, Player,
|
from flask_login import current_user, login_required
|
||||||
User, Tryout, Evaluation, TryoutRegistration,
|
from marshmallow import ValidationError
|
||||||
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
|
from sqlalchemy import false, func, select
|
||||||
)
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
|
from app.models import (
|
||||||
|
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')
|
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):
|
def validate_score(score_value):
|
||||||
"""Validate that a score is between 1 and 10."""
|
"""Turn a raw form value into a criterion score (1..10), or None.
|
||||||
if score_value is 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
|
return None
|
||||||
try:
|
try:
|
||||||
score = int(score_value)
|
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
|
return score
|
||||||
return None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def compute_overall(scores):
|
|
||||||
"""Average the non-None scores, or return None if there are none."""
|
|
||||||
valid = [s for s in scores if s is not None]
|
|
||||||
return sum(valid) / len(valid) if valid else None
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_evaluation(evaluation, scores, comments, position):
|
def _apply_evaluation(evaluation, scores, comments, position):
|
||||||
"""Write validated scores/comments/position onto an Evaluation instance."""
|
"""Write validated scores, comments and position onto an Evaluation."""
|
||||||
for field_name, _ in EVALUATION_CRITERIA:
|
evaluation.apply_scores(scores)
|
||||||
setattr(evaluation, field_name, scores[field_name])
|
|
||||||
evaluation.overall_score = compute_overall(list(scores.values()))
|
|
||||||
evaluation.comments = comments
|
evaluation.comments = comments
|
||||||
evaluation.position_recommendation = position
|
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
|
||||||
|
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('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
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
|
user = current_user
|
||||||
|
|
||||||
if isinstance(user, Player):
|
if isinstance(user, Player):
|
||||||
flash('You do not have permission to view evaluations.', 'danger')
|
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
sort_column = request.args.get('sort', 'created_at')
|
sort_column = request.args.get('sort', 'overall_score')
|
||||||
sort_order = request.args.get('order', 'desc')
|
sort_order = request.args.get('order', 'desc')
|
||||||
if sort_order not in ('asc', 'desc'):
|
if sort_order not in ('asc', 'desc'):
|
||||||
sort_order = 'desc'
|
sort_order = 'desc'
|
||||||
|
|
||||||
player_alias = aliased(User, name='eval_player')
|
visible_ids = _visible_evaluation_ids(user)
|
||||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
|
||||||
|
|
||||||
sort_map = {
|
aggregate = (
|
||||||
'tryout': Tryout.title,
|
db.session.query(
|
||||||
'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(
|
|
||||||
Evaluation.player_id,
|
Evaluation.player_id,
|
||||||
func.count(Evaluation.id).label('eval_count'),
|
func.count(Evaluation.id).label('eval_count'),
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
).group_by(Evaluation.player_id).all()
|
func.max(Evaluation.created_at).label('last_eval'),
|
||||||
player_scores = {}
|
)
|
||||||
for row in avg_scores:
|
.filter(Evaluation.id.in_(visible_ids))
|
||||||
p = User.query.get(row.player_id)
|
.group_by(Evaluation.player_id)
|
||||||
if p:
|
.all()
|
||||||
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 = {}
|
|
||||||
|
|
||||||
return render_template('pages/evaluations.html',
|
players_by_id = _users_by_id(row.player_id for row in aggregate)
|
||||||
evaluations=evaluations, player_scores=player_scores,
|
grouped = []
|
||||||
sort_column=sort_column, sort_order=sort_order)
|
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'])
|
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
||||||
@@ -133,92 +331,85 @@ def list_evaluations():
|
|||||||
def evaluate_player(tryout_id, player_id):
|
def evaluate_player(tryout_id, player_id):
|
||||||
"""Evaluate a specific player in a tryout."""
|
"""Evaluate a specific player in a tryout."""
|
||||||
if not current_user.can_evaluate():
|
if not current_user.can_evaluate():
|
||||||
flash('You do not have permission to evaluate players.', 'danger')
|
flash(_('You do not have permission to evaluate players.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
is_registered = TryoutRegistration.query.filter_by(
|
is_registered = (
|
||||||
tryout_id=tryout_id, player_id=player_id,
|
TryoutRegistration.query.filter_by(
|
||||||
).first() is not None
|
tryout_id=tryout_id,
|
||||||
|
player_id=player_id,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
flash('Player is not registered for this tryout.', 'danger')
|
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
player = User.query.get_or_404(player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
if not isinstance(player, Player):
|
if not isinstance(player, Player):
|
||||||
flash('Can only evaluate players.', 'danger')
|
flash(_('Can only evaluate players.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing_eval = Evaluation.query.filter_by(
|
existing_eval = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
|
tryout_id=tryout_id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=current_user.id,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if request.method == 'POST':
|
def render_evaluation_form():
|
||||||
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')
|
|
||||||
|
|
||||||
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')
|
|
||||||
else:
|
|
||||||
evaluation = Evaluation(
|
|
||||||
tryout_id=tryout_id, player_id=player_id,
|
|
||||||
evaluator_id=current_user.id,
|
|
||||||
mecanics_score=mecanics, cohesion_score=cohesion,
|
|
||||||
communication_score=communication, gamesense_score=gamesense,
|
|
||||||
versatility_score=versatility, discipline_score=discipline,
|
|
||||||
analysis_score=analysis, sport_ethics_score=sport_ethics,
|
|
||||||
mental_score=mental, overall_score=overall,
|
|
||||||
comments=comments, position_recommendation=position,
|
|
||||||
)
|
|
||||||
db.session.add(evaluation)
|
|
||||||
flash('Evaluation submitted successfully!', 'success')
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
evaluators = None
|
evaluators = None
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Admin):
|
||||||
all_evaluations = Evaluation.query.filter_by(
|
all_evaluations = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id,
|
tryout_id=tryout_id,
|
||||||
|
player_id=player_id,
|
||||||
).all()
|
).all()
|
||||||
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
|
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
|
||||||
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',
|
return render_template(
|
||||||
tryout=tryout, player=player,
|
'pages/evaluate_player.html',
|
||||||
|
tryout=tryout,
|
||||||
|
player=player,
|
||||||
existing_eval=existing_eval,
|
existing_eval=existing_eval,
|
||||||
evaluators=evaluators,
|
evaluators=evaluators,
|
||||||
game_positions=GAME_POSITIONS)
|
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')
|
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||||
@@ -226,24 +417,31 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
def players_to_evaluate(tryout_id):
|
def players_to_evaluate(tryout_id):
|
||||||
"""List players that need evaluation in a specific tryout."""
|
"""List players that need evaluation in a specific tryout."""
|
||||||
if not current_user.can_evaluate():
|
if not current_user.can_evaluate():
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||||
players = []
|
players_by_id = _users_by_id(reg.player_id for reg in registrations)
|
||||||
for reg in registrations:
|
evaluated_player_ids = {
|
||||||
p = User.query.get(reg.player_id)
|
player_id
|
||||||
if p and isinstance(p, Player):
|
for (player_id,) in db.session.query(Evaluation.player_id)
|
||||||
existing = Evaluation.query.filter_by(
|
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
|
||||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
.all()
|
||||||
).first()
|
}
|
||||||
players.append({'player': p, 'evaluated': existing is not None,
|
players = [
|
||||||
'registration': reg})
|
{
|
||||||
|
'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)
|
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||||
|
|
||||||
@@ -284,23 +482,31 @@ def batch_evaluate(tryout_id):
|
|||||||
player = User.query.get(pid)
|
player = User.query.get(pid)
|
||||||
if not player or not isinstance(player, Player):
|
if not player or not isinstance(player, Player):
|
||||||
continue
|
continue
|
||||||
is_registered = TryoutRegistration.query.filter_by(
|
is_registered = (
|
||||||
tryout_id=tryout_id, player_id=pid,
|
TryoutRegistration.query.filter_by(
|
||||||
).first() is not None
|
tryout_id=tryout_id,
|
||||||
|
player_id=pid,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
continue
|
continue
|
||||||
existing = Evaluation.query.filter_by(
|
existing = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
|
tryout_id=tryout_id,
|
||||||
|
player_id=pid,
|
||||||
|
evaluator_id=current_user.id,
|
||||||
).first()
|
).first()
|
||||||
existing_scores = {
|
existing_scores = {
|
||||||
field_name: getattr(existing, field_name) if existing else None
|
field_name: getattr(existing, field_name) if existing else None
|
||||||
for field_name, _ in EVALUATION_CRITERIA
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
}
|
}
|
||||||
players.append({
|
players.append(
|
||||||
|
{
|
||||||
'player': player,
|
'player': player,
|
||||||
'existing': existing,
|
'existing': existing,
|
||||||
'existing_scores': existing_scores,
|
'existing_scores': existing_scores,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
flash('No valid players selected for evaluation.', 'danger')
|
flash('No valid players selected for evaluation.', 'danger')
|
||||||
@@ -308,6 +514,7 @@ def batch_evaluate(tryout_id):
|
|||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
saved = 0
|
saved = 0
|
||||||
|
try:
|
||||||
for entry in players:
|
for entry in players:
|
||||||
pid = entry['player'].id
|
pid = entry['player'].id
|
||||||
scores = {
|
scores = {
|
||||||
@@ -322,18 +529,32 @@ def batch_evaluate(tryout_id):
|
|||||||
_apply_evaluation(existing, scores, comments, position)
|
_apply_evaluation(existing, scores, comments, position)
|
||||||
else:
|
else:
|
||||||
evaluation = Evaluation(
|
evaluation = Evaluation(
|
||||||
tryout_id=tryout_id, player_id=pid,
|
tryout_id=tryout_id,
|
||||||
|
player_id=pid,
|
||||||
evaluator_id=current_user.id,
|
evaluator_id=current_user.id,
|
||||||
)
|
)
|
||||||
_apply_evaluation(evaluation, scores, comments, position)
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
db.session.add(evaluation)
|
db.session.add(evaluation)
|
||||||
saved += 1
|
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()
|
db.session.commit()
|
||||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_template('pages/batch_evaluate.html',
|
return render_template(
|
||||||
tryout=tryout, players=players,
|
'pages/batch_evaluate.html',
|
||||||
|
tryout=tryout,
|
||||||
|
players=players,
|
||||||
evaluation_criteria=EVALUATION_CRITERIA,
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
game_positions=GAME_POSITIONS)
|
game_positions=GAME_POSITIONS,
|
||||||
|
)
|
||||||
|
|||||||
+152
-51
@@ -3,16 +3,29 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
from datetime import date
|
||||||
from flask_login import login_required, current_user
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin, Manager, Coach, Player, Scout,
|
Admin,
|
||||||
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
|
Coach,
|
||||||
Match, MatchParticipant, OrgTeam,
|
Evaluation,
|
||||||
|
Manager,
|
||||||
|
Match,
|
||||||
|
MatchParticipant,
|
||||||
|
Player,
|
||||||
|
Scout,
|
||||||
|
TeamMember,
|
||||||
|
Tryout,
|
||||||
|
TryoutRegistration,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from sqlalchemy import func
|
from app.permissions import coach_tryout_ids
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
main_bp = Blueprint('main', __name__)
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
@@ -23,6 +36,32 @@ def index():
|
|||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/lang/<locale>')
|
||||||
|
def set_language(locale):
|
||||||
|
"""Switch the interface language and return where the user came from.
|
||||||
|
|
||||||
|
Available to anonymous visitors too: the login page has to be readable
|
||||||
|
before anyone can sign in.
|
||||||
|
|
||||||
|
A GET link rather than a form: the only thing a forged request could
|
||||||
|
achieve is changing the visitor's own display language, which carries
|
||||||
|
no consequence worth a token. The redirect target is still validated —
|
||||||
|
an unchecked `Referer` would make this an open redirect.
|
||||||
|
"""
|
||||||
|
from app.i18n import set_locale
|
||||||
|
from app.routes.auth import is_safe_url
|
||||||
|
|
||||||
|
if not set_locale(locale):
|
||||||
|
flash(_('That language is not available.'), 'warning')
|
||||||
|
|
||||||
|
target = request.referrer
|
||||||
|
if target and is_safe_url(target):
|
||||||
|
return redirect(target)
|
||||||
|
return redirect(
|
||||||
|
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/dashboard')
|
@main_bp.route('/dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -43,46 +82,95 @@ def dashboard():
|
|||||||
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
||||||
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
||||||
today = date.today()
|
today = date.today()
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
stats['upcoming_matches'] = (
|
||||||
Match.status == 'scheduled', Match.date >= today,
|
Match.query.filter(
|
||||||
).order_by(Match.date, Match.start_time).limit(5).all()
|
Match.status == 'scheduled',
|
||||||
|
Match.date >= today,
|
||||||
|
)
|
||||||
|
.order_by(Match.date, Match.start_time)
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
elif isinstance(user, Manager):
|
elif isinstance(user, Manager):
|
||||||
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
||||||
stats['active_tryouts'] = Tryout.query.filter_by(
|
stats['active_tryouts'] = Tryout.query.filter_by(
|
||||||
created_by=user.id, status='in_progress').count()
|
created_by=user.id, status='in_progress'
|
||||||
|
).count()
|
||||||
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||||
stats['my_tryouts'] = Tryout.query.filter_by(
|
stats['my_tryouts'] = (
|
||||||
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||||
|
)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
stats['upcoming_matches'] = (
|
||||||
|
Match.query.filter(
|
||||||
Match.tryout_id.in_(manager_tryout_ids),
|
Match.tryout_id.in_(manager_tryout_ids),
|
||||||
Match.status == 'scheduled', Match.date >= today,
|
Match.status == 'scheduled',
|
||||||
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
|
Match.date >= today,
|
||||||
|
)
|
||||||
|
.order_by(Match.date, Match.start_time)
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
if manager_tryout_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
elif isinstance(user, Coach):
|
elif isinstance(user, Coach):
|
||||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||||
registrations = TryoutRegistration.query.filter(
|
|
||||||
TryoutRegistration.status.in_(['registered', 'attended'])).all()
|
# A count, computed as a count. This used to load every registration
|
||||||
registered_player_ids = [r.player_id for r in registrations]
|
# row in the club and every evaluation this coach had written, build
|
||||||
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
# two Python sets and subtract them — two full table reads to produce
|
||||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
# one integer (PERF-004).
|
||||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
already_evaluated = (
|
||||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
db.session.query(Evaluation.player_id)
|
||||||
|
.filter(
|
||||||
|
Evaluation.evaluator_id == user.id,
|
||||||
|
Evaluation.player_id == TryoutRegistration.player_id,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
stats['pending_evaluations'] = (
|
||||||
|
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
|
||||||
|
.filter(
|
||||||
|
TryoutRegistration.status.in_(['registered', 'attended']),
|
||||||
|
~already_evaluated,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
stats['my_recent_evaluations'] = (
|
||||||
|
Evaluation.query.filter_by(evaluator_id=user.id)
|
||||||
|
.order_by(Evaluation.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
# Was: the first team matching the legacy coach_id column, and only
|
||||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
# the tryouts targeting it. A coach attached by the many-to-many
|
||||||
target_org_team_id=org_team.id).all()] if org_team else []
|
# relationship, or coaching a second team, saw no upcoming match.
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
tryout_ids = coach_tryout_ids(user)
|
||||||
Match.tryout_id.in_(coach_tryout_ids),
|
stats['upcoming_matches'] = (
|
||||||
Match.status == 'scheduled', Match.date >= today,
|
Match.query.filter(
|
||||||
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
|
Match.tryout_id.in_(tryout_ids),
|
||||||
|
Match.status == 'scheduled',
|
||||||
|
Match.date >= today,
|
||||||
|
)
|
||||||
|
.order_by(Match.date, Match.start_time)
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
if tryout_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
elif isinstance(user, Player):
|
elif isinstance(user, Player):
|
||||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
||||||
stats['my_registrations'] = TryoutRegistration.query.filter_by(
|
stats['my_registrations'] = (
|
||||||
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
|
TryoutRegistration.query.filter_by(player_id=user.id)
|
||||||
|
.order_by(TryoutRegistration.registered_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
next_matches = []
|
next_matches = []
|
||||||
@@ -93,10 +181,15 @@ def dashboard():
|
|||||||
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
||||||
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
||||||
|
|
||||||
upcoming_matches = Match.query.filter(
|
upcoming_matches = (
|
||||||
|
Match.query.filter(
|
||||||
Match.tryout_id.in_(registered_tryout_ids),
|
Match.tryout_id.in_(registered_tryout_ids),
|
||||||
Match.status == 'scheduled', Match.date >= today,
|
Match.status == 'scheduled',
|
||||||
).order_by(Match.date, Match.start_time).all()
|
Match.date >= today,
|
||||||
|
)
|
||||||
|
.order_by(Match.date, Match.start_time)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
for match in upcoming_matches:
|
for match in upcoming_matches:
|
||||||
is_participant = False
|
is_participant = False
|
||||||
@@ -104,36 +197,44 @@ def dashboard():
|
|||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
if match.team1_id in player_team_ids:
|
if match.team1_id in player_team_ids:
|
||||||
is_participant = True
|
is_participant = True
|
||||||
team = next((tm for tm in player_team_memberships
|
team = next(
|
||||||
if tm.team_id == match.team1_id), None)
|
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
|
||||||
|
)
|
||||||
elif match.team2_id in player_team_ids:
|
elif match.team2_id in player_team_ids:
|
||||||
is_participant = True
|
is_participant = True
|
||||||
team = next((tm for tm in player_team_memberships
|
team = next(
|
||||||
if tm.team_id == match.team2_id), None)
|
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if match.id in player_match_ids:
|
if match.id in player_match_ids:
|
||||||
is_participant = True
|
is_participant = True
|
||||||
|
|
||||||
if is_participant:
|
if is_participant:
|
||||||
next_matches.append({
|
next_matches.append(
|
||||||
'tryout': match.tryout, 'match': match,
|
{
|
||||||
|
'tryout': match.tryout,
|
||||||
|
'match': match,
|
||||||
'team': team.team if team else None,
|
'team': team.team if team else None,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
stats['next_matches'] = next_matches
|
stats['next_matches'] = next_matches
|
||||||
|
|
||||||
elif isinstance(user, Scout):
|
elif isinstance(user, Scout):
|
||||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||||
stats['total_evaluations'] = Evaluation.query.count()
|
stats['total_evaluations'] = Evaluation.query.count()
|
||||||
stats['avg_scores'] = db.session.query(
|
top_rows = (
|
||||||
Evaluation.player_id,
|
db.session.query(
|
||||||
|
User,
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
).group_by(Evaluation.player_id).order_by(
|
)
|
||||||
func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
.join(Evaluation, Evaluation.player_id == User.id)
|
||||||
stats['top_players'] = []
|
.filter(User.role == 'player')
|
||||||
for row in stats['avg_scores']:
|
.group_by(User.id)
|
||||||
p = User.query.get(row.player_id)
|
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
|
||||||
if p:
|
.limit(5)
|
||||||
stats['top_players'].append((p, round(row.avg_score, 1)))
|
.all()
|
||||||
|
)
|
||||||
|
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
|
||||||
|
|
||||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||||
+568
-316
File diff suppressed because it is too large
Load Diff
+188
-157
@@ -3,31 +3,54 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
from flask import Blueprint, flash, jsonify, render_template, request, url_for
|
||||||
from flask_login import login_required, current_user
|
from flask_babel import gettext as _
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
|
from app.api import json_endpoint
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin, Manager, Coach, Player,
|
Admin,
|
||||||
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
AppSettings,
|
||||||
|
Coach,
|
||||||
|
Manager,
|
||||||
|
OrgTeam,
|
||||||
|
Player,
|
||||||
|
TeamMatch,
|
||||||
|
TeamMatchParticipant,
|
||||||
|
TeamPlayer,
|
||||||
)
|
)
|
||||||
from datetime import datetime, timedelta
|
from app.pagination import paginate
|
||||||
from app.discord_bot import send_schedule_notification
|
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||||
|
from app.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')
|
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||||
|
|
||||||
|
|
||||||
def can_manage_team_match(team):
|
def can_manage_team_match(team):
|
||||||
"""Check if current user can manage matches for this team."""
|
"""Whether the current user can manage matches for this team.
|
||||||
|
|
||||||
|
Same rule as administering the team itself, so it is the same call.
|
||||||
|
This function used to restate it, and the restatement drifted.
|
||||||
|
"""
|
||||||
|
return can_manage_org_team(current_user, team)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
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 False
|
||||||
|
return not AppSettings.get_bool('season_active', default=False)
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('')
|
@team_matches_bp.route('')
|
||||||
@@ -36,29 +59,21 @@ def list_matches():
|
|||||||
"""List all team matches visible to the current user."""
|
"""List all team matches visible to the current user."""
|
||||||
filter_team_id = request.args.get('team_id', type=int)
|
filter_team_id = request.args.get('team_id', type=int)
|
||||||
|
|
||||||
if isinstance(current_user, Admin):
|
# A manager administers every team, so the listing shows them all;
|
||||||
|
# visible_org_teams() only reports the teams they are attached to.
|
||||||
|
if isinstance(current_user, (Admin, Manager)):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||||
matches_query = TeamMatch.query
|
matches_query = TeamMatch.query
|
||||||
elif isinstance(current_user, Manager):
|
elif isinstance(current_user, (Coach, Player)):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
teams = visible_org_teams(current_user)
|
||||||
matches_query = TeamMatch.query
|
|
||||||
elif isinstance(current_user, Coach):
|
|
||||||
teams = OrgTeam.query.filter(
|
|
||||||
db.or_(
|
|
||||||
OrgTeam.coaches.any(id=current_user.id),
|
|
||||||
OrgTeam.coach_id == current_user.id,
|
|
||||||
)
|
|
||||||
).order_by(OrgTeam.name).all()
|
|
||||||
team_ids = [t.id for t in teams]
|
team_ids = [t.id for t in teams]
|
||||||
matches_query = TeamMatch.query.filter(
|
matches_query = (
|
||||||
|
TeamMatch.query.filter(
|
||||||
TeamMatch.org_team_id.in_(team_ids),
|
TeamMatch.org_team_id.in_(team_ids),
|
||||||
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
)
|
||||||
elif isinstance(current_user, Player):
|
if team_ids
|
||||||
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
|
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
teams = []
|
teams = []
|
||||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||||
@@ -66,42 +81,63 @@ def list_matches():
|
|||||||
if filter_team_id:
|
if filter_team_id:
|
||||||
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
||||||
|
|
||||||
matches = matches_query.order_by(TeamMatch.date.desc()).all()
|
# Pagination also bounds the per-match participant loop below, which is
|
||||||
|
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
|
||||||
|
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
|
||||||
|
matches = matches_page.items
|
||||||
|
|
||||||
match_data = []
|
match_data = []
|
||||||
for tm in matches:
|
for tm in matches:
|
||||||
confirmed, total = tm.get_confirmed_count()
|
confirmed, total = tm.get_confirmed_count()
|
||||||
participants = []
|
participants = []
|
||||||
for p in tm.participants.all():
|
for p in tm.participants.all():
|
||||||
participants.append({
|
participants.append(
|
||||||
'id': p.id, 'player': p.player,
|
{
|
||||||
|
'id': p.id,
|
||||||
|
'player': p.player,
|
||||||
'is_confirmed': p.is_confirmed,
|
'is_confirmed': p.is_confirmed,
|
||||||
})
|
}
|
||||||
match_data.append({
|
)
|
||||||
'match': tm, 'participants': participants,
|
match_data.append(
|
||||||
'confirmed_count': confirmed, 'total_count': total,
|
{
|
||||||
})
|
'match': tm,
|
||||||
|
'participants': participants,
|
||||||
|
'confirmed_count': confirmed,
|
||||||
|
'total_count': total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return render_template('pages/team_matches.html',
|
return render_template(
|
||||||
teams=teams, match_data=match_data,
|
'pages/team_matches.html',
|
||||||
now=datetime.utcnow())
|
teams=teams,
|
||||||
|
match_data=match_data,
|
||||||
|
pagination=matches_page,
|
||||||
|
now=utc_now_naive(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_match(team_id):
|
def create_match(team_id):
|
||||||
"""Create a new regular-season team match."""
|
"""Create a new regular-season team match."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not can_manage_team_match(team):
|
if season_locked():
|
||||||
flash('You do not have permission to schedule matches for this team.', 'danger')
|
flash(
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
'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', '')
|
prefill_date = request.args.get('date', '')
|
||||||
is_practice = request.args.get('type') == 'practice'
|
is_practice = request.args.get('type') == 'practice'
|
||||||
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
||||||
|
|
||||||
if is_practice and request.method == 'GET':
|
if is_practice and request.method == 'GET':
|
||||||
|
|
||||||
class TryoutProxy:
|
class TryoutProxy:
|
||||||
def __init__(self, team_obj):
|
def __init__(self, team_obj):
|
||||||
self.id = 0
|
self.id = 0
|
||||||
@@ -113,56 +149,49 @@ def create_match(team_id):
|
|||||||
proxy_tryout = TryoutProxy(team)
|
proxy_tryout = TryoutProxy(team)
|
||||||
all_players = [tp.player for tp in team_players if tp.player]
|
all_players = [tp.player for tp in team_players if tp.player]
|
||||||
|
|
||||||
return render_template('pages/match_form.html',
|
return render_template(
|
||||||
tryout=proxy_tryout, teams=[], all_players=all_players,
|
'pages/match_form.html',
|
||||||
prefill_date=prefill_date, is_practice=True,
|
tryout=proxy_tryout,
|
||||||
team_id=team_id, team=team)
|
teams=[],
|
||||||
|
all_players=all_players,
|
||||||
|
prefill_date=prefill_date,
|
||||||
|
is_practice=True,
|
||||||
|
team_id=team_id,
|
||||||
|
team=team,
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
title = request.form.get('title', default_title)
|
payload = form_payload(list_fields=(), optional_blank=())
|
||||||
opponent = request.form.get('opponent', '').strip() if not is_practice else None
|
# A practice has no opponent, whatever the form sent.
|
||||||
description = request.form.get('description', '')
|
payload.setdefault('title', default_title)
|
||||||
date_str = request.form.get('date')
|
if is_practice:
|
||||||
start_time_str = request.form.get('start_time')
|
payload.pop('opponent', None)
|
||||||
end_time_str = request.form.get('end_time')
|
|
||||||
location = request.form.get('location', '')
|
|
||||||
|
|
||||||
if not date_str:
|
|
||||||
flash('Date is required.', 'danger')
|
|
||||||
return render_template('pages/team_match_form.html', team=team,
|
|
||||||
team_players=team_players, prefill_date=prefill_date)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
data = TeamMatchSchema().load(payload)
|
||||||
except (ValueError, TypeError):
|
except ValidationError as err:
|
||||||
flash('Invalid date format.', 'danger')
|
flash_validation_errors(err)
|
||||||
return render_template('pages/team_match_form.html', team=team,
|
return render_template(
|
||||||
team_players=team_players, prefill_date=prefill_date,
|
'pages/team_match_form.html',
|
||||||
is_practice=is_practice)
|
team=team,
|
||||||
|
team_players=team_players,
|
||||||
|
prefill_date=prefill_date,
|
||||||
|
is_practice=is_practice,
|
||||||
|
)
|
||||||
|
|
||||||
start_time = None
|
start_time = data['start_time']
|
||||||
end_time = None
|
end_time = data['end_time'] or default_end_time(data['date'], start_time)
|
||||||
if start_time_str:
|
|
||||||
try:
|
|
||||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
|
||||||
if end_time_str:
|
|
||||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
|
||||||
else:
|
|
||||||
start_dt = datetime.combine(date_obj, start_time)
|
|
||||||
end_dt = start_dt + timedelta(minutes=30)
|
|
||||||
end_time = end_dt.time()
|
|
||||||
except ValueError:
|
|
||||||
flash('Invalid time format.', 'danger')
|
|
||||||
return render_template('pages/team_match_form.html', team=team,
|
|
||||||
team_players=team_players, prefill_date=prefill_date,
|
|
||||||
is_practice=is_practice)
|
|
||||||
|
|
||||||
team_match = TeamMatch(
|
team_match = TeamMatch(
|
||||||
org_team_id=team_id, title=title,
|
org_team_id=team_id,
|
||||||
description=description or None,
|
title=data['title'],
|
||||||
opponent=opponent or None,
|
description=data['description'],
|
||||||
date=date_obj, start_time=start_time, end_time=end_time,
|
opponent=data['opponent'],
|
||||||
location=location or None, created_by=current_user.id,
|
date=data['date'],
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
location=data['location'],
|
||||||
|
created_by=current_user.id,
|
||||||
)
|
)
|
||||||
db.session.add(team_match)
|
db.session.add(team_match)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
@@ -170,7 +199,8 @@ def create_match(team_id):
|
|||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
for tp in team_players:
|
for tp in team_players:
|
||||||
participant = TeamMatchParticipant(
|
participant = TeamMatchParticipant(
|
||||||
team_match_id=team_match.id, player_id=tp.player_id,
|
team_match_id=team_match.id,
|
||||||
|
player_id=tp.player_id,
|
||||||
)
|
)
|
||||||
db.session.add(participant)
|
db.session.add(participant)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
@@ -178,21 +208,21 @@ def create_match(team_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Discord notifications
|
notify_participants(
|
||||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
title=team_match.title,
|
||||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
date=team_match.date,
|
||||||
|
start_time=start_time,
|
||||||
for i, tp in enumerate(team_players):
|
end_time=end_time,
|
||||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
|
participants=zip_participants(
|
||||||
send_schedule_notification(
|
[tp.player_id for tp in team_players], notified_participant_ids
|
||||||
user_id=tp.player_id, event_type='match',
|
),
|
||||||
event_title=team_match.title,
|
fallback_id=team_match.id,
|
||||||
event_date=event_date_str, event_time=event_time_str,
|
|
||||||
reference_id=reference_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flash(f'Team match "{title}" scheduled successfully!', 'success')
|
flash(
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
_('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)
|
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||||
|
|
||||||
@@ -201,69 +231,72 @@ def create_match(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_match(match_id):
|
def edit_match(match_id):
|
||||||
"""Edit an existing team match."""
|
"""Edit an existing team match."""
|
||||||
team_match = TeamMatch.query.get_or_404(match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
flash('You do not have permission to edit this match.', 'danger')
|
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
team_match.title = request.form.get('title', team_match.title)
|
# The three date and time fields used to be checked one at a time,
|
||||||
team_match.description = request.form.get('description', '') or None
|
# each flashing and redirecting on its own: a form with two mistakes
|
||||||
team_match.opponent = request.form.get('opponent', '').strip() or None
|
# took two round trips to be told about both. One schema now, every
|
||||||
|
# problem reported at once and in place.
|
||||||
date_str = request.form.get('date')
|
#
|
||||||
if date_str:
|
# 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:
|
try:
|
||||||
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
|
||||||
except (ValueError, TypeError):
|
except ValidationError as err:
|
||||||
flash('Invalid date format.', 'danger')
|
flash_validation_errors(err)
|
||||||
return redirect(url_for('team_matches.edit_match', match_id=match_id))
|
return render_template(
|
||||||
|
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||||
|
)
|
||||||
|
|
||||||
start_time_str = request.form.get('start_time')
|
team_match.title = data['title']
|
||||||
if start_time_str:
|
team_match.description = data['description']
|
||||||
try:
|
team_match.opponent = data['opponent']
|
||||||
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
team_match.date = data['date']
|
||||||
except ValueError:
|
team_match.start_time = data['start_time']
|
||||||
pass
|
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||||
|
team_match.location = data['location']
|
||||||
end_time_str = request.form.get('end_time')
|
team_match.status = data['status']
|
||||||
if end_time_str:
|
|
||||||
try:
|
|
||||||
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
team_match.location = request.form.get('location', '') or None
|
|
||||||
status = request.form.get('status')
|
|
||||||
if status in ['scheduled', 'completed', 'cancelled']:
|
|
||||||
team_match.status = status
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Match updated successfully!', 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
return render_template('pages/team_match_form.html',
|
return render_template(
|
||||||
match=team_match, team=team, team_players=[])
|
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_match(match_id):
|
def delete_match(match_id):
|
||||||
"""Delete a team match."""
|
"""Delete a team match."""
|
||||||
team_match = TeamMatch.query.get_or_404(match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
|
if 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):
|
if not can_manage_team_match(team):
|
||||||
flash('You do not have permission to delete this match.', 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
db.session.delete(team_match)
|
db.session.delete(team_match)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Match deleted successfully.', 'success')
|
flash(_('Match deleted successfully.'), 'success')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/api/manageable-teams')
|
@team_matches_bp.route('/api/manageable-teams')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def api_manageable_teams():
|
def api_manageable_teams():
|
||||||
"""API endpoint returning teams the current user can schedule matches for."""
|
"""API endpoint returning teams the current user can schedule matches for."""
|
||||||
@@ -273,12 +306,7 @@ def api_manageable_teams():
|
|||||||
if isinstance(current_user, (Admin, Manager)):
|
if isinstance(current_user, (Admin, Manager)):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||||
elif isinstance(current_user, Coach):
|
elif isinstance(current_user, Coach):
|
||||||
teams = OrgTeam.query.filter(
|
teams = coach_org_teams(current_user)
|
||||||
db.or_(
|
|
||||||
OrgTeam.coaches.any(id=current_user.id),
|
|
||||||
OrgTeam.coach_id == current_user.id,
|
|
||||||
)
|
|
||||||
).order_by(OrgTeam.name).all()
|
|
||||||
else:
|
else:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
@@ -286,13 +314,14 @@ def api_manageable_teams():
|
|||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle is_confirmed for a team match participant."""
|
"""Toggle is_confirmed for a team match participant."""
|
||||||
team_match = TeamMatch.query.get_or_404(match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
|
|
||||||
participant = TeamMatchParticipant.query.get_or_404(participant_id)
|
participant = db.get_or_404(TeamMatchParticipant, participant_id)
|
||||||
if participant.team_match_id != match_id:
|
if participant.team_match_id != match_id:
|
||||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||||
|
|
||||||
@@ -302,8 +331,10 @@ def toggle_presence(match_id, participant_id):
|
|||||||
|
|
||||||
participant.is_confirmed = not participant.is_confirmed
|
participant.is_confirmed = not participant.is_confirmed
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
'participant_id': participant.id,
|
'participant_id': participant.id,
|
||||||
'is_confirmed': participant.is_confirmed,
|
'is_confirmed': participant.is_confirmed,
|
||||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|||||||
+382
-217
@@ -3,15 +3,33 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
|
||||||
from flask_login import login_required, current_user
|
from flask_babel import gettext as _
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
|
from app.api import json_endpoint
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin, Manager, Coach, Player,
|
Admin,
|
||||||
OrgTeam, User, Team, TeamMember,
|
Coach,
|
||||||
PersonalNote, TeamNote, Tryout, TeamPlayer,
|
Contract,
|
||||||
|
Manager,
|
||||||
|
OneOnOneRequest,
|
||||||
|
OrgTeam,
|
||||||
|
PersonalNote,
|
||||||
|
Player,
|
||||||
|
TeamMatch,
|
||||||
|
TeamNote,
|
||||||
|
TeamPlayer,
|
||||||
|
Tryout,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from app.permissions import visible_org_teams
|
||||||
|
from app.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')
|
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||||
|
|
||||||
@@ -22,34 +40,35 @@ def list_teams():
|
|||||||
"""List all organization teams visible to the current user."""
|
"""List all organization teams visible to the current user."""
|
||||||
can_manage = current_user.can_manage_teams()
|
can_manage = current_user.can_manage_teams()
|
||||||
|
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Player):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||||
elif isinstance(current_user, Coach):
|
|
||||||
teams = OrgTeam.query.filter(
|
|
||||||
db.or_(
|
|
||||||
OrgTeam.coaches.any(id=current_user.id),
|
|
||||||
OrgTeam.coach_id == current_user.id,
|
|
||||||
)
|
|
||||||
).order_by(OrgTeam.name).all()
|
|
||||||
elif isinstance(current_user, Manager):
|
|
||||||
teams = OrgTeam.query.filter(
|
|
||||||
db.or_(
|
|
||||||
OrgTeam.managers.any(id=current_user.id),
|
|
||||||
OrgTeam.manager_id == current_user.id,
|
|
||||||
)
|
|
||||||
).order_by(OrgTeam.name).all()
|
|
||||||
elif isinstance(current_user, Player):
|
|
||||||
flash('Use My Team(s) to view your teams.', 'info')
|
|
||||||
return redirect(url_for('teams.my_teams'))
|
return redirect(url_for('teams.my_teams'))
|
||||||
else:
|
if not isinstance(current_user, (Admin, Coach, Manager)):
|
||||||
flash('You do not have permission to view teams.', 'danger')
|
flash(_('You do not have permission to view teams.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
teams = visible_org_teams(current_user)
|
||||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
|
||||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
coaches = (
|
||||||
return render_template('pages/teams.html', teams=teams, coaches=coaches,
|
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||||
managers=managers, all_players=all_players, can_manage=can_manage)
|
)
|
||||||
|
managers = (
|
||||||
|
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||||
|
)
|
||||||
|
# is_active_account, like the two queries above it. Without it the "add
|
||||||
|
# player" select offered accounts that had been deactivated, and
|
||||||
|
# add_player accepted them.
|
||||||
|
all_players = (
|
||||||
|
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
'pages/teams.html',
|
||||||
|
teams=teams,
|
||||||
|
coaches=coaches,
|
||||||
|
managers=managers,
|
||||||
|
all_players=all_players,
|
||||||
|
can_manage=can_manage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/my-teams')
|
@teams_bp.route('/my-teams')
|
||||||
@@ -57,247 +76,363 @@ def list_teams():
|
|||||||
def my_teams():
|
def my_teams():
|
||||||
"""View the player's own teams with upcoming matches."""
|
"""View the player's own teams with upcoming matches."""
|
||||||
if not isinstance(current_user, Player):
|
if not isinstance(current_user, Player):
|
||||||
flash('This page is for players.', 'info')
|
flash(_('This page is for players.'), 'info')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
from app.models import TeamMatch, TeamMatchParticipant
|
from app.models import TeamMatch, TeamMatchParticipant
|
||||||
|
|
||||||
player_teams = current_user.get_org_teams()
|
player_teams = current_user.get_org_teams()
|
||||||
now = datetime.utcnow()
|
now = utc_now_naive()
|
||||||
team_data = []
|
team_data = []
|
||||||
|
|
||||||
for org_team in player_teams:
|
for org_team in player_teams:
|
||||||
matches = TeamMatch.query.filter(
|
matches = (
|
||||||
|
TeamMatch.query.filter(
|
||||||
TeamMatch.org_team_id == org_team.id,
|
TeamMatch.org_team_id == org_team.id,
|
||||||
TeamMatch.status == 'scheduled',
|
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 = []
|
matches_data = []
|
||||||
for tm in matches:
|
for tm in matches:
|
||||||
confirmed, total = tm.get_confirmed_count()
|
confirmed, total = tm.get_confirmed_count()
|
||||||
participant = TeamMatchParticipant.query.filter_by(
|
participant = TeamMatchParticipant.query.filter_by(
|
||||||
team_match_id=tm.id, player_id=current_user.id,
|
team_match_id=tm.id,
|
||||||
|
player_id=current_user.id,
|
||||||
).first()
|
).first()
|
||||||
matches_data.append({
|
matches_data.append(
|
||||||
|
{
|
||||||
'match': tm,
|
'match': tm,
|
||||||
'participant_id': participant.id if participant else None,
|
'participant_id': participant.id if participant else None,
|
||||||
'is_confirmed': participant.is_confirmed if participant else False,
|
'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_data.append(
|
||||||
'team': org_team, 'matches': matches_data,
|
{
|
||||||
|
'team': org_team,
|
||||||
|
'matches': matches_data,
|
||||||
'coaches': org_team.get_coaches(),
|
'coaches': org_team.get_coaches(),
|
||||||
'managers': org_team.get_managers(),
|
'managers': org_team.get_managers(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def _posted(schema):
|
||||||
|
"""Load a form through `schema`, or None when it will not load.
|
||||||
|
|
||||||
|
The five assignment routes below each answer a bad field with their own
|
||||||
|
flash and a redirect to the same page, so a shared "it did not validate"
|
||||||
|
return is enough; the field-level message is flashed on the way out.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return schema.load(form_payload())
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _assignable(user, expected_class):
|
||||||
|
"""Whether this account may be given a role on a team.
|
||||||
|
|
||||||
|
Deactivated accounts were offered by the selects and accepted by the
|
||||||
|
routes. `is_active_account` is what stops someone logging in — a person
|
||||||
|
who has left the club — so putting them on a roster contradicts the one
|
||||||
|
control that says they are gone. The listings filtered it for coaches and
|
||||||
|
managers and not for players, two lines apart, which is how it went
|
||||||
|
unnoticed.
|
||||||
|
"""
|
||||||
|
return isinstance(user, expected_class) and bool(user.is_active_account)
|
||||||
|
|
||||||
|
|
||||||
|
def _staff_member(user_id, expected_class):
|
||||||
|
"""The user behind an id, only if they may hold the role being assigned.
|
||||||
|
|
||||||
|
Returns None for a missing id, an unknown id, an account of the wrong
|
||||||
|
role, or a deactivated one. The role check is the point (SEC-16): the id
|
||||||
|
comes from a `<select>` the browser rendered, so it is a value the client
|
||||||
|
chooses, and nothing checked it in two of the three places that used it.
|
||||||
|
A forged submission could therefore list a player among a team's coaches
|
||||||
|
— the same defect wave G fixed in `tryouts.py`, left standing here.
|
||||||
|
|
||||||
|
Defers to `_assignable` rather than repeating `isinstance`: two functions
|
||||||
|
in one file answering "may this account take this role" differently is
|
||||||
|
the shape of every defect this module has had.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Already an int or None, thanks to the schema.
|
||||||
|
expected_class: Coach or Manager.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User | None: The account, when it may take the role.
|
||||||
|
"""
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
return user if user and _assignable(user, expected_class) else None
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/create', methods=['POST'])
|
@teams_bp.route('/create', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_team():
|
def create_team():
|
||||||
"""Create a new organization team."""
|
"""Create a new organization team."""
|
||||||
if not current_user.can_manage_teams():
|
if not current_user.can_manage_teams():
|
||||||
flash('You do not have permission to create teams.', 'danger')
|
flash(_('You do not have permission to create teams.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
name = request.form.get('name')
|
try:
|
||||||
coach_id = request.form.get('coach_id')
|
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||||
manager_id = request.form.get('manager_id')
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if not name:
|
name = data['name']
|
||||||
flash('Team name is required.', 'danger')
|
if OrgTeam.query.filter_by(name=name).first():
|
||||||
return redirect(url_for('teams.list_teams'))
|
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()
|
coach = _staff_member(data['coach_id'], Coach)
|
||||||
if existing:
|
manager = _staff_member(data['manager_id'], Manager)
|
||||||
flash(f'Team "{name}" already exists.', 'danger')
|
|
||||||
return redirect(url_for('teams.list_teams'))
|
|
||||||
|
|
||||||
team = OrgTeam(
|
team = OrgTeam(
|
||||||
name=name,
|
name=name,
|
||||||
coach_id=int(coach_id) if coach_id else None,
|
coach_id=coach.id if coach else None,
|
||||||
manager_id=int(manager_id) if manager_id else None,
|
manager_id=manager.id if manager else None,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
)
|
)
|
||||||
db.session.add(team)
|
db.session.add(team)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
if coach_id:
|
if coach:
|
||||||
coach_user = User.query.get(int(coach_id))
|
team.coaches.append(coach)
|
||||||
if coach_user:
|
if manager:
|
||||||
team.coaches.append(coach_user)
|
team.managers.append(manager)
|
||||||
if manager_id:
|
|
||||||
manager_user = User.query.get(int(manager_id))
|
|
||||||
if manager_user:
|
|
||||||
team.managers.append(manager_user)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Team "{name}" created successfully!', 'success')
|
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_team(team_id):
|
def edit_team(team_id):
|
||||||
"""Edit an existing organization team."""
|
"""Edit an existing organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('You do not have permission to edit this team.', 'danger')
|
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
name = request.form.get('name')
|
try:
|
||||||
coach_id = request.form.get('coach_id')
|
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||||
manager_id = request.form.get('manager_id')
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if not name:
|
name = data['name']
|
||||||
flash('Team name is required.', 'danger')
|
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
|
||||||
return redirect(url_for('teams.list_teams'))
|
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()
|
team.name = name
|
||||||
if existing:
|
|
||||||
flash(f'Team "{name}" already exists.', 'danger')
|
|
||||||
return redirect(url_for('teams.list_teams'))
|
|
||||||
|
|
||||||
if request.form.get('sync_staff') == '1':
|
if data['sync_staff'] == '1':
|
||||||
coach_ids = request.form.getlist('coach_ids')
|
team.coaches = [
|
||||||
manager_ids = request.form.getlist('manager_ids')
|
user for user in (_staff_member(cid, Coach) for cid in data['coach_ids']) if user
|
||||||
|
]
|
||||||
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)
|
|
||||||
coach_list = team.coaches.all()
|
coach_list = team.coaches.all()
|
||||||
team.coach_id = coach_list[0].id if coach_list else None
|
team.coach_id = coach_list[0].id if coach_list else None
|
||||||
|
|
||||||
team.managers = []
|
team.managers = [
|
||||||
for mid in manager_ids:
|
user for user in (_staff_member(mid, Manager) for mid in data['manager_ids']) if user
|
||||||
if mid and mid.strip():
|
]
|
||||||
manager_user = User.query.get(int(mid))
|
|
||||||
if manager_user and isinstance(manager_user, Manager):
|
|
||||||
team.managers.append(manager_user)
|
|
||||||
manager_list = team.managers.all()
|
manager_list = team.managers.all()
|
||||||
team.manager_id = manager_list[0].id if manager_list else None
|
team.manager_id = manager_list[0].id if manager_list else None
|
||||||
else:
|
else:
|
||||||
team.coach_id = int(coach_id) if coach_id else None
|
# This branch never checked the role, while the one above did — the
|
||||||
team.manager_id = int(manager_id) if manager_id else None
|
# same file disagreeing with itself (SEC-16). _staff_member is the
|
||||||
|
# single answer now.
|
||||||
|
coach = _staff_member(data['coach_id'], Coach)
|
||||||
|
manager = _staff_member(data['manager_id'], Manager)
|
||||||
|
|
||||||
if coach_id:
|
team.coach_id = coach.id if coach else None
|
||||||
coach_user = User.query.get(int(coach_id))
|
team.manager_id = manager.id if manager else None
|
||||||
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
|
|
||||||
team.coaches.append(coach_user)
|
if coach and not team.coaches.filter_by(id=coach.id).first():
|
||||||
if manager_id:
|
team.coaches.append(coach)
|
||||||
manager_user = User.query.get(int(manager_id))
|
if manager and not team.managers.filter_by(id=manager.id).first():
|
||||||
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
|
team.managers.append(manager)
|
||||||
team.managers.append(manager_user)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Team "{name}" updated successfully!', 'success')
|
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_team(team_id):
|
def delete_team(team_id):
|
||||||
"""Delete an organization team."""
|
"""Delete an organization team.
|
||||||
if not current_user.can_manage_teams():
|
|
||||||
flash('You do not have permission to delete teams.', 'danger')
|
Two checks, not one, and not the one the audit recommended (SEC-AUTHZ-006).
|
||||||
return redirect(url_for('teams.list_teams'))
|
|
||||||
|
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
|
name = team.name
|
||||||
|
|
||||||
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
|
# One transaction. This used to commit three times, so a failure at the
|
||||||
for t in tryouts:
|
# third step left the tryouts detached and the players removed without
|
||||||
t.target_org_team_id = None
|
# the team being deleted — an inconsistent state nothing could undo.
|
||||||
db.session.commit()
|
#
|
||||||
|
# TeamNote.org_team_id and TeamMatch.org_team_id are NOT NULL, and were
|
||||||
|
# not handled at all: deleting a team that had ever been used raised
|
||||||
|
# IntegrityError. Contract.team_id and OneOnOneRequest.org_team_id are
|
||||||
|
# nullable, and the rows outlive the team, so they are only detached.
|
||||||
|
|
||||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
|
# Entities that only make sense as part of the team.
|
||||||
db.session.commit()
|
TeamNote.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
||||||
|
for team_match in TeamMatch.query.filter_by(org_team_id=team_id).all():
|
||||||
|
db.session.delete(team_match) # participants follow by cascade
|
||||||
|
TeamPlayer.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Entities that survive it.
|
||||||
|
Tryout.query.filter_by(target_org_team_id=team_id).update(
|
||||||
|
{'target_org_team_id': None}, synchronize_session=False
|
||||||
|
)
|
||||||
|
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
|
||||||
|
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
|
||||||
|
{'org_team_id': None}, synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
db.session.delete(team)
|
db.session.delete(team)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Team "{name}" deleted successfully.', 'success')
|
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_coach(team_id):
|
def add_coach(team_id):
|
||||||
"""Add a coach to an organization team."""
|
"""Add a coach to an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
coach_id = request.form.get('coach_id')
|
data = _posted(TeamStaffSchema())
|
||||||
if not coach_id:
|
if data is None:
|
||||||
flash('Please select a coach.', 'danger')
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
return redirect(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))
|
coach = db.session.get(User, data['coach_id'])
|
||||||
if not isinstance(coach, Coach):
|
if not coach or not _assignable(coach, Coach):
|
||||||
flash('Only coaches can be assigned as coach.', 'danger')
|
flash(_('Only coaches can be assigned as coach.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if team.coaches.filter_by(id=coach.id).first():
|
if team.coaches.filter_by(id=coach.id).first():
|
||||||
flash(f'{coach.username} is already a coach of {team.name}.', 'info')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_(
|
||||||
|
'%(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)
|
team.coaches.append(coach)
|
||||||
if not team.coach_id:
|
if not team.coach_id:
|
||||||
team.coach_id = coach.id
|
team.coach_id = coach.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'{coach.username} added as coach of {team.name}.', 'success')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_('%(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'])
|
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_manager(team_id):
|
def add_manager(team_id):
|
||||||
"""Add a manager to an organization team."""
|
"""Add a manager to an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
manager_id = request.form.get('manager_id')
|
data = _posted(TeamStaffSchema())
|
||||||
if not manager_id:
|
if data is None:
|
||||||
flash('Please select a manager.', 'danger')
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
return redirect(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))
|
manager = db.session.get(User, data['manager_id'])
|
||||||
if not isinstance(manager, Manager):
|
if not manager or not _assignable(manager, Manager):
|
||||||
flash('Only managers can be assigned as manager.', 'danger')
|
flash(_('Only managers can be assigned as manager.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if team.managers.filter_by(id=manager.id).first():
|
if team.managers.filter_by(id=manager.id).first():
|
||||||
flash(f'{manager.username} is already a manager of {team.name}.', 'info')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_(
|
||||||
|
'%(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)
|
team.managers.append(manager)
|
||||||
if not team.manager_id:
|
if not team.manager_id:
|
||||||
team.manager_id = manager.id
|
team.manager_id = manager.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'{manager.username} added as manager of {team.name}.', 'success')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_('%(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'])
|
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_coach(team_id):
|
def remove_coach(team_id):
|
||||||
"""Remove a coach from an organization team."""
|
"""Remove a coach from an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
coach_id = request.form.get('coach_id')
|
data = _posted(TeamStaffSchema())
|
||||||
if coach_id:
|
if data is None:
|
||||||
coach = User.query.get(int(coach_id))
|
return 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():
|
if coach and team.coaches.filter_by(id=coach.id).first():
|
||||||
team.coaches.remove(coach)
|
team.coaches.remove(coach)
|
||||||
if team.coach_id == coach.id:
|
if team.coach_id == coach.id:
|
||||||
@@ -307,22 +442,25 @@ def remove_coach(team_id):
|
|||||||
team.coach_id = None
|
team.coach_id = None
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Coach removed from {team.name}.', 'success')
|
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_manager(team_id):
|
def remove_manager(team_id):
|
||||||
"""Remove a manager from an organization team."""
|
"""Remove a manager from an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
manager_id = request.form.get('manager_id')
|
data = _posted(TeamStaffSchema())
|
||||||
if manager_id:
|
if data is None:
|
||||||
manager = User.query.get(int(manager_id))
|
return 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():
|
if manager and team.managers.filter_by(id=manager.id).first():
|
||||||
team.managers.remove(manager)
|
team.managers.remove(manager)
|
||||||
if team.manager_id == manager.id:
|
if team.manager_id == manager.id:
|
||||||
@@ -332,68 +470,80 @@ def remove_manager(team_id):
|
|||||||
team.manager_id = None
|
team.manager_id = None
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Manager removed from {team.name}.', 'success')
|
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_player(team_id):
|
def add_player(team_id):
|
||||||
"""Add a player to an organization team."""
|
"""Add a player to an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
player_id = request.form.get('player_id')
|
data = _posted(TeamPlayerSchema())
|
||||||
status = request.form.get('status', 'starter')
|
if data is None:
|
||||||
if not player_id:
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
flash('Please select a player.', 'danger')
|
if not data['player_id']:
|
||||||
return redirect(url_for('teams.list_teams'))
|
flash(_('Please select a player.'), 'danger')
|
||||||
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
player = User.query.get_or_404(int(player_id))
|
status = data['status']
|
||||||
if not isinstance(player, Player):
|
player = db.session.get(User, data['player_id'])
|
||||||
flash('Can only assign players to teams.', 'danger')
|
if not player or not _assignable(player, Player):
|
||||||
return redirect(url_for('teams.list_teams'))
|
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()
|
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||||
if existing:
|
if existing:
|
||||||
flash(f'{player.username} is already on {team.name}.', 'info')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_('%(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)
|
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||||
db.session.add(tp)
|
db.session.add(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'{player.username} added to {team.name}!', 'success')
|
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_player(team_id, player_id):
|
def remove_player(team_id, player_id):
|
||||||
"""Remove a player from an organization team."""
|
"""Remove a player from an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('Permission denied.', 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return 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()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
if not tp:
|
if not tp:
|
||||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_('%(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.delete(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'{player.username} removed from {team.name}.', 'success')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_('%(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'])
|
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_player_status(team_id, player_id):
|
def toggle_player_status(team_id, player_id):
|
||||||
"""Toggle a player's status between starter and substitute."""
|
"""Toggle a player's status between starter and substitute."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
return jsonify({'error': 'Permission denied'}), 403
|
return jsonify({'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
@@ -403,53 +553,68 @@ def toggle_player_status(team_id, player_id):
|
|||||||
|
|
||||||
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
|
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({
|
return jsonify(
|
||||||
'success': True, 'player_id': player_id,
|
{
|
||||||
'new_status': tp.status, 'player_name': tp.player.username,
|
'success': True,
|
||||||
})
|
'player_id': player_id,
|
||||||
|
'new_status': tp.status,
|
||||||
|
'player_name': tp.player.username,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_team_note(team_id):
|
def add_team_note(team_id):
|
||||||
"""Add a team improvement note (coaches only)."""
|
"""Add a team improvement note (coaches only)."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('You do not have permission to add notes to this team.', 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
except ValidationError as err:
|
||||||
|
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.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Team notes added successfully!', 'success')
|
flash(_('Team notes added successfully!'), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_player_note(team_id, player_id):
|
def add_player_note(team_id, player_id):
|
||||||
"""Add a personal note for a player (coaches only)."""
|
"""Add a personal note for a player (coaches only)."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash('You do not have permission to add notes to this team.', 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return 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):
|
if not isinstance(player, Player):
|
||||||
flash('Can only add notes for players.', 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
if not tp:
|
if not tp:
|
||||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
flash(
|
||||||
return redirect(url_for('teams.list_teams'))
|
_('%(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()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
except ValidationError as err:
|
||||||
|
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.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Note added for {player.username}!', 'success')
|
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
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
|
||||||
+332
-18
@@ -139,7 +139,10 @@ a:hover { color: var(--primary-dark); }
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links li a {
|
/* Logging out is a POST, so its nav entry is a button inside a form
|
||||||
|
rather than a link. It has to read as one of the entries above it. */
|
||||||
|
.nav-links li a,
|
||||||
|
.nav-links li .nav-form button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -149,7 +152,17 @@ a:hover { color: var(--primary-dark); }
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links li a:hover, .nav-links li a.active {
|
.nav-links li .nav-form button {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links li a:hover, .nav-links li a.active,
|
||||||
|
.nav-links li .nav-form button:hover {
|
||||||
background: rgba(255,255,255,0.08);
|
background: rgba(255,255,255,0.08);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -159,7 +172,8 @@ a:hover { color: var(--primary-dark); }
|
|||||||
padding-left: 17px;
|
padding-left: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links li a i { width: 20px; text-align: center; font-size: 1.1rem; }
|
.nav-links li a i,
|
||||||
|
.nav-links li .nav-form button i { width: 20px; text-align: center; font-size: 1.1rem; }
|
||||||
|
|
||||||
.nav-divider {
|
.nav-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -1029,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
|
|||||||
|
|
||||||
.fc {
|
.fc {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
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 {
|
.fc-toolbar {
|
||||||
@@ -1041,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
|
|||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-button {
|
.fc .fc-button {
|
||||||
background: var(--primary) !important;
|
background: var(--gray-50) !important;
|
||||||
border: none !important;
|
border: 1px solid var(--gray-200) !important;
|
||||||
|
color: var(--gray-700) !important;
|
||||||
border-radius: var(--radius-sm) !important;
|
border-radius: var(--radius-sm) !important;
|
||||||
padding: 6px 12px !important;
|
padding: 6px 12px !important;
|
||||||
font-size: 0.85rem !important;
|
font-size: 0.85rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-button:hover {
|
.fc .fc-button:hover {
|
||||||
background: var(--primary-dark) !important;
|
background: var(--gray-100) !important;
|
||||||
|
color: var(--gray-900) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-button-primary:not(:disabled).fc-button-active,
|
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||||
.fc-button-primary:not(:disabled):active {
|
.fc .fc-button-primary:not(:disabled):active {
|
||||||
background: var(--primary-dark) !important;
|
background: var(--primary) !important;
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-daygrid-day {
|
.fc-daygrid-day,
|
||||||
|
.fc-timegrid-slot {
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
|
|||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-event {
|
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
|
||||||
border-radius: var(--radius-sm);
|
its inline event colors) */
|
||||||
padding: 2px 4px;
|
#calendar .fc-event {
|
||||||
font-size: 0.8rem;
|
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 {
|
#calendar .fc-event-body {
|
||||||
font-weight: 600;
|
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 {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -1242,6 +1360,64 @@ a:hover { color: var(--primary-dark); }
|
|||||||
color: white !important;
|
color: white !important;
|
||||||
border-color: #4f46e5 !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 {
|
.merged-disponibility-time-block.high-availability {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
@@ -1956,6 +2132,74 @@ a:hover { color: var(--primary-dark); }
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Language switcher
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
.nav-language {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-language .lang-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-language .lang-link:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-language .lang-current {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-language .lang-separator {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-language {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-language .lang-link { color: var(--text-secondary); text-decoration: none; }
|
||||||
|
.auth-language .lang-link:hover { color: var(--primary); text-decoration: underline; }
|
||||||
|
.auth-language .lang-current { font-weight: 600; color: var(--text-primary); }
|
||||||
|
.auth-language .lang-separator { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* Visually hidden, still announced by screen readers. */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honeypot: hidden from everyone, filled in only by a robot (SEC-AUTH-008).
|
||||||
|
The opposite of .sr-only above — that one hides from the eye and keeps the
|
||||||
|
announcement, this one has to hide from both. display:none is deliberate:
|
||||||
|
an off-screen input is still reachable by keyboard and by a screen reader,
|
||||||
|
and a person who lands in it gets refused with no idea why. */
|
||||||
|
.honeypot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Batch Evaluation - 2 cards wide layout */
|
/* Batch Evaluation - 2 cards wide layout */
|
||||||
.batch-eval-form {
|
.batch-eval-form {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -1982,3 +2226,73 @@ a:hover { color: var(--primary-dark); }
|
|||||||
grid-template-columns: 1fr;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+354
-1
@@ -420,10 +420,209 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
initDraggableBlocks();
|
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');
|
const alerts = document.querySelectorAll('.alert-dismissible');
|
||||||
alerts.forEach(function (alert) {
|
alerts.forEach(function (alert) {
|
||||||
|
if (alert.getAttribute('data-armed')) return;
|
||||||
|
alert.setAttribute('data-armed', '1');
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
if (alert.parentElement) {
|
if (alert.parentElement) {
|
||||||
alert.style.opacity = '0';
|
alert.style.opacity = '0';
|
||||||
@@ -436,4 +635,158 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 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
|
Dumps the PostgreSQL database with pg_dump and archives the uploaded
|
||||||
and uploaded contract documents. Designed to be run as a scheduled task
|
contract documents. Designed to be run from a scheduled task (Windows Task
|
||||||
(Windows Task Scheduler) or cron job.
|
Scheduler) or a cron job.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python backup.py
|
python app/supporting_scripts/backup.py
|
||||||
|
python app/supporting_scripts/backup.py --verify-only <archive>
|
||||||
|
|
||||||
Configuration via environment variables:
|
Configuration via environment variables:
|
||||||
BACKUP_DIR: Directory to store backups (default: ./backups)
|
DATABASE_URL PostgreSQL connection string (required)
|
||||||
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
BACKUP_DIR Where to store backups (default: ./backups)
|
||||||
|
BACKUP_RETENTION_DAYS How long to keep them (default: 30)
|
||||||
|
PG_DUMP Path to pg_dump if not on PATH
|
||||||
|
PG_RESTORE Path to pg_restore if not on PATH
|
||||||
|
|
||||||
|
A note on what this file used to be
|
||||||
|
-----------------------------------
|
||||||
|
The previous version targeted **SQLite**: it imported sqlite3, read
|
||||||
|
DATABASE_PATH defaulting to instance/team_tryouts.db, and used the sqlite3
|
||||||
|
backup API. Production runs on PostgreSQL, so the file never existed, the
|
||||||
|
script printed "[WARNING] Database not found... Skipping database backup"
|
||||||
|
and — because main() only tracked the verification result — still exited 0.
|
||||||
|
It reported success while backing up nothing at all. Any scheduled task
|
||||||
|
watching the exit code saw green.
|
||||||
|
|
||||||
|
Restoring is documented in docs/restauration-base.md. A backup that has
|
||||||
|
never been restored is not a backup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import subprocess
|
||||||
|
import sys
|
||||||
from datetime import datetime, timedelta
|
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
|
# 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))
|
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'))
|
PG_DUMP = os.getenv('PG_DUMP', 'pg_dump')
|
||||||
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
|
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():
|
def create_backup_dir():
|
||||||
@@ -29,55 +169,200 @@ def create_backup_dir():
|
|||||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def backup_database():
|
def backup_database(conn):
|
||||||
"""Backup the SQLite database using sqlite3's built-in backup API.
|
"""Dump the PostgreSQL database.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Path to the created backup file, or None if failed.
|
str: Path to the created archive.
|
||||||
"""
|
|
||||||
if not os.path.exists(DATABASE_PATH):
|
|
||||||
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BackupError: If pg_dump is missing, fails, or produces nothing.
|
||||||
|
"""
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
backup_filename = f'db_backup_{timestamp}.db'
|
backup_path = os.path.join(BACKUP_DIR, f'db_backup_{timestamp}.dump')
|
||||||
backup_path = os.path.join(BACKUP_DIR, backup_filename)
|
|
||||||
|
print(f'[INFO] Dumping {describe_target(conn)}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
source = sqlite3.connect(DATABASE_PATH)
|
result = subprocess.run(
|
||||||
destination = sqlite3.connect(backup_path)
|
build_dump_command(conn, backup_path),
|
||||||
source.backup(destination)
|
env=dump_environment(conn),
|
||||||
source.close()
|
capture_output=True,
|
||||||
destination.close()
|
text=True,
|
||||||
print(f'[OK] Database backed up to: {backup_path}')
|
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
|
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():
|
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:
|
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):
|
documents_dir = documents_root()
|
||||||
print('[INFO] No documents directory found. Skipping document backup.')
|
|
||||||
|
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
|
return None
|
||||||
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
archive_basename = f'documents_backup_{timestamp}'
|
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_drive_{timestamp}')
|
||||||
archive_path = os.path.join(BACKUP_DIR, archive_basename)
|
|
||||||
|
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:
|
try:
|
||||||
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR)
|
import zipfile
|
||||||
zip_path = f'{archive_path}.zip'
|
|
||||||
print(f'[OK] Documents backed up to: {zip_path}')
|
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
|
return zip_path
|
||||||
except Exception as e:
|
|
||||||
print(f'[ERROR] Document backup failed: {e}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_old_backups():
|
def cleanup_old_backups():
|
||||||
@@ -90,95 +375,85 @@ def cleanup_old_backups():
|
|||||||
|
|
||||||
for filename in os.listdir(BACKUP_DIR):
|
for filename in os.listdir(BACKUP_DIR):
|
||||||
file_path = os.path.join(BACKUP_DIR, filename)
|
file_path = os.path.join(BACKUP_DIR, filename)
|
||||||
if os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
continue
|
||||||
if file_time < cutoff:
|
if datetime.fromtimestamp(os.path.getmtime(file_path)) >= cutoff:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||||
except OSError as e:
|
except OSError as exc:
|
||||||
print(f'[WARNING] Could not remove {filename}: {e}')
|
print(f'[WARNING] Could not remove {filename}: {exc}')
|
||||||
|
|
||||||
if removed_count > 0:
|
if removed_count:
|
||||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||||
else:
|
else:
|
||||||
print('[CLEANUP] No old backups to remove.')
|
print('[CLEANUP] No old backups to remove.')
|
||||||
|
|
||||||
|
|
||||||
def verify_backup(backup_path):
|
# ---------------------------------------------------------------------------
|
||||||
"""Verify a database backup by running a quick integrity check.
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(argv=None):
|
||||||
"""Run the full backup process.
|
"""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:
|
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'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||||
print(f'Backup directory: {BACKUP_DIR}')
|
print(f'Backup directory: {BACKUP_DIR}')
|
||||||
|
# Printed because it is the value that was wrong for months without
|
||||||
|
# anyone being able to see it from the output.
|
||||||
|
print(f'Document source: {documents_root()}')
|
||||||
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||||
create_backup_dir()
|
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
|
verified = verify_backup(backup_path)
|
||||||
db_backup_path = backup_database()
|
documents_ok = True
|
||||||
success = True
|
try:
|
||||||
|
|
||||||
# 2. Verify database backup
|
|
||||||
if db_backup_path:
|
|
||||||
if not verify_backup(db_backup_path):
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# 3. Backup documents
|
|
||||||
backup_documents()
|
backup_documents()
|
||||||
|
except BackupError as exc:
|
||||||
# 4. Cleanup old backups
|
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()
|
cleanup_old_backups()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if success:
|
if verified and documents_ok and drive_ok:
|
||||||
print('=== Backup completed successfully ===')
|
print('=== Backup completed successfully ===')
|
||||||
else:
|
return 0
|
||||||
print('=== Backup completed with warnings ===')
|
|
||||||
|
|
||||||
return 0 if success else 1
|
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -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
|
- Debug mode status
|
||||||
- HTTPS configuration
|
- HTTPS configuration
|
||||||
- Dependency vulnerabilities
|
- Dependency vulnerabilities
|
||||||
- Database connectivity
|
- Required database configuration
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python security_scan.py [--url http://localhost:5000]
|
python security_scan.py [--url http://localhost:5000]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import os
|
||||||
import urllib.request
|
|
||||||
import ssl
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
||||||
|
|
||||||
|
|
||||||
def check_environment():
|
def check_environment():
|
||||||
@@ -31,8 +35,8 @@ def check_environment():
|
|||||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||||
print('=' * 60)
|
print('=' * 60)
|
||||||
|
|
||||||
critical_vars = ['SECRET_KEY']
|
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
|
||||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
recommended_vars = ['CORS_ALLOWED_ORIGINS']
|
||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
for var in critical_vars:
|
for var in critical_vars:
|
||||||
@@ -58,7 +62,8 @@ def check_environment():
|
|||||||
# Check FLASK_DEBUG
|
# Check FLASK_DEBUG
|
||||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||||
if debug == 'true':
|
if debug == 'true':
|
||||||
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
|
print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
|
||||||
|
all_ok = False
|
||||||
else:
|
else:
|
||||||
print('[OK] FLASK_DEBUG is disabled')
|
print('[OK] FLASK_DEBUG is disabled')
|
||||||
|
|
||||||
@@ -91,10 +96,11 @@ def check_https_headers(url):
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a context that doesn't verify SSL (for local testing)
|
# Keep the default certificate and hostname verification. A scanner
|
||||||
|
# that accepts an invalid certificate can validate headers while the
|
||||||
|
# transport itself is impersonated. Local runs without TLS should use
|
||||||
|
# http:// explicitly or opt out with --skip-http.
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, method='HEAD')
|
req = urllib.request.Request(url, method='HEAD')
|
||||||
|
|
||||||
@@ -128,7 +134,9 @@ def check_https_headers(url):
|
|||||||
all_ok = False
|
all_ok = False
|
||||||
|
|
||||||
if 'SameSite' in cookie:
|
if 'SameSite' in cookie:
|
||||||
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}')
|
print(
|
||||||
|
f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print('[WARN] Cookies missing SameSite attribute')
|
print('[WARN] Cookies missing SameSite attribute')
|
||||||
all_ok = False
|
all_ok = False
|
||||||
@@ -146,9 +154,9 @@ def check_https_headers(url):
|
|||||||
all_ok = False
|
all_ok = False
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
|
||||||
print('[SKIP] Run with --url <application_url> to check headers')
|
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
||||||
return True # Not a failure, just can't check
|
return False
|
||||||
|
|
||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
@@ -165,38 +173,52 @@ def check_dependencies():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
[
|
||||||
|
sys.executable,
|
||||||
|
'-m',
|
||||||
|
'pip_audit',
|
||||||
|
'--requirement',
|
||||||
|
str(REQUIREMENTS_FILE),
|
||||||
|
'--format',
|
||||||
|
'json',
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60
|
timeout=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print('[OK] No known vulnerabilities found')
|
print('[OK] No known vulnerabilities found')
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
vulns = data.get('dependencies', [])
|
# pip-audit's "dependencies" array lists EVERY dependency, each
|
||||||
if vulns:
|
# carrying a "vulns" list that is empty when the package is
|
||||||
for vuln in vulns:
|
# clean. Treating the array itself as the vulnerability list
|
||||||
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}')
|
# 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
|
return False
|
||||||
else:
|
|
||||||
print('[OK] No vulnerabilities found')
|
print('[OK] No vulnerabilities found')
|
||||||
return True
|
return True
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
if result.stdout:
|
if result.stdout:
|
||||||
print(f'[INFO] {result.stdout.strip()}')
|
print(f'[INFO] {result.stdout.strip()}')
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
print(f'[WARN] {result.stderr.strip()}')
|
print(f'[FAIL] {result.stderr.strip()}')
|
||||||
return True
|
else:
|
||||||
|
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
|
||||||
|
return False
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
||||||
return True
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('[WARN] pip-audit timed out')
|
print('[FAIL] pip-audit timed out')
|
||||||
return True
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_file_permissions():
|
def check_file_permissions():
|
||||||
@@ -215,7 +237,7 @@ def check_file_permissions():
|
|||||||
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
|
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
|
||||||
if os.path.exists(gitignore_path):
|
if os.path.exists(gitignore_path):
|
||||||
required_patterns = ['.env', 'instance/', '*.db', '*.log']
|
required_patterns = ['.env', 'instance/', '*.db', '*.log']
|
||||||
with open(gitignore_path, 'r') as f:
|
with open(gitignore_path) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
for pattern in required_patterns:
|
for pattern in required_patterns:
|
||||||
@@ -237,7 +259,7 @@ def check_file_permissions():
|
|||||||
|
|
||||||
# Check for leftover .pyc or __pycache__
|
# Check for leftover .pyc or __pycache__
|
||||||
pycache_count = 0
|
pycache_count = 0
|
||||||
for root, dirs, files in os.walk(os.getcwd()):
|
for _root, dirs, files in os.walk(os.getcwd()):
|
||||||
if '__pycache__' in dirs:
|
if '__pycache__' in dirs:
|
||||||
pycache_count += 1
|
pycache_count += 1
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -264,8 +286,22 @@ def check_flask_config():
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# The script lives two levels below the project root; without this the
|
||||||
|
# import fails and the whole check was silently skipped.
|
||||||
|
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
if root not in sys.path:
|
||||||
|
sys.path.insert(0, root)
|
||||||
|
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
app = create_app()
|
|
||||||
|
# Inspect configuration only: no schema creation, no Discord bot.
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
|
||||||
|
'AUTO_CREATE_TABLES': False,
|
||||||
|
'ENABLE_DISCORD_BOT': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Check session cookie settings
|
# Check session cookie settings
|
||||||
cookie_checks = [
|
cookie_checks = [
|
||||||
@@ -312,8 +348,17 @@ def check_flask_config():
|
|||||||
else:
|
else:
|
||||||
print('[OK] DEBUG mode: disabled')
|
print('[OK] DEBUG mode: disabled')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 — any failure to load the app is a failed check
|
||||||
print(f'[SKIP] Cannot check Flask config: {e}')
|
# Returning all_ok (still True) here meant that failing to load the
|
||||||
|
# application at all was counted as a passing check — the most
|
||||||
|
# important section of the report silently never ran.
|
||||||
|
#
|
||||||
|
# The breadth is the point: this section's question is "does the
|
||||||
|
# application load with a safe configuration", and every way of not
|
||||||
|
# loading answers it the same way. Reported on stdout because this
|
||||||
|
# script is read by a CI job, not by a log collector.
|
||||||
|
print(f'[FAIL] Cannot check Flask config: {e}')
|
||||||
|
return False
|
||||||
|
|
||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
@@ -327,19 +372,32 @@ def main():
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Security validation scanner')
|
parser = argparse.ArgumentParser(description='Security validation scanner')
|
||||||
parser.add_argument('--url', default='http://localhost:5000',
|
parser.add_argument(
|
||||||
help='Application URL to check headers (default: http://localhost:5000)')
|
'--url',
|
||||||
|
default='http://localhost:5000',
|
||||||
|
help='Application URL to check headers (default: http://localhost:5000)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--skip-http',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip the live HTTP header check (no server running, e.g. in CI)',
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print('╔══════════════════════════════════════════════════════════╗')
|
# Plain ASCII: the box-drawing characters this banner used crashed the
|
||||||
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║')
|
# script outright on a cp1252 Windows console, which is the platform the
|
||||||
print('╠══════════════════════════════════════════════════════════╣')
|
# project is developed and deployed on.
|
||||||
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
print('=' * 60)
|
||||||
print('╚══════════════════════════════════════════════════════════╝')
|
print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER')
|
||||||
|
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||||
|
print('=' * 60)
|
||||||
|
|
||||||
checks = [
|
checks = [check_environment]
|
||||||
check_environment,
|
if args.skip_http:
|
||||||
lambda: check_https_headers(args.url),
|
print('\n[SKIP] HTTP header check disabled via --skip-http')
|
||||||
|
else:
|
||||||
|
checks.append(lambda: check_https_headers(args.url))
|
||||||
|
checks += [
|
||||||
check_dependencies,
|
check_dependencies,
|
||||||
check_file_permissions,
|
check_file_permissions,
|
||||||
check_flask_config,
|
check_flask_config,
|
||||||
@@ -363,7 +421,6 @@ def main():
|
|||||||
if failed == 0:
|
if failed == 0:
|
||||||
print('\n[OK] All security checks passed!')
|
print('\n[OK] All security checks passed!')
|
||||||
return 0
|
return 0
|
||||||
else:
|
|
||||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% block title %}400 Bad Request - UdeS team manager{% endblock %}
|
{% block title %}{{ _('400 Bad Request') }} - UdeS team manager{% endblock %}
|
||||||
{% block page_title %}Bad Request{% endblock %}
|
{% block page_title %}{{ _('Bad Request') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
</div>
|
</div>
|
||||||
<h2>400 — Bad Request</h2>
|
<h2>{{ _('400 — Bad Request') }}</h2>
|
||||||
<p>The request could not be understood by the server. Please check your input and try again.</p>
|
<p>{{ _('The request could not be understood by the server. Please check your input and try again.') }}</p>
|
||||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||||
<i class="fas fa-arrow-left"></i> Go Back
|
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% block title %}403 Forbidden - UdeS team manager{% endblock %}
|
{% block title %}{{ _('403 Forbidden') }} - UdeS team manager{% endblock %}
|
||||||
{% block page_title %}Access Denied{% endblock %}
|
{% block page_title %}{{ _('Access Denied') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<i class="fas fa-lock"></i>
|
<i class="fas fa-lock"></i>
|
||||||
</div>
|
</div>
|
||||||
<h2>403 — Forbidden</h2>
|
<h2>{{ _('403 — Forbidden') }}</h2>
|
||||||
<p>You do not have permission to access this resource. If you believe this is an error, please contact an administrator.</p>
|
<p>{{ _('You do not have permission to access this resource. If you believe this is an error, please contact an administrator.') }}</p>
|
||||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||||
<i class="fas fa-arrow-left"></i> Go Back
|
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% block title %}404 Not Found - UdeS team manager{% endblock %}
|
{% block title %}{{ _('404 Not Found') }} - UdeS team manager{% endblock %}
|
||||||
{% block page_title %}Page Not Found{% endblock %}
|
{% block page_title %}{{ _('Page Not Found') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
</div>
|
</div>
|
||||||
<h2>404 — Not Found</h2>
|
<h2>{{ _('404 — Not Found') }}</h2>
|
||||||
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
|
<p>{{ _('The page you are looking for does not exist. It may have been moved or deleted.') }}</p>
|
||||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||||
<i class="fas fa-home"></i> Return Home
|
<i class="fas fa-home"></i> {{ _('Return Home') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% block title %}429 Too Many Requests - UdeS team manager{% endblock %}
|
{% block title %}{{ _('429 Too Many Requests') }} - UdeS team manager{% endblock %}
|
||||||
{% block page_title %}Rate Limit Exceeded{% endblock %}
|
{% block page_title %}{{ _('Rate Limit Exceeded') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<i class="fas fa-hourglass-half"></i>
|
<i class="fas fa-hourglass-half"></i>
|
||||||
</div>
|
</div>
|
||||||
<h2>429 — Too Many Requests</h2>
|
<h2>{{ _('429 — Too Many Requests') }}</h2>
|
||||||
<p>You have sent too many requests in a short period. Please wait a moment and try again.</p>
|
<p>{{ _('You have sent too many requests in a short period. Please wait a moment and try again.') }}</p>
|
||||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||||
<i class="fas fa-arrow-left"></i> Go Back
|
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% block title %}500 Server Error - UdeS team manager{% endblock %}
|
{% block title %}{{ _('500 Server Error') }} - UdeS team manager{% endblock %}
|
||||||
{% block page_title %}Internal Server Error{% endblock %}
|
{% block page_title %}{{ _('Internal Server Error') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<i class="fas fa-cogs"></i>
|
<i class="fas fa-cogs"></i>
|
||||||
</div>
|
</div>
|
||||||
<h2>500 — Internal Server Error</h2>
|
<h2>{{ _('500 — Internal Server Error') }}</h2>
|
||||||
<p>Something went wrong on our end. The error has been logged and will be investigated. Please try again later.</p>
|
<p>{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}</p>
|
||||||
|
{# The reference is what makes a report actionable: it names one request
|
||||||
|
in errors.log. It identifies nothing else — no session, no account —
|
||||||
|
so there is nothing to protect here (OBS-005). #}
|
||||||
|
{% if request_id and request_id != '-' %}
|
||||||
|
<p class="text-muted small">{{ _('Reference to quote if you report this:') }} <code>{{ request_id }}</code></p>
|
||||||
|
{% endif %}
|
||||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||||
<i class="fas fa-redo-alt"></i> Try Again
|
<i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{# Language switcher.
|
||||||
|
|
||||||
|
Included from both branches of the layout: the signed-in sidebar and the
|
||||||
|
anonymous authentication page. Someone who cannot read the current
|
||||||
|
language has to be able to change it *before* signing in, so this cannot
|
||||||
|
live behind the login.
|
||||||
|
|
||||||
|
Each language is written in its own language, for the same reason. #}
|
||||||
|
<i class="fas fa-language" aria-hidden="true"></i>
|
||||||
|
<span class="sr-only">{{ _('Language') }}</span>
|
||||||
|
{% for code in supported_locales %}
|
||||||
|
{%- if code == current_locale %}
|
||||||
|
<span class="lang-current" aria-current="true">{{ locale_names[code] }}</span>
|
||||||
|
{%- else %}
|
||||||
|
<a href="{{ url_for('main.set_language', locale=code) }}"
|
||||||
|
class="lang-link" hreflang="{{ code }}" rel="alternate">{{ locale_names[code] }}</a>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if not loop.last %}<span class="lang-separator" aria-hidden="true">·</span>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
@@ -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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{ current_locale }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}UdeS team manager{% endblock %}</title>
|
<title>{% block title %}UdeS team manager{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
{# Subresource integrity (QUA-004). Without it, whoever controls the CDN
|
||||||
|
controls what runs on every page of this site — and the CSP names these
|
||||||
|
hosts as allowed, so it would not object.
|
||||||
|
|
||||||
|
What SRI does and does not do: it pins this exact file, so the browser
|
||||||
|
refuses a version that has been altered since. It does not prove the
|
||||||
|
file was honest when the hash was taken. This hash is the one cdnjs
|
||||||
|
publishes for the release, not one derived from the copy we downloaded.
|
||||||
|
|
||||||
|
integrity requires crossorigin. Changing the version means changing
|
||||||
|
the hash, or the asset silently stops loading. #}
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
|
||||||
</head>
|
</head>
|
||||||
@@ -30,26 +45,26 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
|
||||||
<i class="fas fa-th-large"></i>
|
<i class="fas fa-th-large"></i>
|
||||||
<span>Dashboard</span>
|
<span>{{ _('Dashboard') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
|
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
|
||||||
<i class="fas fa-calendar-alt"></i>
|
<i class="fas fa-calendar-alt"></i>
|
||||||
<span>Tryouts</span>
|
<span>{{ _('Tryouts') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
|
||||||
<i class="fas fa-calendar"></i>
|
<i class="fas fa-calendar"></i>
|
||||||
<span>Calendar</span>
|
<span>{{ _('Calendar') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.can_evaluate() %}
|
{% if current_user.can_evaluate() %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
|
||||||
<i class="fas fa-clipboard-check"></i>
|
<i class="fas fa-clipboard-check"></i>
|
||||||
<span>Evaluations</span>
|
<span>{{ _('Evaluations') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -57,14 +72,14 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
<span>My Team(s)</span>
|
<span>{{ _('My Team(s)') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
|
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
|
||||||
<i class="fas fa-users-cog"></i>
|
<i class="fas fa-users-cog"></i>
|
||||||
<span>Manage Teams</span>
|
<span>{{ _('Manage Teams') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -72,7 +87,13 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
|
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
|
||||||
<i class="fas fa-users-cog"></i>
|
<i class="fas fa-users-cog"></i>
|
||||||
<span>Manage Users</span>
|
<span>{{ _('Manage Users') }}</span>
|
||||||
|
</a>
|
||||||
|
</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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -80,7 +101,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
|
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
|
||||||
<i class="fas fa-sticky-note"></i>
|
<i class="fas fa-sticky-note"></i>
|
||||||
<span>My Notes</span>
|
<span>{{ _('My Notes') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -88,42 +109,53 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
|
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
|
||||||
<i class="fas fa-sticky-note"></i>
|
<i class="fas fa-sticky-note"></i>
|
||||||
<span>Notes & One on One</span>
|
<span>{{ _('Notes & One on One') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
||||||
<i class="fas fa-file-contract"></i>
|
<i class="fas fa-file-contract"></i>
|
||||||
<span>Contracts</span>
|
<span>{{ _('Contracts') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-divider"></li>
|
<li class="nav-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
<span>My Profile</span>
|
<span>{{ _('My Profile') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('auth.logout') }}" class="logout-link">
|
{# A form, not a link: logging out is a state change, and a
|
||||||
|
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>
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
<span>Logout</span>
|
<span>{{ _('Logout') }}</span>
|
||||||
</a>
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li class="nav-divider"></li>
|
||||||
|
<li class="nav-language">
|
||||||
|
{% include "layouts/_language_switcher.html" %}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="main-content" id="mainContent">
|
<div class="main-content" id="mainContent">
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()">
|
<button class="sidebar-toggle" id="sidebarToggle" data-action="toggle-sidebar">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
|
<h1>{% block page_title %}{{ _('Dashboard') }}{% endblock %}</h1>
|
||||||
{% block breadcrumb %}{% endblock %}
|
{% block breadcrumb %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<button class="dark-mode-toggle" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark mode">
|
<button class="dark-mode-toggle" id="darkModeToggle" data-action="toggle-dark-mode"
|
||||||
|
title="{{ _('Toggle dark mode') }}">
|
||||||
<i class="fas fa-moon"></i>
|
<i class="fas fa-moon"></i>
|
||||||
</button>
|
</button>
|
||||||
{% block header_actions %}{% endblock %}
|
{% block header_actions %}{% endblock %}
|
||||||
@@ -134,13 +166,14 @@
|
|||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="alert alert-{{ category }} alert-dismissible">
|
<div class="alert alert-{{ category }} alert-dismissible">
|
||||||
<span>{{ message }}</span>
|
<span>{{ message }}</span>
|
||||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
<button type="button" class="alert-close" data-action="dismiss-alert"
|
||||||
|
aria-label="{{ _('Dismiss') }}">×</button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content" id="page-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +185,8 @@
|
|||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="alert alert-{{ category }} alert-dismissible">
|
<div class="alert alert-{{ category }} alert-dismissible">
|
||||||
<span>{{ message }}</span>
|
<span>{{ message }}</span>
|
||||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
<button type="button" class="alert-close" data-action="dismiss-alert"
|
||||||
|
aria-label="{{ _('Dismiss') }}">×</button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -162,8 +196,25 @@
|
|||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="auth-logo-img">
|
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="auth-logo-img">
|
||||||
<h2>UdeS team manager</h2>
|
<h2>UdeS team manager</h2>
|
||||||
<p>UdeS team manager</p>
|
<p>{{ _('UdeS team manager') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="auth-language">
|
||||||
|
{% include "layouts/_language_switcher.html" %}
|
||||||
|
</div>
|
||||||
|
{# The same `content` block as the signed-in branch, rendered
|
||||||
|
here too — `self.content()` rather than a second
|
||||||
|
`{% block %}`, which Jinja refuses.
|
||||||
|
|
||||||
|
The error pages (400, 403, 404, 429, 500) all fill `content`,
|
||||||
|
and it existed only inside the `is_authenticated` branch: a
|
||||||
|
signed-out visitor hitting any of them got the logo, the
|
||||||
|
language switcher and no message whatsoever. The <title> still
|
||||||
|
said "404", which is most of why nobody noticed.
|
||||||
|
|
||||||
|
Only one branch of the `if` runs, so this never double-renders.
|
||||||
|
Sign-in pages fill `auth_content` instead and leave this
|
||||||
|
empty. #}
|
||||||
|
{{ self.content() }}
|
||||||
{% block auth_content %}{% endblock %}
|
{% block auth_content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,11 +108,12 @@
|
|||||||
{# Modal Macro - renders a modal dialog #}
|
{# Modal Macro - renders a modal dialog #}
|
||||||
{% macro modal(id, title, content, footer_buttons=None) %}
|
{% macro modal(id, title, content, footer_buttons=None) %}
|
||||||
<div id="{{ id }}" class="modal hidden">
|
<div id="{{ id }}" class="modal hidden">
|
||||||
<div class="modal-backdrop" onclick="hideModal('{{ id }}')"></div>
|
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="{{ id }}"></div>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
<button class="modal-close" onclick="hideModal('{{ id }}')">×</button>
|
<button class="modal-close" data-action="hide-modal" data-modal-id="{{ id }}"
|
||||||
|
aria-label="{{ _('Close') }}">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{ content }}
|
{{ content }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% block title %}Add Personal Note - UdeS team manager{% endblock %}
|
{% block title %}{{ _('Add Personal Note') }} - UdeS team manager{% endblock %}
|
||||||
{% block page_title %}Add Personal Note{% endblock %}
|
{% block page_title %}{{ _('Add Personal Note') }}{% endblock %}
|
||||||
{% block breadcrumb %}
|
{% block breadcrumb %}
|
||||||
<span class="breadcrumb">
|
<span class="breadcrumb">
|
||||||
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
|
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-sticky-note"></i> Add Personal Note</h3>
|
<h3><i class="fas fa-sticky-note"></i> {{ _('Add Personal Note') }}</h3>
|
||||||
{% if context_type == 'tryout' %}
|
{% if context_type == 'tryout' %}
|
||||||
<p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p>
|
<p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p>
|
||||||
{% elif context_type == 'match' %}
|
{% elif context_type == 'match' %}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="
|
<form method="POST" data-ajax action="
|
||||||
{% if context_type == 'tryout' %}
|
{% if context_type == 'tryout' %}
|
||||||
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
||||||
{% elif context_type == 'match' %}
|
{% elif context_type == 'match' %}
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="player_id">Player</label>
|
<label for="player_id">{{ _('Player') }}</label>
|
||||||
<select name="player_id" id="player_id" class="form-select" required>
|
<select name="player_id" id="player_id" class="form-select" required>
|
||||||
<option value="">-- Select a player --</option>
|
<option value="">{{ _('-- Select a player --') }}</option>
|
||||||
{% for player in players %}
|
{% for player in players %}
|
||||||
<option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}>
|
<option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}>
|
||||||
{{ player.username }}
|
{{ player.username }}
|
||||||
@@ -56,15 +56,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="content">Note Content</label>
|
<label for="content">{{ _('Note Content') }}</label>
|
||||||
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="Enter your coaching notes..." required></textarea>
|
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="{{ _('Enter your coaching notes...') }}" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if context_type != 'tryout' and context_type != 'match' %}
|
{% if context_type != 'tryout' and context_type != 'match' %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tryout_id">Link to Tryout (Optional)</label>
|
<label for="tryout_id">{{ _('Link to Tryout (Optional)') }}</label>
|
||||||
<select name="tryout_id" id="tryout_id" class="form-select">
|
<select name="tryout_id" id="tryout_id" class="form-select">
|
||||||
<option value="">-- No tryout --</option>
|
<option value="">{{ _('-- No tryout --') }}</option>
|
||||||
{% for t in tryouts %}
|
{% for t in tryouts %}
|
||||||
<option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option>
|
<option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -72,9 +72,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="match_id">Link to Match (Optional)</label>
|
<label for="match_id">{{ _('Link to Match (Optional)') }}</label>
|
||||||
<select name="match_id" id="match_id" class="form-select">
|
<select name="match_id" id="match_id" class="form-select">
|
||||||
<option value="">-- No match --</option>
|
<option value="">{{ _('-- No match --') }}</option>
|
||||||
{% for m in matches %}
|
{% for m in matches %}
|
||||||
<option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option>
|
<option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -82,9 +82,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="team_id">Link to Team (Optional)</label>
|
<label for="team_id">{{ _('Link to Team (Optional)') }}</label>
|
||||||
<select name="team_id" id="team_id" class="form-select">
|
<select name="team_id" id="team_id" class="form-select">
|
||||||
<option value="">-- No team --</option>
|
<option value="">{{ _('-- No team --') }}</option>
|
||||||
{% for team in teams %}
|
{% for team in teams %}
|
||||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
{% if team_notes %}
|
{% if team_notes %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Team Notes (Reference)</label>
|
<label>{{ _('Team Notes (Reference)') }}</label>
|
||||||
<div class="team-notes-reference">
|
<div class="team-notes-reference">
|
||||||
{% for note in team_notes %}
|
{% for note in team_notes %}
|
||||||
<div class="note-reference-item">
|
<div class="note-reference-item">
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
" class="btn btn-secondary">Cancel</a>
|
" class="btn btn-secondary">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="fas fa-save"></i> Add Note
|
<i class="fas fa-save"></i> {{ _('Add Note') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user