Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06508cc7d6 | ||
|
|
9647003c3f | ||
|
|
2b943c5c22 | ||
|
|
437b229c82 | ||
|
|
3b7b9182d7 | ||
|
|
20dabecd67 | ||
|
|
838b247649 | ||
|
|
9166d8abeb | ||
|
|
9cedf4a038 | ||
|
|
dd8b9671c6 | ||
|
|
ad3dea6a15 | ||
|
|
8d7de75e99 | ||
|
|
06d6ad7eaa | ||
|
|
e76cd7bb23 | ||
|
|
bda23dbb67 | ||
|
|
66f2838402 | ||
|
|
546f28571b | ||
|
|
7a1dab21cd | ||
|
|
709e8a5d51 | ||
|
|
70db8a7491 | ||
|
|
7b9eee4805 | ||
|
|
3882b6035f | ||
|
|
39808dd04e | ||
|
|
506a061405 | ||
|
|
c5e5cfa014 | ||
|
|
0308eb9eef | ||
|
|
d8541678a6 | ||
|
|
d0a9e75fe6 | ||
|
|
47ff544848 | ||
|
|
ab0b975211 | ||
|
|
aebc28fb8a | ||
|
|
e5c29d8113 | ||
|
|
2e10bbbd62 | ||
|
|
9d0456c2fd | ||
|
|
0cd9a186ee | ||
|
|
5c87064a17 | ||
|
|
8e3865f557 | ||
|
|
7cec18c139 | ||
|
|
2f40290f00 | ||
|
|
51877b46a0 | ||
|
|
20158a9e7a | ||
|
|
835a394d3f | ||
|
|
d85da32ef5 | ||
|
|
92d72e4d48 | ||
|
|
37f70c89e3 | ||
|
|
80bc1413f1 | ||
|
|
fcb58e8a17 | ||
|
|
09453199b8 | ||
|
|
15bfebf4fc | ||
|
|
d15a3de2a1 | ||
|
|
a92600c305 | ||
|
|
afab7070fb | ||
|
|
7bf428f9a0 | ||
|
|
ca45be80db | ||
|
|
ab44258d72 | ||
|
|
983b7a1f49 | ||
|
|
d05e9cde32 | ||
|
|
32f193c008 | ||
|
|
5ecea55f55 | ||
|
|
a793b7ed0d | ||
|
|
b277c453f9 | ||
|
|
1b990a84d9 | ||
|
|
de9448a9aa | ||
|
|
2c37c05c8f | ||
|
|
56aee7f3f3 | ||
|
|
90866f830e | ||
|
|
ed586233f6 | ||
|
|
fa0a378827 |
@@ -6,37 +6,147 @@ on:
|
||||
# branches:
|
||||
# - main # Optional: Run automatically on pushes to the main branch
|
||||
|
||||
# OPS-011 — what this workflow now guarantees, and what it still does not.
|
||||
#
|
||||
# Guaranteed:
|
||||
# - nothing is uploaded unless the test suite and the linters pass;
|
||||
# - only files on an explicit allowlist are uploaded, so a new file at the
|
||||
# repository root does not reach production by default. That is how
|
||||
# clear_db.py — a script that DROPs every table and recreates
|
||||
# admin/password — got there in the first place;
|
||||
# - after the upload, /health is polled until it answers healthy, and the
|
||||
# job fails loudly if it does not. Before, a half-uploaded tree was a
|
||||
# green deployment.
|
||||
#
|
||||
# NOT guaranteed — the switch is not atomic. Files are mirrored in place, so
|
||||
# for the length of the transfer production runs a mixture of two versions.
|
||||
# Closing that needs a release-directory layout, which has three
|
||||
# prerequisites, two of which cannot be done from here:
|
||||
#
|
||||
# 1. the Pterodactyl startup command must run the app from `current/`
|
||||
# rather than from the server root, and the server must be restarted on
|
||||
# switch — a panel change;
|
||||
# 2. `documents/`, `logs/` and `.env` must live beside the releases, not
|
||||
# inside one. DOCUMENTS_ROOT exists for this (app/storage.py);
|
||||
# 3. contract paths must be relative to that root, so the switch does not
|
||||
# strand them. Done: new rows are relative, old absolute ones still
|
||||
# resolve.
|
||||
#
|
||||
# docs/deployment.md carries the design and the rollback procedure.
|
||||
|
||||
# Least privilege (CI-003). This job never writes back to the repository; it
|
||||
# holds the SSH key to the production node, which makes it the most valuable
|
||||
# job in either forge to compromise.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Actions pinned to a commit, version in the comment. A tag is a moving
|
||||
# pointer, and moving `v4` here means running arbitrary code in the job that
|
||||
# holds that key. If the Gitea runner ever fails to resolve a commit ref, it
|
||||
# fails on the checkout step — loudly, like the `@v7` that did not exist.
|
||||
|
||||
jobs:
|
||||
deploy-to-sftp:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Was @v7, which does not exist (latest major is v5): the workflow
|
||||
# failed on its very first step.
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Install lftp and ssh
|
||||
run: sudo apt-get update && sudo apt-get install -y lftp openssh-client
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# The gate. This runner is not the GitHub one, so a green CI over
|
||||
# there proves nothing about what is about to be shipped from here:
|
||||
# the deploy is triggered by hand, on whatever the branch holds.
|
||||
- name: Refuse to deploy a broken tree
|
||||
run: |
|
||||
python -m pytest -q
|
||||
python -m ruff check .
|
||||
python -m ruff format --check .
|
||||
|
||||
# An allowlist, not a list of exclusions. The previous form mirrored
|
||||
# the whole working tree minus nine globs, so every file added to the
|
||||
# repository shipped to production unless someone remembered to
|
||||
# exclude it. This inverts the default: a new top-level file has to be
|
||||
# named here to reach the server.
|
||||
- name: Assemble the release payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p payload
|
||||
cp -r app payload/
|
||||
cp requirements.txt wsgi.py payload/
|
||||
# Compiled catalogues are versioned deliberately: the deployment is
|
||||
# a file mirror with no build step (docs/translations.md).
|
||||
find payload -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||
find payload -name '*.pyc' -delete
|
||||
echo "Shipping $(find payload -type f | wc -l) files:"
|
||||
find payload -maxdepth 2 -type d | sort
|
||||
|
||||
- name: Set up SSH Private Key
|
||||
env:
|
||||
# Binds the secret to a secure environment variable
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
# Uses the environment variable, so the raw key is never printed in the execution log
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
|
||||
- name: Push files via SFTP with progress
|
||||
run: |
|
||||
# The mirror command below uses the -R (reverse) flag
|
||||
# to push from local './' to remote './'
|
||||
# Connection is made using 'open' inside the execution block to enforce SSH key usage
|
||||
# --delete is deliberately NOT used. Uploaded contracts, logs and the
|
||||
# server's own .env live under the deployment root and are absent
|
||||
# from the repository; deleting anything not present locally would
|
||||
# destroy them. Stale files therefore still accumulate — that is the
|
||||
# other half of what the release-directory layout would fix.
|
||||
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
|
||||
set sftp:auto-confirm yes; \
|
||||
set net:max-retries 5; \
|
||||
set net:timeout 30; \
|
||||
set cmd:fail-exit yes; \
|
||||
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
|
||||
mirror -R --verbose --parallel=4 ./ ./; \
|
||||
quit"
|
||||
mirror -R --verbose --parallel=4 ./payload/ ./; \
|
||||
quit"
|
||||
|
||||
# Without this a deployment that left the site 500ing reported success,
|
||||
# and the first person to hear about it was a user. /health checks the
|
||||
# database connection and reports whether the Discord bot thread is
|
||||
# alive (OPS-012).
|
||||
# HEALTH_URL is carried as a secret rather than as a variable. It is
|
||||
# not secret — it is the public site — but `secrets` is the context
|
||||
# this runner is already known to support, and a smoke test that fails
|
||||
# to run because of an unsupported expression is worse than none.
|
||||
- name: Smoke test
|
||||
if: ${{ secrets.HEALTH_URL != '' }}
|
||||
env:
|
||||
HEALTH_URL: ${{ secrets.HEALTH_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The app is restarted by the panel, not by this workflow, so the
|
||||
# first few probes are expected to fail or answer from the old
|
||||
# process. Two minutes, then give up.
|
||||
for attempt in $(seq 1 24); do
|
||||
body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body=''
|
||||
if echo "$body" | grep -q '"status": *"healthy"'; then
|
||||
echo "Healthy after ${attempt} attempt(s):"
|
||||
echo "$body"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt ${attempt}: not healthy yet"
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md."
|
||||
exit 1
|
||||
|
||||
- name: Warn when no health check is configured
|
||||
if: ${{ secrets.HEALTH_URL == '' }}
|
||||
run: |
|
||||
echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https://<host>/health in the repository secrets."
|
||||
|
||||
+52
-25
@@ -11,84 +11,111 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least privilege: nothing here writes back to the repository.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Third-party actions are pinned to a commit, with the version in a comment
|
||||
# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a
|
||||
# job that holds this repository's token. The comment is what makes the pin
|
||||
# maintainable — a bare 40-character hash tells a reader nothing about
|
||||
# whether it is current. Dependabot updates both together.
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.12'
|
||||
|
||||
jobs:
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pip-audit
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit==2.9.0
|
||||
|
||||
- name: Scan for vulnerable dependencies
|
||||
run: pip-audit --require-hashes --no-deps || pip-audit
|
||||
# Previously: `pip-audit --require-hashes --no-deps || pip-audit`.
|
||||
# Neither form named the requirements file, so the fallback audited the
|
||||
# runner's environment — which contained pip-audit and nothing else.
|
||||
# The job passed green while checking none of the application's
|
||||
# dependencies. -r makes it audit what the application actually pins.
|
||||
- name: Scan declared dependencies for known vulnerabilities
|
||||
run: pip-audit -r requirements.txt
|
||||
|
||||
lint:
|
||||
name: Lint with Ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
run: pip install ruff==0.14.4
|
||||
|
||||
# Rule selection and per-file ignores live in pyproject.toml. Before it
|
||||
# existed, this step ran ruff's bare defaults with no configuration.
|
||||
- name: Run ruff linter
|
||||
run: ruff check . --output-format=github
|
||||
|
||||
- name: Run ruff formatter check
|
||||
# Enabled now that the repository has been formatted once, in its own
|
||||
# commit (QUA-002). Reaching this step before that would have failed on
|
||||
# 72 of 76 files for reasons unrelated to correctness.
|
||||
- name: Check formatting
|
||||
run: ruff format --check .
|
||||
|
||||
security-scan:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install app dependencies
|
||||
run: pip install -r requirements.txt
|
||||
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# The path was `security_scan.py`, but the script lives under
|
||||
# app/supporting_scripts/. The step had therefore failed on every run
|
||||
# since the file was moved.
|
||||
- name: Run security scan
|
||||
env:
|
||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||
FLASK_DEBUG: 'false'
|
||||
run: python security_scan.py --skip-http
|
||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [security-audit, lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# Previously an `echo` guarded by continue-on-error: the job reported
|
||||
# success without executing anything. The suite needs no environment
|
||||
# variables and no database server: create_app() takes its configuration
|
||||
# as an argument and the fixtures use a temporary SQLite file.
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "No tests configured yet. Add tests to the project."
|
||||
# python -m pytest tests/ --cov=. --cov-report=xml
|
||||
continue-on-error: true
|
||||
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||
|
||||
+141
-17
@@ -1,27 +1,151 @@
|
||||
.env
|
||||
# =============================================================================
|
||||
# Secrets et configuration locale
|
||||
# =============================================================================
|
||||
# `.env*` couvre .env, .env.local, .env.production, .env.bak…
|
||||
# L'exception laisse passer le seul fichier modèle, qui ne doit contenir
|
||||
# que des valeurs factices.
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.instance/
|
||||
*.key
|
||||
*.pem
|
||||
*.pfx
|
||||
*.p12
|
||||
.certs/
|
||||
id_rsa
|
||||
id_ed25519
|
||||
known_hosts
|
||||
credentials.json
|
||||
service-account*.json
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — Claude
|
||||
# =============================================================================
|
||||
.claude/
|
||||
.claude.json
|
||||
.claude/settings.local.json
|
||||
CLAUDE.md
|
||||
CLAUDE.local.md
|
||||
.anthropic/
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — ChatGPT / OpenAI
|
||||
# =============================================================================
|
||||
.chatgpt/
|
||||
.openai/
|
||||
.codex/
|
||||
AGENTS.md
|
||||
chatgpt-*.md
|
||||
openai-*.md
|
||||
|
||||
# =============================================================================
|
||||
# Assistants IA — autres outils
|
||||
# =============================================================================
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.cursorignore
|
||||
.windsurf/
|
||||
.windsurfrules
|
||||
.aider*
|
||||
.continue/
|
||||
.clinerules
|
||||
.roo/
|
||||
.gemini/
|
||||
GEMINI.md
|
||||
.github/copilot-instructions.md
|
||||
.copilot/
|
||||
|
||||
# =============================================================================
|
||||
# Notes de travail et suivi générés par assistant IA
|
||||
# =============================================================================
|
||||
# Rapports d'audit, plans, brouillons et notes de session : documents de
|
||||
# travail, non destinés à être versionnés dans le dépôt applicatif.
|
||||
.ai/
|
||||
audit/
|
||||
*.audit.md
|
||||
NOTES-IA.md
|
||||
TODO-IA.md
|
||||
|
||||
# =============================================================================
|
||||
# Python
|
||||
# =============================================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environnements virtuels
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Outils
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
coverage_html_report/
|
||||
coverage.xml
|
||||
|
||||
# =============================================================================
|
||||
# Données applicatives — ne jamais versionner
|
||||
# =============================================================================
|
||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||
# voir l'historique du commit `4fd27ba`).
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Contrats téléversés (données personnelles)
|
||||
documents/
|
||||
|
||||
__pycache__/
|
||||
*.cpython-313.pyc
|
||||
*.cpython-312.pyc
|
||||
*.pyc
|
||||
# Sauvegardes produites par app/supporting_scripts/backup.py
|
||||
backups/
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
||||
htmlcov/
|
||||
.DS_Store
|
||||
# Journaux applicatifs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
.certs/
|
||||
*.pem
|
||||
# État d'exécution du bot Discord
|
||||
discord_pending.json
|
||||
|
||||
docs/
|
||||
*.html
|
||||
# =============================================================================
|
||||
# Éditeurs et systèmes d'exploitation
|
||||
# =============================================================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
instance/
|
||||
*.db
|
||||
# =============================================================================
|
||||
# NOTE — règles retirées volontairement
|
||||
# =============================================================================
|
||||
# `docs/` et `*.html` figuraient ici et ignoraient TOUT fichier .html du
|
||||
# dépôt, y compris les templates Jinja2 de app/templates/. Un nouveau
|
||||
# template était donc invisible pour git : l'application fonctionnait en
|
||||
# local et cassait en production avec une TemplateNotFound, sans que
|
||||
# `git status` ne signale quoi que ce soit.
|
||||
#
|
||||
# Les rapports de couverture HTML, qui étaient vraisemblablement la cible
|
||||
# de ces règles, sont couverts ci-dessus par `htmlcov/` et
|
||||
# `coverage_html_report/`.
|
||||
|
||||
# =============================================================================
|
||||
# Internationalisation
|
||||
# =============================================================================
|
||||
# Le gabarit de catalogue est entierement regenerable :
|
||||
# pybabel extract -F babel.cfg -k _l -o messages.pot .
|
||||
# Les catalogues .po (sources de traduction) et .mo (compiles, lus a
|
||||
# l'execution) sont eux versionnes : le deploiement est un simple miroir de
|
||||
# fichiers, sans etape de compilation.
|
||||
messages.pot
|
||||
|
||||
@@ -1,90 +1,153 @@
|
||||
# Plateforme centralisée de tryouts
|
||||
|
||||
## Security Configuration
|
||||
Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
|
||||
évaluations, gestion des équipes, disponibilités, contrats, et notifications
|
||||
Discord.
|
||||
|
||||
### Required Environment Variables
|
||||
Le site est servi **en français**, l'anglais reste accessible par le sélecteur
|
||||
de la barre latérale (voir `docs/translations.md`).
|
||||
|
||||
Before deploying, create a `.env` file which integrates everything in the .env.exemple.
|
||||
Ensure you follow the comments of the exemple if you are to use this tool in production.
|
||||
---
|
||||
|
||||
## Démarrer
|
||||
|
||||
### Security Features Implemented
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
|
||||
cp app/.env.example .env # puis remplir SECRET_KEY et DATABASE_URL
|
||||
.venv/Scripts/python run.py # développement, http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
|
||||
- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled
|
||||
- **CSRF Protection**: Enabled by default on all forms
|
||||
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
|
||||
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
|
||||
- **Open Redirect Prevention**: URL validation on login redirect
|
||||
- **Authorization Checks**: Proper ownership validation on all sensitive operations
|
||||
- **nginx**: reverse-proxy and load balancer
|
||||
- **Waitress WSGI**: Production ready WSGI
|
||||
`SECRET_KEY` et `DATABASE_URL` sont **obligatoires** : `create_app()` refuse
|
||||
de démarrer sans eux. `DATABASE_URL` doit pointer sur PostgreSQL ; le pilote
|
||||
psycopg 3 est nommé automatiquement si l'URL n'en nomme pas.
|
||||
|
||||
### When true in .env:
|
||||
- **Forces HTTPS only**
|
||||
- **Forcer secure cookies**
|
||||
Production : `python wsgi.py` (Waitress derrière nginx). Voir
|
||||
`docs/deployment.md`.
|
||||
|
||||
## App details
|
||||
Ce sont les **deux seuls** points d'entrée.
|
||||
|
||||
### Code
|
||||
## Vérifier
|
||||
|
||||
- Full python backend using flask
|
||||
- statics are pure HTML and CSS
|
||||
- Some js to add logic to styling and showing certain pages/cards
|
||||
```bash
|
||||
.venv/Scripts/python -m pytest # suite complète
|
||||
.venv/Scripts/python -m ruff check . # lint
|
||||
.venv/Scripts/python -m ruff format --check .
|
||||
```
|
||||
|
||||
### Functionalities
|
||||
Les trois tournent en CI et y sont bloquants.
|
||||
|
||||
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role.
|
||||
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
|
||||
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
|
||||
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
|
||||
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
|
||||
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
|
||||
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
|
||||
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
|
||||
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
|
||||
---
|
||||
|
||||
## Ce que fait l'application
|
||||
|
||||
## Discord Integration
|
||||
- **Comptes et rôles** — cinq rôles : président (`admin`), gérant
|
||||
(`manager`), coach, joueur (`player`), recruteur (`scout`). Le président
|
||||
attribue les rôles.
|
||||
- **Sélections** — organisation des tryouts, trois formats de match
|
||||
(équipe contre équipe, joueur contre joueur, scrim), évaluation des
|
||||
joueurs sur dix critères.
|
||||
- **Équipes** — effectifs de la saison, matchs et entraînements. Le
|
||||
formulaire d'entraînement affiche les disponibilités des joueurs.
|
||||
- **Disponibilités** — créneaux hebdomadaires des joueurs, créneaux
|
||||
réservables des coachs.
|
||||
- **Notes** — un coach écrit des notes d'équipe (visibles par l'équipe) et
|
||||
des notes nominatives (visibles par le joueur concerné).
|
||||
- **Un-à-un** — un joueur demande une séance à son coach ; le coach répond
|
||||
depuis le site ou par une réaction sur le message privé Discord.
|
||||
- **Contrats** — dépôt d'un contrat par le staff, signature par le joueur.
|
||||
|
||||
The application supports sending Discord direct messages to coaches when players request One on One sessions,
|
||||
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
|
||||
## Comment c'est construit
|
||||
|
||||
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request.
|
||||
Same thing with **matches** and **practices**, the players can react or answer on the platform.
|
||||
Backend Python 3.12 / Flask, rendu serveur en Jinja2, CSS et JavaScript
|
||||
maison, sans framework front. Base PostgreSQL via SQLAlchemy. Bot Discord
|
||||
(`discord.py`) dans un fil du même processus que le serveur web.
|
||||
|
||||
### Setup Instructions
|
||||
`docs/architecture.md` contient les diagrammes (classes, paquets, flux
|
||||
d'une requête).
|
||||
|
||||
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
|
||||
---
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Create a new application
|
||||
3. Go to the "Bot" tab and create a bot user
|
||||
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
|
||||
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
|
||||
## Sécurité
|
||||
|
||||
#### 2. Add Bot to your server
|
||||
En place et vérifié par des tests :
|
||||
|
||||
For the bot to send DMs:
|
||||
1. Each user must have the bot added to their Discord server OR be friends with the bot
|
||||
2. Users need to add their Discord User ID to their profile:
|
||||
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
|
||||
- Right-click on their profile → Copy ID
|
||||
- Enter this numeric ID in the "Discord User ID" field in their profile settings
|
||||
- **Limitation de débit** sur la connexion (10 requêtes/minute par IP).
|
||||
- **Cookies de session** `HttpOnly`, `SameSite=Lax`, `Secure`, avec
|
||||
expiration effective.
|
||||
- **CSRF** sur tous les formulaires, y compris la déconnexion (en POST).
|
||||
- **HTTPS** forcé en production, **HSTS**.
|
||||
- **CSP sans `unsafe-inline`** sur `script-src` : aucun gestionnaire
|
||||
d'événement en ligne, chaque bloc `<script>` porte un nonce par requête.
|
||||
- **Redirections** validées (rien ne sort du site).
|
||||
- **Validation** par schémas marshmallow sur les formulaires de compte, avec
|
||||
politique de mot de passe.
|
||||
- **Autorisation** centralisée dans `app/permissions.py`.
|
||||
- **Journal d'authentification** (`logs/auth.log`) : connexions, échecs,
|
||||
changements de rôle, suppressions de compte.
|
||||
- **Téléversements** vérifiés par extension *et* par signature de fichier.
|
||||
|
||||
Ce qui **n'est pas** fait, pour que personne ne s'y fie :
|
||||
|
||||
### How It Works
|
||||
- **Aucune migration de schéma.** `db.create_all()` crée les tables
|
||||
manquantes et ne modifie jamais une table existante : une colonne ajoutée
|
||||
à un modèle est absente de la production.
|
||||
- **Les secrets de l'historique git ne sont pas révoqués** — jeton du bot,
|
||||
mot de passe PostgreSQL, `SECRET_KEY`.
|
||||
- **`trusted_proxy='*'`** reste le défaut : l'en-tête `X-Forwarded-For` est
|
||||
accepté de n'importe quelle source, donc la limitation par IP est
|
||||
contournable. C'est désormais la variable `TRUSTED_PROXY` plutôt qu'une
|
||||
constante — `docs/deployment.md` donne la valeur pour chaque topologie.
|
||||
- **Les comptes créés par le formulaire d'inscription sont actifs
|
||||
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
|
||||
de produit en attente, voir `docs/roles-and-permissions.md`.
|
||||
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
|
||||
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
|
||||
snowflake et l'application refuse les nouvelles collisions. Les doublons
|
||||
historiques doivent être relevés puis corrigés avant la contrainte
|
||||
`UNIQUE` (`schema_report.py --check-discord-identities`).
|
||||
|
||||
When a player submits a One on One request:
|
||||
1. The system checks if the coach has a Discord User ID configured
|
||||
2. If configured, a direct message is sent to the coach via the Discord bot
|
||||
`docs/security-checklist.md` détaille la liste avant mise en production.
|
||||
|
||||
### Message Format
|
||||
### Documentation
|
||||
|
||||
The Discord DM includes:
|
||||
- Player name
|
||||
- Team name
|
||||
- Requested date and time slot
|
||||
- Discussion points (if provided)
|
||||
- Link to the application for approval/rejection
|
||||
- Two provided reactions to accept or refuse via discord
|
||||
| Document | Pour |
|
||||
|---|---|
|
||||
| `docs/deployment.md` | Installer, déployer, revenir en arrière |
|
||||
| `docs/roles-and-permissions.md` | Qui peut faire quoi, et où c'est décidé |
|
||||
| `docs/database-restore.md` | Sauvegarder et restaurer |
|
||||
| `docs/database-schema.md` | Sortir de `create_all()` : relevé, Alembic, migrations |
|
||||
| `docs/incident-runbook.md` | Quand quelque chose ne va pas |
|
||||
| `docs/architecture.md` | Diagrammes |
|
||||
| `docs/translations.md` | Ajouter ou corriger une traduction |
|
||||
| `docs/security-checklist.md` | Avant une mise en production |
|
||||
|
||||
---
|
||||
|
||||
## Intégration Discord
|
||||
|
||||
Messages privés au coach lors d'une demande d'un-à-un, aux joueurs à la
|
||||
création d'un match ou d'un entraînement les concernant, et rappel 24 h
|
||||
avant un match. Les réponses se font par réaction sur le message ou depuis
|
||||
le site.
|
||||
|
||||
L'état des messages en attente de réponse est dans `discord_pending.json`,
|
||||
**non versionné** : c'est de l'état d'exécution, propre à chaque serveur.
|
||||
|
||||
### Mise en place
|
||||
|
||||
Le bot du club existe déjà ; ce qui suit ne concerne qu'une nouvelle
|
||||
installation.
|
||||
|
||||
1. Créer une application sur le [portail développeur
|
||||
Discord](https://discord.com/developers/applications), puis un bot.
|
||||
2. Copier le jeton dans `DISCORD_BOT_TOKEN`.
|
||||
3. Activer **Message Content Intent** dans les *Privileged Gateway Intents*.
|
||||
C'est le seul intent privilégié demandé : il sert à lire le motif d'un
|
||||
refus écrit en réponse au message.
|
||||
4. Chaque personne doit partager un serveur avec le bot (ou l'avoir en ami)
|
||||
pour recevoir un message privé, et renseigner son identifiant Discord
|
||||
dans son profil (Discord → Paramètres → Avancés → Mode développeur, puis
|
||||
clic droit sur son profil → Copier l'identifiant).
|
||||
|
||||
`/health` indique si le bot tourne et s'il est connecté.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Team Tryouts — environment variables
|
||||
#
|
||||
# Copy to .env and fill in. Every value here is a PRODUCTION-SAFE default:
|
||||
# copying this file and changing nothing gives a locked-down configuration
|
||||
# that refuses to start until the two required secrets are set, rather than
|
||||
# a working one that happens to be wide open (OPS-003).
|
||||
#
|
||||
# The previous version shipped FLASK_DEBUG=true under a heading that said
|
||||
# "fill in the values for production". The Werkzeug debugger executes code
|
||||
# submitted through the browser, so that one line turned a copy-paste into a
|
||||
# remote shell.
|
||||
#
|
||||
# For local development, see the DEVELOPMENT block at the bottom.
|
||||
|
||||
# =============================================================================
|
||||
# Required — the application refuses to start without these
|
||||
# =============================================================================
|
||||
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
# Never reuse one between environments: this key signs session cookies, so
|
||||
# whoever holds it can forge a session for any account.
|
||||
SECRET_KEY=
|
||||
|
||||
# Expected form: postgresql://user:password@host:5432/database
|
||||
# The psycopg 3 driver is named for you by create_app(); postgresql:// alone
|
||||
# would send SQLAlchemy looking for psycopg2, which is not installed.
|
||||
DATABASE_URL=
|
||||
|
||||
# =============================================================================
|
||||
# Security — these defaults assume HTTPS in front. Do not relax them on a
|
||||
# deployed instance.
|
||||
# =============================================================================
|
||||
|
||||
# Session cookies are only sent over HTTPS.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# Plain HTTP is redirected to HTTPS.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# The Werkzeug debugger is a remote code execution primitive by design.
|
||||
# Never true on anything reachable from a network you do not control.
|
||||
FLASK_DEBUG=false
|
||||
|
||||
# Inline <script> without a nonce. Off: every block carries one, and turning
|
||||
# this on gives up the protection that would have blocked the stored XSS
|
||||
# (SEC-WEB-001). It exists as an escape hatch, not as a setting to tune.
|
||||
CSP_ALLOW_INLINE_SCRIPT=false
|
||||
|
||||
# Comma-separated origins allowed to call this API cross-site. Empty means
|
||||
# no CORS policy at all, which is correct: the site renders its own HTML on
|
||||
# one origin and needs none.
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# =============================================================================
|
||||
# Networking
|
||||
# =============================================================================
|
||||
|
||||
# Interface Waitress binds. 127.0.0.1 keeps it reachable only through the
|
||||
# local reverse proxy; 0.0.0.0 exposes it directly and is only correct if
|
||||
# something else in front is doing the filtering.
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
# Whether to believe X-Forwarded-For, and from whom. This decides which IP
|
||||
# the rate limiter and the audit log record.
|
||||
#
|
||||
# (empty) — trust nobody. Correct when nothing proxies the app.
|
||||
# 127.0.0.1 — trust a reverse proxy on this same machine. The usual case.
|
||||
# * — trust everyone. Only ever correct if the app cannot be reached
|
||||
# except through the proxy, at the network level. Otherwise any
|
||||
# caller can claim any IP and walk around the rate limit.
|
||||
#
|
||||
# See docs/deployment.md before changing this (OPS-002).
|
||||
TRUSTED_PROXY=127.0.0.1
|
||||
|
||||
# =============================================================================
|
||||
# Optional — Discord
|
||||
# =============================================================================
|
||||
|
||||
# Leave ENABLE_DISCORD_BOT=false and the token empty to run without Discord.
|
||||
ENABLE_DISCORD_BOT=false
|
||||
DISCORD_BOT_TOKEN=
|
||||
|
||||
# OAuth2, for "Connect Discord" on the sign-up page.
|
||||
# Create an application at https://discord.com/developers/applications
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||
|
||||
# =============================================================================
|
||||
# Optional — storage
|
||||
# =============================================================================
|
||||
|
||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
||||
# to a release-directory layout, or a deployment will take the documents with
|
||||
# it (OPS-011, app/storage.py).
|
||||
#
|
||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
||||
# did not, and archived `./documents` regardless — so setting this here and
|
||||
# nowhere else produced empty contract backups that still exited 0.
|
||||
DOCUMENTS_ROOT=
|
||||
|
||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
||||
# defaults are anchored on the application, not on the directory the process
|
||||
# was started from, which is what they used to be (OBS-006).
|
||||
LOG_DIR=
|
||||
|
||||
# Where the backup script writes its archives. Empty means `backups/` beside
|
||||
# the application.
|
||||
BACKUP_DIR=
|
||||
|
||||
# =============================================================================
|
||||
# Optional — rate limiting
|
||||
# =============================================================================
|
||||
|
||||
# Where the rate limiter keeps its counters. Empty means `memory://`, which
|
||||
# is correct for a single Waitress process and is what this deployment runs.
|
||||
#
|
||||
# Set it to a shared backend (redis://…) BEFORE running more than one worker:
|
||||
# in-memory counters are per-process, so N workers let through N times every
|
||||
# configured limit, with nothing to show for it in the logs.
|
||||
#
|
||||
# Note that shared storage does not by itself make the limits sound: they are
|
||||
# keyed on the client IP, which is forgeable until TRUSTED_PROXY is set
|
||||
# correctly (SEC-WEB-002 / OPS-002 — see above).
|
||||
RATELIMIT_STORAGE_URI=
|
||||
|
||||
# Tables are created at startup when missing. Set to false once Alembic owns
|
||||
# the schema (DB-002/DB-004): create_all() never ALTERs, so a column added to
|
||||
# a model is silently absent from an existing database.
|
||||
AUTO_CREATE_TABLES=true
|
||||
|
||||
# =============================================================================
|
||||
# DEVELOPMENT ONLY — the values to change on a laptop, and nowhere else
|
||||
# =============================================================================
|
||||
#
|
||||
# FLASK_DEBUG=true reloader and interactive debugger
|
||||
# SESSION_COOKIE_SECURE=false cookies over plain HTTP
|
||||
# FORCE_HTTPS=false no redirect to HTTPS
|
||||
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||
#
|
||||
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||
# development session cannot accidentally inherit a production binding.
|
||||
@@ -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
|
||||
+454
-99
@@ -5,16 +5,64 @@ the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||
from flask_cors import CORS
|
||||
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
import secrets
|
||||
|
||||
import markupsafe
|
||||
from dotenv import load_dotenv
|
||||
from flask import (
|
||||
Flask,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from app import i18n
|
||||
from app.extensions import babel, csrf, db, limiter, login_manager
|
||||
from app.pagination import page_url
|
||||
|
||||
load_dotenv()
|
||||
|
||||
#: Fallback for requests that never reached a view: a 404 on an unrouted
|
||||
#: path has no endpoint to read a mark off, and `/users/api/typo` should
|
||||
#: still answer a fetch() in JSON.
|
||||
#:
|
||||
#: Not the primary mechanism. Seven error handlers each carried their own
|
||||
#: copy of a list like this (STD-09); the copies had drifted, and all seven
|
||||
#: were missing the same endpoints. Views now mark themselves — see
|
||||
#: `app/api.py` for why a prefix list could not have been made correct.
|
||||
JSON_URL_PREFIXES = (
|
||||
'/users/disponibilities',
|
||||
'/users/coach-availability',
|
||||
'/users/api/',
|
||||
'/matches/api/',
|
||||
'/team-matches/api/',
|
||||
)
|
||||
|
||||
|
||||
def wants_json_response():
|
||||
"""Whether this request must be answered with JSON rather than an HTML page.
|
||||
|
||||
Three signals, in order of authority: the view said so (`@json_endpoint`),
|
||||
the path is under a JSON prefix (for requests that matched no view at
|
||||
all), or the caller asked for JSON and nothing else.
|
||||
"""
|
||||
view = current_app.view_functions.get(request.endpoint) if request.endpoint else None
|
||||
if getattr(view, 'returns_json', False):
|
||||
return True
|
||||
if request.path.startswith(JSON_URL_PREFIXES):
|
||||
return True
|
||||
accept = request.accept_mimetypes
|
||||
return accept.best == 'application/json' and not accept.accept_html
|
||||
|
||||
|
||||
def nl2br(value):
|
||||
"""Convert newlines to HTML line breaks.
|
||||
@@ -26,13 +74,103 @@ def nl2br(value):
|
||||
Markup: HTML-safe string with line breaks.
|
||||
"""
|
||||
if value:
|
||||
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
|
||||
# Markup('<br>').join() escapes each segment before joining.
|
||||
# Markup('<br>'.join(...)) would mark attacker-controlled text as safe.
|
||||
return markupsafe.Markup('<br>').join(str(value).splitlines())
|
||||
return ''
|
||||
|
||||
|
||||
def create_app():
|
||||
def normalise_database_url(url):
|
||||
"""Name the PostgreSQL driver explicitly in a connection URL.
|
||||
|
||||
`postgresql://…` does not mean "whichever driver is installed": it means
|
||||
psycopg2, which SQLAlchemy imports at create_engine() time. requirements
|
||||
.txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean
|
||||
install starting against the URL Render hands out — and the one this
|
||||
project's own documentation shows — raises
|
||||
|
||||
ModuleNotFoundError: No module named 'psycopg2'
|
||||
|
||||
before the first request. Anything with a driver already spelled out
|
||||
(`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so
|
||||
naming psycopg2 stays possible for an environment that has it.
|
||||
|
||||
`postgres://` is the legacy alias several hosts still emit; SQLAlchemy
|
||||
dropped it in 1.4.
|
||||
|
||||
Args:
|
||||
url: Value of DATABASE_URL, or None.
|
||||
|
||||
Returns:
|
||||
str | None: The URL, with a driver named when it was PostgreSQL.
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
scheme, separator, rest = url.partition('://')
|
||||
if not separator or '+' in scheme:
|
||||
return url
|
||||
if scheme in ('postgres', 'postgresql'):
|
||||
return f'postgresql+psycopg://{rest}'
|
||||
return url
|
||||
|
||||
|
||||
def build_csp(*, allow_inline_script, nonce=None):
|
||||
"""Assemble the Content-Security-Policy header.
|
||||
|
||||
Two mutually exclusive modes, and they really are exclusive.
|
||||
|
||||
Under CSP level 3, a browser that understands nonces **ignores
|
||||
'unsafe-inline' entirely as soon as a nonce is present**. Emitting both
|
||||
would therefore not be a gentle transition: it would drop every inline
|
||||
script and every onclick attribute at once, in modern browsers only.
|
||||
The switch has to be atomic, which is why one flag drives it.
|
||||
|
||||
While allow_inline_script is true no nonce is emitted at all, so adding
|
||||
nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless.
|
||||
|
||||
Flipping the flag requires every inline event handler to be gone first.
|
||||
A nonce cannot authorise an onclick attribute — nonces apply to script
|
||||
elements, never to handler attributes. See tests/test_csp.py, which
|
||||
tracks how many are left.
|
||||
|
||||
Args:
|
||||
allow_inline_script: Keep 'unsafe-inline' in script-src.
|
||||
nonce: Per-request nonce, used only when inline script is not allowed.
|
||||
|
||||
Returns:
|
||||
str: The header value.
|
||||
"""
|
||||
if allow_inline_script:
|
||||
script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
else:
|
||||
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
|
||||
|
||||
return '; '.join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
f'script-src {script_src}',
|
||||
# style-src is a separate migration: inline style="" attributes are
|
||||
# spread across the templates and are not an XSS vector on their own.
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
|
||||
"font-src 'self' https://cdnjs.cloudflare.com",
|
||||
"img-src 'self' data: https://cdn.discordapp.com",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
"""Create and configure the Flask application.
|
||||
|
||||
Args:
|
||||
config: Optional mapping of configuration overrides, applied after the
|
||||
environment defaults and before validation. This is what makes the
|
||||
factory usable from tests: pass a throwaway database URI, a dummy
|
||||
secret, and turn off the Discord bot, without touching os.environ.
|
||||
|
||||
Initializes Flask with:
|
||||
- Secret key for session security
|
||||
- Database configuration
|
||||
@@ -52,30 +190,103 @@ def create_app():
|
||||
Flask: Configured Flask application instance.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Cache-busting stamps for static files, filled lazily by the url_defaults
|
||||
# hook below. Per application instance, so the test suite does not carry
|
||||
# one app's mtimes into the next.
|
||||
_static_stamps = {}
|
||||
|
||||
# --- defaults from the environment ------------------------------------
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '')
|
||||
app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true'
|
||||
|
||||
# Now false: every inline event handler has been replaced by a
|
||||
# data-action attribute dispatched from main.js, so script-src no longer
|
||||
# needs 'unsafe-inline'. Inline <script> blocks carry a per-request
|
||||
# nonce. The escape hatch remains for a deployment that hits an
|
||||
# overlooked handler — but leaving it on gives up the protection that
|
||||
# would have blocked SEC-XSS-001.
|
||||
app.config['CSP_ALLOW_INLINE_SCRIPT'] = (
|
||||
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Internationalisation. French is the site's primary language.
|
||||
app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE
|
||||
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'translations'
|
||||
)
|
||||
|
||||
# Side effects of create_app(), both on by default so that production and
|
||||
# development behave exactly as before. Tests turn them off.
|
||||
app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
|
||||
app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
|
||||
|
||||
# Where the rate limiter keeps its counters (SEC-WEB-004).
|
||||
#
|
||||
# `memory://` is what Flask-Limiter falls back to when nothing is set,
|
||||
# and it is the correct choice here: Waitress serves this application
|
||||
# from one process, so one set of counters in that process is all there
|
||||
# is to share. Naming it changes nothing at runtime and two things
|
||||
# otherwise — it stops being an accident, and it becomes settable to a
|
||||
# Redis URI on the day the deployment gains a second process, which is
|
||||
# the day in-memory counters would start letting through N times the
|
||||
# configured limit without anyone noticing.
|
||||
#
|
||||
# What this does not fix: the counters are keyed on an IP address that
|
||||
# is forgeable while TRUSTED_PROXY is unresolved (OPS-002). Shared
|
||||
# storage for a forgeable key buys nothing, which is why that one is
|
||||
# the prerequisite and not this.
|
||||
app.config['RATELIMIT_STORAGE_URI'] = os.getenv('RATELIMIT_STORAGE_URI', 'memory://')
|
||||
|
||||
# --- caller overrides win ---------------------------------------------
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
# --- validation, after overrides so tests can supply their own ---------
|
||||
if not app.config['SECRET_KEY']:
|
||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
||||
if not app.config['SQLALCHEMY_DATABASE_URI']:
|
||||
raise RuntimeError(
|
||||
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
|
||||
)
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
|
||||
# After the overrides, so a caller-supplied URL is normalised too.
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = normalise_database_url(
|
||||
app.config['SQLALCHEMY_DATABASE_URI']
|
||||
)
|
||||
|
||||
# File upload size limit (16 MB)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||
|
||||
# Secure session cookie settings
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
app.config['SESSION_COOKIE_SECURE'] = (
|
||||
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
)
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||
|
||||
# Configure CORS - restrict to specific origins in production
|
||||
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||
allowed_origins = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',')
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
||||
|
||||
|
||||
# CORS is only configured when origins are named explicitly.
|
||||
#
|
||||
# The previous else-branch called CORS(app, supports_credentials=True)
|
||||
# with no origins argument. flask-cors then defaults to '*' and, because
|
||||
# credentials are allowed, echoes back whatever Origin the caller sent
|
||||
# together with Access-Control-Allow-Credentials: true — the opposite of
|
||||
# the "allow all (development) or none (production)" the comment claimed.
|
||||
#
|
||||
# Exploitation was blocked by SESSION_COOKIE_SAMESITE = 'Lax', which stops
|
||||
# the browser attaching the session cookie to a cross-site fetch. That is
|
||||
# a single setting standing between a misconfiguration and a cross-origin
|
||||
# data leak. This application renders server-side HTML on one origin and
|
||||
# needs no CORS policy at all.
|
||||
if allowed_origins:
|
||||
CORS(
|
||||
app,
|
||||
@@ -84,33 +295,122 @@ def create_app():
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600, # Cache preflight for 1 hour
|
||||
)
|
||||
else:
|
||||
# When no origins specified, allow all (development) or none (production)
|
||||
# In production with a reverse proxy, CORS is handled at the Nginx level
|
||||
CORS(
|
||||
app,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600,
|
||||
)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
# Said once, at startup, because the failure mode is silent: counters in
|
||||
# process memory are lost on every restart and are not shared, so a
|
||||
# second worker would double every limit and nothing would report it.
|
||||
if app.config['RATELIMIT_STORAGE_URI'].startswith('memory://'):
|
||||
app.logger.info(
|
||||
'Rate limiting counters are held in process memory. Correct for a '
|
||||
'single-process deployment; set RATELIMIT_STORAGE_URI to a shared '
|
||||
'backend before running more than one worker (SEC-WEB-004).'
|
||||
)
|
||||
else:
|
||||
app.logger.info(
|
||||
'Rate limiting counters are held in a shared backend (%s).',
|
||||
app.config['RATELIMIT_STORAGE_URI'].split('://', 1)[0],
|
||||
)
|
||||
|
||||
babel.init_app(app, locale_selector=i18n.select_locale)
|
||||
|
||||
# Exposed to every template so the language switcher can render itself
|
||||
# without each view having to pass the list along.
|
||||
@app.before_request
|
||||
def generate_csp_nonce():
|
||||
# Only meaningful once inline script is disallowed; generated
|
||||
# unconditionally so templates can carry nonce="" beforehand.
|
||||
g.csp_nonce = secrets.token_urlsafe(16)
|
||||
|
||||
@app.before_request
|
||||
def assign_request_id():
|
||||
"""Give this request a name, so its log lines can be found (OBS-005).
|
||||
|
||||
Every record emitted while handling it carries this id — see
|
||||
RequestIdFilter — which is what turns "an error happened around
|
||||
14:32" into the six lines that led to it. It goes back in
|
||||
X-Request-Id and onto the 500 page, so that a report of "it broke
|
||||
when I clicked save" is enough to find the trace.
|
||||
|
||||
Generated here, never taken from an inbound header: with no trusted
|
||||
proxy settled (OPS-002), an accepted header lets any caller write
|
||||
arbitrary text — newlines included — into the log file.
|
||||
"""
|
||||
g.request_id = secrets.token_hex(8)
|
||||
|
||||
@app.after_request
|
||||
def expose_request_id(response):
|
||||
response.headers['X-Request-Id'] = g.get('request_id', '-')
|
||||
return response
|
||||
|
||||
@app.url_defaults
|
||||
def version_static_urls(endpoint, values):
|
||||
"""Stamp every static URL with the file's modification time.
|
||||
|
||||
Without this, nginx cannot be allowed to cache style.css and main.js:
|
||||
their URLs never change, so a 30-day expiry means a 30-day-old stylesheet
|
||||
with no way to invalidate it short of telling people to hard-refresh.
|
||||
With it, a deployed file gets a new URL and the old entry simply stops
|
||||
being asked for — which is what makes the `immutable` in nginx.conf
|
||||
true rather than merely fast (PERF-006).
|
||||
|
||||
The stamp is computed once per file per process. The process restarts
|
||||
on deploy, which is exactly when a file can have changed.
|
||||
"""
|
||||
if endpoint != 'static' or 'filename' not in values:
|
||||
return
|
||||
filename = values['filename']
|
||||
stamp = _static_stamps.get(filename)
|
||||
if stamp is None:
|
||||
try:
|
||||
stamp = str(int(os.stat(os.path.join(app.static_folder, filename)).st_mtime))
|
||||
except OSError:
|
||||
# A missing file is the template's problem, not this hook's:
|
||||
# let the URL build and let the 404 say so.
|
||||
stamp = ''
|
||||
_static_stamps[filename] = stamp
|
||||
if stamp:
|
||||
values['v'] = stamp
|
||||
|
||||
@app.context_processor
|
||||
def inject_csp_nonce():
|
||||
return {
|
||||
'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_locales():
|
||||
from flask_babel import get_locale
|
||||
|
||||
return {
|
||||
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
|
||||
'supported_locales': i18n.SUPPORTED_LOCALES,
|
||||
'locale_names': i18n.LOCALE_NAMES,
|
||||
}
|
||||
|
||||
# Used by layouts/_pagination.html. A global rather than something each
|
||||
# listing passes, because the thing that goes wrong with pagination links
|
||||
# is dropping the rest of the query string — `sort`, `order`, `team_id` —
|
||||
# and that is easier to get right once than in four templates (MNT-14).
|
||||
app.jinja_env.globals['page_url'] = page_url
|
||||
|
||||
# Configure structured logging
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
configure_logging(app)
|
||||
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.evaluations import evaluations_bp
|
||||
from app.routes.users import users_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.matches import matches_bp
|
||||
from app.routes.team_matches import team_matches_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.users import users_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
@@ -137,25 +437,20 @@ def create_app():
|
||||
HSTS is only sent in production (non-debug) to avoid breaking
|
||||
local development over plain HTTP.
|
||||
"""
|
||||
# X-XSS-Protection is deliberately not set: the auditor it addressed
|
||||
# has been removed from every current browser, and its last versions
|
||||
# introduced vulnerabilities of their own. CSP frame-ancestors and
|
||||
# X-Frame-Options cover the remaining ground.
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), '
|
||||
'interest-cohort=(), payment=(), usb=()'
|
||||
'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
|
||||
)
|
||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' https://cdnjs.cloudflare.com; "
|
||||
"img-src 'self' data: https://cdn.discordapp.com; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
response.headers['Content-Security-Policy'] = build_csp(
|
||||
allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'],
|
||||
nonce=g.get('csp_nonce'),
|
||||
)
|
||||
|
||||
# Only enable HSTS when HTTPS is actually being used
|
||||
@@ -177,11 +472,17 @@ def create_app():
|
||||
|
||||
Respects the X-Forwarded-Proto header from reverse proxies.
|
||||
Can be disabled via FORCE_HTTPS environment variable.
|
||||
|
||||
Returns:
|
||||
Response | None: A redirect, or None to let the request through.
|
||||
"""
|
||||
if not app.debug:
|
||||
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
|
||||
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true':
|
||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||
if not app.debug and app.config['FORCE_HTTPS']:
|
||||
already_secure = (
|
||||
request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
)
|
||||
if not already_secure:
|
||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Health Check Endpoint
|
||||
@@ -202,13 +503,28 @@ def create_app():
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
# The bot runs in a daemon thread inside this process. When it dies
|
||||
# the site keeps serving pages and every notification stops, with
|
||||
# nothing to see from outside — which is how it stayed unnoticed.
|
||||
# Reported, not fatal: a club without Discord reminders is degraded,
|
||||
# not down, and a 503 here would take the site out of the load
|
||||
# balancer for it (OPS-012).
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
from app.discord_bot import bot_status
|
||||
|
||||
health_data['discord_bot'] = bot_status()
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
health_data['database'] = 'connected'
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Never echo the driver error: it routinely carries the host,
|
||||
# database name and user of the connection string, and /health
|
||||
# is unauthenticated.
|
||||
app.logger.error('Health check: database unreachable', exc_info=True)
|
||||
health_data['status'] = 'unhealthy'
|
||||
health_data['database'] = f'error: {str(e)}'
|
||||
health_data['database'] = 'error'
|
||||
return jsonify(health_data), 503
|
||||
|
||||
return jsonify(health_data), 200
|
||||
@@ -216,6 +532,36 @@ def create_app():
|
||||
# =========================================================================
|
||||
# Custom Error Handlers
|
||||
# =========================================================================
|
||||
#
|
||||
# STD-09. The seven handlers below each carried their own copy of a list
|
||||
# of URL prefixes, and the copies had drifted: three of them checked
|
||||
# `/users/coach-availability`, four did not. Worse, every copy was
|
||||
# missing the same five endpoints — `/matches/api/…` and
|
||||
# `/team-matches/api/…` — so an error on any of those answered a
|
||||
# `fetch()` with an HTML error page. The browser then failed to parse it
|
||||
# as JSON and the page simply did nothing: on the calendar, the tryout
|
||||
# and team selects stayed empty with no message anywhere. A session that
|
||||
# expired mid-page produced exactly that, because the 401 handler
|
||||
# redirects to an HTML login form.
|
||||
#
|
||||
# The mechanism now lives in wants_json_response() / app/api.py, and a
|
||||
# test walks the URL map to prove no jsonify-returning view is missed.
|
||||
#
|
||||
# The handler below is the one that actually mattered. `@login_required`
|
||||
# never reaches the 401 handler: Flask-Login intercepts first and calls
|
||||
# its own unauthorized callback, which redirects. So every one of the
|
||||
# sixteen JSON endpoints answered an expired session with a 302 to an
|
||||
# HTML login form, whatever the prefix list said — and the page's
|
||||
# `fetch()` threw parsing it. Rewriting the prefix list alone would have
|
||||
# left this untouched and looked like a fix.
|
||||
@login_manager.unauthorized_handler
|
||||
def handle_unauthorized():
|
||||
"""What an unauthenticated request gets: a redirect, or a 401 in JSON."""
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Your session has expired.'}), 401
|
||||
flash(_('Please log in to access this page.'), 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
"""Handle 400 Bad Request errors.
|
||||
@@ -226,28 +572,21 @@ def create_app():
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/coach-availability') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
"""Handle 401 Unauthorized errors.
|
||||
"""Handle an explicit abort(401).
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login for pages, JSON for API.
|
||||
Rarely reached: `@login_required` is intercepted by Flask-Login
|
||||
before Flask's error handling, and answered by handle_unauthorized
|
||||
above. This covers code that aborts with 401 itself, and gives the
|
||||
same answer — the two used to differ, and the flash message here was
|
||||
the one string in the application that had never been translated.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
from flask import flash as _flash
|
||||
_flash('Please log in to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
return handle_unauthorized()
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
@@ -259,8 +598,7 @@ def create_app():
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@@ -274,8 +612,7 @@ def create_app():
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if wants_json_response():
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@@ -289,12 +626,10 @@ def create_app():
|
||||
Returns:
|
||||
Response: JSON error for API or rendered page.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Too many requests',
|
||||
'message': 'Please try again later.'
|
||||
}), 429
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{'error': 'Too many requests', 'message': 'Please try again later.'}
|
||||
), 429
|
||||
return render_template('errors/429.html', error=error), 429
|
||||
|
||||
@app.errorhandler(500)
|
||||
@@ -315,13 +650,21 @@ def create_app():
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.'
|
||||
}), 500
|
||||
return render_template('errors/500.html'), 500
|
||||
# The id is the only thing that connects a user saying "it broke when
|
||||
# I clicked save" to the stack trace in errors.log. It identifies one
|
||||
# request and nothing else — no session, no account, nothing an
|
||||
# attacker can use — so showing it costs nothing (OBS-005).
|
||||
request_id = g.get('request_id', '-')
|
||||
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.',
|
||||
'request_id': request_id,
|
||||
}
|
||||
), 500
|
||||
return render_template('errors/500.html', request_id=request_id), 500
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
@@ -333,13 +676,10 @@ def create_app():
|
||||
Returns:
|
||||
Response: JSON error for API, re-raises for others.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': error.name,
|
||||
'message': error.description,
|
||||
'code': error.code
|
||||
}), error.code
|
||||
if wants_json_response():
|
||||
return jsonify(
|
||||
{'error': error.name, 'message': error.description, 'code': error.code}
|
||||
), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
@@ -347,25 +687,40 @@ def create_app():
|
||||
# =========================================================================
|
||||
with app.app_context():
|
||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
||||
db.create_all()
|
||||
|
||||
# NOTE: create_all() only ever creates missing tables. It never adds a
|
||||
# column to an existing one, so a model change is silently absent from
|
||||
# any database that already has the table. Replacing this with Alembic
|
||||
# is tracked as DB-002/DB-004; until then the behaviour is preserved.
|
||||
if app.config['AUTO_CREATE_TABLES']:
|
||||
db.create_all()
|
||||
|
||||
# Start the Discord bot for notifications
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
start_bot(flask_app=app)
|
||||
except Exception as e:
|
||||
app.logger.warning('Could not start Discord bot: %s', e)
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
|
||||
start_bot(flask_app=app)
|
||||
except Exception: # the site must come up even if the bot cannot
|
||||
# With the message alone, the two ways this fails — a bad token
|
||||
# and a broken import in discord_bot — read identically, and
|
||||
# neither is diagnosable from one line. Notifications are down
|
||||
# either way, so the traceback is the whole value of the log.
|
||||
# Error, not warning: a club that receives no reminders has lost
|
||||
# a feature, and the old level put that next to the deprecation
|
||||
# notices.
|
||||
app.logger.error(
|
||||
'Could not start the Discord bot. The site is up; no notification '
|
||||
'will be sent until this is fixed.',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only used for development - production uses wsgi.py (Waitress)
|
||||
app = create_app()
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
if debug_mode:
|
||||
app.logger.warning(
|
||||
'Running in DEBUG mode with Flask built-in server. '
|
||||
'This is NOT suitable for production. Use wsgi.py instead.'
|
||||
)
|
||||
app.run(debug=debug_mode, host='0.0.0.0', port=10000)
|
||||
# No __main__ block here on purpose. There used to be one, and with run.py
|
||||
# and wsgi.py that made three ways to start the application, each with its
|
||||
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
|
||||
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
|
||||
# module defines the factory; run.py starts it for development, wsgi.py for
|
||||
# production (ARCH-007).
|
||||
|
||||
+1336
-567
File diff suppressed because it is too large
Load Diff
+20
-14
@@ -1,9 +1,10 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_babel import Babel
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
# Database and extension initialization
|
||||
db = SQLAlchemy()
|
||||
@@ -12,20 +13,25 @@ login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
csrf = CSRFProtect()
|
||||
|
||||
# Rate limiter for brute-force protection
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
# Internationalisation. French is the primary language of the site; English
|
||||
# stays available. See app/i18n.py for how a locale is chosen.
|
||||
babel = Babel()
|
||||
|
||||
# Rate limiter for brute-force protection.
|
||||
#
|
||||
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
|
||||
# app.config, which create_app fills from the environment and defaults to
|
||||
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""
|
||||
Hash a plain text password using werkzeug's security functions.
|
||||
|
||||
|
||||
Args:
|
||||
password (str): The plain text password to hash.
|
||||
|
||||
|
||||
Returns:
|
||||
str: The hashed password string.
|
||||
"""
|
||||
@@ -35,12 +41,12 @@ def hash_password(password):
|
||||
def check_password(password_hash, password):
|
||||
"""
|
||||
Verify a password against its hash.
|
||||
|
||||
|
||||
Args:
|
||||
password_hash (str): The stored password hash.
|
||||
password (str): The plain text password to verify.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the password matches the hash, False otherwise.
|
||||
"""
|
||||
return check_password_hash(password_hash, password)
|
||||
return check_password_hash(password_hash, password)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
+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
|
||||
+149
-29
@@ -11,59 +11,120 @@ Usage:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import re
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from app.storage import logs_root
|
||||
|
||||
#: Value used when a record is emitted outside a request — startup, the
|
||||
#: Discord bot thread, the scheduler. Short and obviously not an id, so a
|
||||
#: grep for one never matches it by accident.
|
||||
NO_REQUEST = '-'
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Stamp every record with the id of the request that produced it.
|
||||
|
||||
Without this, a 500 in errors.log and the six lines in app.log that led
|
||||
to it are related only by their timestamps, which is not a relation when
|
||||
the server is handling more than one request at a time (OBS-005).
|
||||
|
||||
The id is generated per request and never read from an inbound header.
|
||||
Accepting one would be convenient for tracing across nginx, and it would
|
||||
also let any caller write arbitrary text — newlines included — into the
|
||||
log file, which is how a log gets forged rather than read. There is no
|
||||
trusted proxy to take it from while OPS-002 is open.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
record.request_id = NO_REQUEST
|
||||
try:
|
||||
from flask import g, has_request_context
|
||||
|
||||
if has_request_context():
|
||||
record.request_id = g.get('request_id', NO_REQUEST)
|
||||
except Exception: # noqa: BLE001 — logging must never be the thing that fails
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""Logging filter that redacts sensitive information from log messages.
|
||||
|
||||
|
||||
Filters out: passwords, API keys, session tokens, and other secrets
|
||||
that might accidentally be logged.
|
||||
"""
|
||||
|
||||
# Patterns to redact
|
||||
SENSITIVE_PATTERNS = [
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'),
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'),
|
||||
(
|
||||
re.compile(
|
||||
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
|
||||
),
|
||||
'[REDACTED]',
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
|
||||
),
|
||||
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
|
||||
),
|
||||
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
||||
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
||||
]
|
||||
|
||||
def filter(self, record):
|
||||
"""Apply redaction to the log record's message.
|
||||
|
||||
"""Apply redaction to the log record's fully rendered message.
|
||||
|
||||
The record is rendered first (msg % args) and the result stored back
|
||||
as msg with args cleared. Redacting record.msg alone would miss almost
|
||||
everything: this codebase logs with %s placeholders, so the sensitive
|
||||
value lives in record.args while record.msg holds only the format
|
||||
string.
|
||||
|
||||
Args:
|
||||
record: The log record to filter.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: Always True (never drops records, only redacts).
|
||||
"""
|
||||
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
||||
msg = record.msg
|
||||
for pattern, replacement in self.SENSITIVE_PATTERNS:
|
||||
if callable(replacement):
|
||||
msg = pattern.sub(replacement, msg)
|
||||
else:
|
||||
msg = pattern.sub(replacement, msg)
|
||||
record.msg = msg
|
||||
try:
|
||||
rendered = record.getMessage()
|
||||
except Exception: # noqa: BLE001 — see below; this one cannot log its own failure
|
||||
# A malformed format string must not lose the record entirely.
|
||||
# Nor can it be logged: this runs inside a filter, and logging
|
||||
# from here re-enters the same filter on the new record. The
|
||||
# traceback BLE001 normally asks for is the one thing this
|
||||
# handler must not produce, hence the waiver.
|
||||
return True
|
||||
|
||||
for pattern, replacement in self.SENSITIVE_PATTERNS:
|
||||
rendered = pattern.sub(replacement, rendered)
|
||||
|
||||
record.msg = rendered
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure structured logging for the Flask application.
|
||||
|
||||
|
||||
Sets up three rotating file handlers:
|
||||
- errors.log: ERROR and CRITICAL level messages
|
||||
- auth.log: Authentication-related events (INFO and above)
|
||||
- app.log: All application logs (DEBUG and above, configurable)
|
||||
|
||||
|
||||
Also configures console output for development.
|
||||
|
||||
|
||||
Args:
|
||||
app: The Flask application instance to configure logging for.
|
||||
"""
|
||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
||||
# Anchored on the project, not on the working directory (OBS-006). The
|
||||
# old form put the logs wherever the process happened to be started
|
||||
# from, so a service restarted by hand from another directory quietly
|
||||
# began writing somewhere else — and the file you go looking at when
|
||||
# something is wrong is the one that must not move.
|
||||
log_dir = logs_root()
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Remove default Flask handlers to avoid duplicate logging
|
||||
@@ -76,11 +137,17 @@ def configure_logging(app):
|
||||
|
||||
# Create the sensitive data filter
|
||||
sensitive_filter = SensitiveDataFilter()
|
||||
request_id_filter = RequestIdFilter()
|
||||
|
||||
# Formatter with timestamp, level, module, and message
|
||||
# Formatter with timestamp, level, module, request id, and message.
|
||||
#
|
||||
# request_id comes from RequestIdFilter, which is attached to every
|
||||
# handler below. A handler that formats with this string and does not
|
||||
# carry the filter raises on its first record — so if one is ever added,
|
||||
# add the filter with it.
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -89,11 +156,12 @@ def configure_logging(app):
|
||||
error_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
backupCount=10,
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
error_handler.addFilter(sensitive_filter)
|
||||
error_handler.addFilter(request_id_filter)
|
||||
app.logger.addHandler(error_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -102,11 +170,12 @@ def configure_logging(app):
|
||||
auth_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'auth.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5
|
||||
backupCount=5,
|
||||
)
|
||||
auth_handler.setLevel(logging.INFO)
|
||||
auth_handler.setFormatter(formatter)
|
||||
auth_handler.addFilter(sensitive_filter)
|
||||
auth_handler.addFilter(request_id_filter)
|
||||
|
||||
# Create a named logger specifically for auth events
|
||||
auth_logger = logging.getLogger('team_tryouts.auth')
|
||||
@@ -120,22 +189,41 @@ def configure_logging(app):
|
||||
app_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
backupCount=10,
|
||||
)
|
||||
app_handler.setLevel(log_level)
|
||||
app_handler.setFormatter(formatter)
|
||||
app_handler.addFilter(sensitive_filter)
|
||||
app_handler.addFilter(request_id_filter)
|
||||
app.logger.addHandler(app_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 4. Console Handler (always enabled for debugging in both dev and production)
|
||||
# 4. Console Handler (always on)
|
||||
# -------------------------------------------------------------------------
|
||||
# Previously gated on FLASK_DEBUG, which meant production emitted nothing
|
||||
# on stdout — precisely where the Pterodactyl console looks. Keep the
|
||||
# handler unconditional and vary the level instead.
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setLevel(logging.DEBUG if debug_mode else log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(sensitive_filter)
|
||||
console_handler.addFilter(request_id_filter)
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 5. Package logger ('app.*') — notably app.discord_bot
|
||||
# -------------------------------------------------------------------------
|
||||
# Modules using logging.getLogger(__name__) resolve to 'app.<module>'.
|
||||
# Without handlers here their INFO records were dropped entirely and
|
||||
# WARNING+ fell through to Python's lastResort handler, unformatted.
|
||||
package_logger = logging.getLogger('app')
|
||||
package_logger.setLevel(log_level)
|
||||
package_logger.propagate = False
|
||||
for handler in (error_handler, app_handler, console_handler):
|
||||
if handler not in package_logger.handlers:
|
||||
package_logger.addHandler(handler)
|
||||
|
||||
# Log startup information
|
||||
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
||||
app.logger.info('Application startup')
|
||||
@@ -146,8 +234,40 @@ def configure_logging(app):
|
||||
# Module-level auth logger factory
|
||||
def get_auth_logger():
|
||||
"""Get the authentication event logger.
|
||||
|
||||
|
||||
Returns:
|
||||
logging.Logger: Logger for authentication events.
|
||||
"""
|
||||
return logging.getLogger('team_tryouts.auth')
|
||||
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))
|
||||
|
||||
@@ -92,4 +92,4 @@ from app.models.user_gamertag import UserGamertag
|
||||
from app.models.contract import Contract
|
||||
from app.models.team_note import TeamNote
|
||||
from app.models.personal_note import PersonalNote
|
||||
from app.models.one_on_one_request import OneOnOneRequest
|
||||
from app.models.one_on_one_request import OneOnOneRequest
|
||||
|
||||
+30
-16
@@ -2,24 +2,38 @@
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
org_team_coaches = db.Table('org_team_coaches',
|
||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
org_team_coaches = db.Table(
|
||||
'org_team_coaches',
|
||||
db.Column(
|
||||
'org_team_id',
|
||||
db.Integer,
|
||||
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
org_team_managers = db.Table('org_team_managers',
|
||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
org_team_managers = db.Table(
|
||||
'org_team_managers',
|
||||
db.Column(
|
||||
'org_team_id',
|
||||
db.Integer,
|
||||
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
tryout_coaches = db.Table('tryout_coaches',
|
||||
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
tryout_coaches = db.Table(
|
||||
'tryout_coaches',
|
||||
db.Column(
|
||||
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -52,11 +52,7 @@ PLATFORM_CODES = {
|
||||
'Epic': 'epic',
|
||||
}
|
||||
|
||||
PLATFORM_DEFAULTS = {
|
||||
'Apex Legends': 'pc',
|
||||
'Rainbow Six Siege': 'ubi',
|
||||
'Rocket League': 'epic'
|
||||
}
|
||||
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
|
||||
|
||||
TRN_URLS = {
|
||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||
@@ -67,4 +63,4 @@ TRN_URLS = {
|
||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
||||
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
||||
'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -9,6 +9,21 @@ def load_user(user_id):
|
||||
|
||||
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
|
||||
automatically because SQLAlchemy resolves the identity column.
|
||||
|
||||
Returns None for deactivated accounts so that disabling a user also
|
||||
invalidates the sessions they already hold. Flask-Login only consults
|
||||
is_active when login_user() is called, never when restoring a session
|
||||
from the cookie, so the check has to happen here.
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.user_model.user import User
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
try:
|
||||
pk = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
user = db.session.get(User, pk)
|
||||
if user is None or not user.is_active_account:
|
||||
return None
|
||||
return user
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Availability models — BaseAvailability and its concrete subclasses."""
|
||||
|
||||
from app.models.availability.base import BaseAvailability
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
from app.models.availability.coach_availability import CoachAvailability
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
|
||||
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
||||
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BaseAvailability(db.Model):
|
||||
"""Shared schema for player disponibilities and coach availabilities."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
day_of_week = db.Column(db.Integer, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Coach availability in 30-minute time blocks for One on One sessions."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class CoachAvailability(BaseAvailability):
|
||||
"""Coach availability in 30-minute blocks for One on One sessions."""
|
||||
|
||||
__tablename__ = 'coach_availabilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
coach = db.relationship('User', backref='coach_availabilities')
|
||||
coach = db.relationship('User', backref='coach_availabilities')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Player availability in 30-minute time blocks."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class PlayerDisponibility(BaseAvailability):
|
||||
"""Player availability in 30-minute blocks."""
|
||||
|
||||
__tablename__ = 'player_disponibilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
player = db.relationship('User', backref='disponibilities')
|
||||
player = db.relationship('User', backref='disponibilities')
|
||||
|
||||
+24
-7
@@ -1,10 +1,13 @@
|
||||
"""Contract documents for players to sign."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Contract(db.Model):
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
__tablename__ = 'contracts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -28,13 +31,29 @@ class Contract(db.Model):
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
|
||||
|
||||
def can_view(self, user):
|
||||
"""Whether this user may read the contract and download its files.
|
||||
|
||||
Two defects used to sit in the coach branch:
|
||||
|
||||
- `not self.team_id` acted as a wildcard, so any coach listed in the
|
||||
legacy OrgTeam.coach_id column could read every contract with no
|
||||
team attached — and upload_contract leaves team_id null whenever
|
||||
the player belongs to no team.
|
||||
- the lookup went through OrgTeam.coach_id only, so a coach attached
|
||||
through the many-to-many relationship saw nothing at all.
|
||||
|
||||
Access now follows the same rule as everywhere else: the coach and
|
||||
the player must actually work together.
|
||||
"""
|
||||
if user.id == self.player_id:
|
||||
return True
|
||||
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.user import User
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.permissions import coach_can_access_player
|
||||
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
@@ -42,10 +61,8 @@ class Contract(db.Model):
|
||||
if player and player.get_org_teams():
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
if org_team and (not self.team_id or self.team_id == org_team.id):
|
||||
return True
|
||||
return coach_can_access_player(user, self.player_id)
|
||||
return False
|
||||
|
||||
def can_upload_signed(self, user):
|
||||
return user.id == self.player_id
|
||||
return user.id == self.player_id
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Player evaluation record."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Player evaluation record."""
|
||||
|
||||
__tablename__ = 'evaluations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
@@ -27,4 +30,50 @@ class Evaluation(db.Model):
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||
)
|
||||
)
|
||||
|
||||
#: The nine criteria, in the order the form shows them. The overall score
|
||||
#: is their mean; a criterion left blank is left out of the mean rather
|
||||
#: than counted as a zero, which is why this list exists rather than the
|
||||
#: route summing nine named variables (ARCH-005, QUA-003).
|
||||
CRITERIA = (
|
||||
'mecanics_score',
|
||||
'cohesion_score',
|
||||
'communication_score',
|
||||
'gamesense_score',
|
||||
'versatility_score',
|
||||
'discipline_score',
|
||||
'analysis_score',
|
||||
'sport_ethics_score',
|
||||
'mental_score',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def overall_from(cls, scores):
|
||||
"""Mean of the criteria that were actually filled in.
|
||||
|
||||
Args:
|
||||
scores: Mapping of criterion name to score or None.
|
||||
|
||||
Returns:
|
||||
float | None: None when nothing was scored — which is not the
|
||||
same as zero, and must not become one. A player nobody could
|
||||
assess has no overall score; a player who scored zero on
|
||||
everything cannot exist, the scale starts at one.
|
||||
"""
|
||||
given = [scores.get(name) for name in cls.CRITERIA]
|
||||
given = [score for score in given if score is not None]
|
||||
if not given:
|
||||
return None
|
||||
return sum(given) / len(given)
|
||||
|
||||
def apply_scores(self, scores):
|
||||
"""Write these criteria onto the record and recompute the overall.
|
||||
|
||||
Every criterion is assigned, including the ones left blank: an edit
|
||||
that clears a score has to clear it, and the mean has to be the mean
|
||||
of what is on the record afterwards.
|
||||
"""
|
||||
for name in self.CRITERIA:
|
||||
setattr(self, name, scores.get(name))
|
||||
self.overall_score = self.overall_from(scores)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Match models — BaseMatch and its concrete subclasses."""
|
||||
|
||||
from app.models.match_model.base import BaseMatch
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.match_model.team_match import TeamMatch
|
||||
|
||||
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
|
||||
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BaseMatch(db.Model):
|
||||
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
@@ -15,4 +18,4 @@ class BaseMatch(db.Model):
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='scheduled')
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Match / scrimmage within a tryout."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class Match(BaseMatch):
|
||||
"""Match / scrimmage within a tryout."""
|
||||
|
||||
__tablename__ = 'matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
@@ -16,7 +18,13 @@ class Match(BaseMatch):
|
||||
tryout = db.relationship('Tryout', backref='matches')
|
||||
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
|
||||
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
|
||||
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
|
||||
# delete-orphan: without it, SQLAlchemy tries to detach participants by
|
||||
# setting match_id to NULL, which the NOT NULL column refuses — so
|
||||
# deleting any match that had participants raised IntegrityError.
|
||||
# TeamMatch.participants already declared this; Match did not.
|
||||
participants = db.relationship(
|
||||
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_participating_players(self):
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class TeamMatch(BaseMatch):
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
|
||||
__tablename__ = 'team_matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
@@ -13,10 +15,10 @@ class TeamMatch(BaseMatch):
|
||||
org_team = db.relationship('OrgTeam', backref='team_matches')
|
||||
creator = db.relationship('User', backref='created_team_matches')
|
||||
participants = db.relationship(
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_confirmed_count(self):
|
||||
all_p = self.participants.all()
|
||||
confirmed = sum(1 for p in all_p if p.is_confirmed)
|
||||
return confirmed, len(all_p)
|
||||
return confirmed, len(all_p)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Request from player to coach for a One on One session."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class OneOnOneRequest(db.Model):
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
__tablename__ = 'one_on_one_requests'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -22,4 +25,4 @@ class OneOnOneRequest(db.Model):
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
|
||||
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Organisation team models."""
|
||||
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
__all__ = ['OrgTeam', 'TeamPlayer']
|
||||
__all__ = ['OrgTeam', 'TeamPlayer']
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import org_team_coaches, org_team_managers
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class OrgTeam(db.Model):
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
__tablename__ = 'org_teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
@@ -17,20 +20,30 @@ class OrgTeam(db.Model):
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
coaches = db.relationship(
|
||||
'User', secondary=org_team_coaches, lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'))
|
||||
'User',
|
||||
secondary=org_team_coaches,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'),
|
||||
)
|
||||
managers = db.relationship(
|
||||
'User', secondary=org_team_managers, lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'))
|
||||
'User',
|
||||
secondary=org_team_managers,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'),
|
||||
)
|
||||
|
||||
coach = db.relationship(
|
||||
'User', foreign_keys=[coach_id],
|
||||
'User',
|
||||
foreign_keys=[coach_id],
|
||||
backref=db.backref('coached_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
viewonly=True,
|
||||
)
|
||||
manager = db.relationship(
|
||||
'User', foreign_keys=[manager_id],
|
||||
'User',
|
||||
foreign_keys=[manager_id],
|
||||
backref=db.backref('managed_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
def get_coaches(self):
|
||||
coach_list = self.coaches.all()
|
||||
@@ -49,5 +62,7 @@ class OrgTeam(db.Model):
|
||||
return [tp.player for tp in self.team_players]
|
||||
|
||||
def get_players_with_status(self):
|
||||
return [{'player': tp.player, 'status': tp.status,
|
||||
'position': tp.position} for tp in self.team_players]
|
||||
return [
|
||||
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
||||
for tp in self.team_players
|
||||
]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Many-to-many junction: player to org-team."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TeamPlayer(db.Model):
|
||||
"""Many-to-many: player to org-team."""
|
||||
|
||||
__tablename__ = 'team_players'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -18,4 +21,4 @@ class TeamPlayer(db.Model):
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Participant models — BaseParticipant and its concrete subclasses."""
|
||||
|
||||
from app.models.participant.base import BaseParticipant
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||
|
||||
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
|
||||
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Abstract base class for match participant models."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BaseParticipant(db.Model):
|
||||
"""Shared schema for match participants."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Participant in a tryout-scoped match."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class MatchParticipant(BaseParticipant):
|
||||
"""Participant in a tryout-scoped match."""
|
||||
|
||||
__tablename__ = 'match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
||||
@@ -12,4 +14,4 @@ class MatchParticipant(BaseParticipant):
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
attendance_confirmed = db.Column(db.Boolean, default=False)
|
||||
|
||||
player = db.relationship('User')
|
||||
player = db.relationship('User')
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Participant in a regular-season team match."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class TeamMatchParticipant(BaseParticipant):
|
||||
"""Participant in a regular-season team match."""
|
||||
|
||||
__tablename__ = 'team_match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
|
||||
is_confirmed = db.Column(db.Boolean, default=False)
|
||||
|
||||
player = db.relationship('User')
|
||||
player = db.relationship('User')
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Personal notes from coach to individual player."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class PersonalNote(db.Model):
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
__tablename__ = 'personal_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -21,4 +24,4 @@ class PersonalNote(db.Model):
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
match = db.relationship('Match', foreign_keys=[match_id])
|
||||
team = db.relationship('Team', foreign_keys=[team_id])
|
||||
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
|
||||
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tryout-specific temporary team models."""
|
||||
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
__all__ = ['Team', 'TeamMember']
|
||||
__all__ = ['Team', 'TeamMember']
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
__tablename__ = 'teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
@@ -13,4 +16,4 @@ class Team(db.Model):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = db.relationship('User', backref='created_teams')
|
||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TeamMember(db.Model):
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
__tablename__ = 'team_members'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
@@ -12,4 +15,4 @@ class TeamMember(db.Model):
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Team improvement notes from coach."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TeamNote(db.Model):
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
__tablename__ = 'team_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
@@ -14,4 +17,4 @@ class TeamNote(db.Model):
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
team = db.relationship('OrgTeam', backref='team_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tryout models."""
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
__all__ = ['Tryout', 'TryoutRegistration']
|
||||
__all__ = ['Tryout', 'TryoutRegistration']
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import tryout_coaches
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Tryout(db.Model):
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
__tablename__ = 'tryouts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
@@ -19,7 +22,9 @@ class Tryout(db.Model):
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
||||
coach_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||
@@ -29,13 +34,16 @@ class Tryout(db.Model):
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
|
||||
target_org_team = db.relationship(
|
||||
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_ended(self):
|
||||
"""Tryout is considered ended after its end_date passes.
|
||||
Falls back to date if end_date is not set."""
|
||||
from datetime import date as date_type
|
||||
|
||||
today = date_type.today()
|
||||
if self.end_date is not None:
|
||||
return self.end_date < today
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Registration linking a player to a tryout."""
|
||||
from app.extensions import db
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class TryoutRegistration(db.Model):
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
__tablename__ = 'tryout_registrations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
status = db.Column(db.String(20), default='registered')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Store gamertag per game for each user."""
|
||||
from app.extensions import db
|
||||
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._constants import PLATFORM_CODES, PLATFORM_DEFAULTS, TRN_URLS
|
||||
|
||||
|
||||
class UserGamertag(db.Model):
|
||||
"""Store gamertag per game for each user."""
|
||||
|
||||
__tablename__ = 'user_gamertags'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -15,9 +18,7 @@ class UserGamertag(db.Model):
|
||||
|
||||
user = db.relationship('User', backref='gamertags')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
|
||||
)
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
|
||||
|
||||
def get_trn_url(self):
|
||||
if self.game not in TRN_URLS:
|
||||
@@ -34,11 +35,11 @@ class UserGamertag(db.Model):
|
||||
platform.lower().replace(' ', '-') if platform else '',
|
||||
)
|
||||
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
||||
elif '{platform}' in url and '{username}' in url:
|
||||
if '{platform}' in url and '{username}' in url:
|
||||
return url.format(
|
||||
platform=platform.lower().replace(' ', '-') if platform else '',
|
||||
username=encoded_gamertag,
|
||||
)
|
||||
elif '{username}' in url:
|
||||
if '{username}' in url:
|
||||
return url.format(username=encoded_gamertag)
|
||||
return url
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.player import Player
|
||||
from app.models.user_model.scout import Scout
|
||||
from app.models.user_model.user import User
|
||||
|
||||
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Admin / President — full access to everything."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Admin(User):
|
||||
"""President / super-admin — full access to everything."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'admin'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -29,4 +31,5 @@ class Admin(User):
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Coach(User):
|
||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams.
|
||||
|
||||
Every question about *which* teams or tryouts belong to this coach is
|
||||
delegated to ``app.permissions``. Two of the three methods below used to
|
||||
answer it themselves, each considering a different subset of the two
|
||||
ways a coach can be attached to a team: ``can_manage_this_tryout``
|
||||
ignored the legacy ``coach_id`` column when checking the target team,
|
||||
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
|
||||
by that column therefore saw an empty calendar (ARCH-002).
|
||||
"""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -17,38 +27,16 @@ class Coach(User):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
if tryout.target_org_team_id:
|
||||
is_coach_of_target = OrgTeam.query.filter(
|
||||
OrgTeam.id == tryout.target_org_team_id,
|
||||
OrgTeam.coaches.any(id=self.id),
|
||||
).first() is not None
|
||||
if is_coach_of_target:
|
||||
return True
|
||||
# Check many-to-many coaches relationship
|
||||
if any(c.id == self.id for c in tryout.coaches):
|
||||
return True
|
||||
# Backward compat: check deprecated coach_id
|
||||
if tryout.coach_id == self.id:
|
||||
return True
|
||||
return False
|
||||
from app.permissions import coach_manages_tryout
|
||||
|
||||
return coach_manages_tryout(self, tryout)
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
if org_team.coaches.filter_by(id=self.id).first():
|
||||
return True
|
||||
if org_team.coach_id == self.id:
|
||||
return True
|
||||
return False
|
||||
return org_team.coach_id == self.id
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models._associations import tryout_coaches
|
||||
team_ids = [t.id for t in self.coached_org_teams.all()]
|
||||
conditions = []
|
||||
if team_ids:
|
||||
conditions.append(Tryout.target_org_team_id.in_(team_ids))
|
||||
# Check many-to-many coaches
|
||||
conditions.append(Tryout.coaches.any(id=self.id))
|
||||
# Backward compat: check deprecated coach_id
|
||||
conditions.append(Tryout.coach_id == self.id)
|
||||
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
|
||||
from app.permissions import coach_tryouts
|
||||
|
||||
return coach_tryouts(self)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Manager(User):
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'manager'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -25,8 +27,12 @@ class Manager(User):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from sqlalchemy import or_
|
||||
return Tryout.query.filter(
|
||||
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)
|
||||
).order_by(Tryout.date).all()
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return (
|
||||
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1,30 +1,44 @@
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Player(User):
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'player'}
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
# tryouts they registered for
|
||||
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
||||
tryouts = Tryout.query.filter(
|
||||
Tryout.id.in_(player_tryout_ids)
|
||||
).order_by(Tryout.date).all() if player_tryout_ids else []
|
||||
tryouts = (
|
||||
Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
|
||||
if player_tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
# plus tryouts where they participate in a match
|
||||
player_matches = Match.query.join(MatchParticipant).filter(
|
||||
MatchParticipant.player_id == self.id,
|
||||
).all()
|
||||
extra_ids = set(m.tryout_id for m in player_matches)
|
||||
extra = Tryout.query.filter(
|
||||
Tryout.id.in_(extra_ids),
|
||||
).order_by(Tryout.date).all() if extra_ids else []
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == self.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
extra_ids = {m.tryout_id for m in player_matches}
|
||||
extra = (
|
||||
Tryout.query.filter(
|
||||
Tryout.id.in_(extra_ids),
|
||||
)
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
if extra_ids
|
||||
else []
|
||||
)
|
||||
|
||||
all_ids = {t.id for t in tryouts}
|
||||
return tryouts + [t for t in extra if t.id not in all_ids]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Scout(User):
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'scout'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -11,4 +13,5 @@ class Scout(User):
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Base User model — shared fields and polymorphic configuration."""
|
||||
from app.extensions import db
|
||||
from flask_login import UserMixin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_login import UserMixin
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""Base user model — shared fields for every role.
|
||||
@@ -10,6 +13,7 @@ class User(UserMixin, db.Model):
|
||||
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
||||
or Scout so that `polymorphic_identity` is set correctly.
|
||||
"""
|
||||
|
||||
__tablename__ = 'users'
|
||||
|
||||
# --- columns -----------------------------------------------------------
|
||||
@@ -27,7 +31,7 @@ class User(UserMixin, db.Model):
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# E-Sports fields
|
||||
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
|
||||
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
|
||||
discord_username = db.Column(db.String(128), nullable=True)
|
||||
discord_user_id = db.Column(db.String(64), nullable=True)
|
||||
league_os_profile = db.Column(db.String(256), nullable=True)
|
||||
@@ -40,16 +44,26 @@ class User(UserMixin, db.Model):
|
||||
|
||||
# --- relationships (defined once on the base) --------------------------
|
||||
evaluations_given = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id',
|
||||
backref='evaluator', lazy='dynamic')
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
|
||||
)
|
||||
evaluations_received = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.player_id',
|
||||
backref='player', lazy='dynamic')
|
||||
tryout_registrations = db.relationship(
|
||||
'TryoutRegistration', backref='player', lazy='dynamic')
|
||||
'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
|
||||
)
|
||||
tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
|
||||
team_assignments = db.relationship(
|
||||
'TeamMember', foreign_keys='TeamMember.player_id',
|
||||
backref='player_ref', lazy='dynamic')
|
||||
'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
|
||||
)
|
||||
|
||||
# --- Flask-Login integration -------------------------------------------
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Whether Flask-Login should accept this account.
|
||||
|
||||
UserMixin returns True unconditionally, which meant a deactivated
|
||||
account kept any session it already held. Binding this to
|
||||
is_active_account makes deactivation take effect on the next request.
|
||||
"""
|
||||
return bool(self.is_active_account)
|
||||
|
||||
# --- shared helper methods ---------------------------------------------
|
||||
def get_games_list(self):
|
||||
@@ -60,8 +74,9 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def get_gamertags(self):
|
||||
"""Return gamertags as a dict keyed by game."""
|
||||
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
|
||||
for gt in self.gamertags}
|
||||
return {
|
||||
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
|
||||
}
|
||||
|
||||
def get_org_teams(self):
|
||||
"""Return all OrgTeams this player belongs to."""
|
||||
|
||||
+48
-12
@@ -110,7 +110,8 @@ http {
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
# X-XSS-Protection intentionally omitted: deprecated, removed from
|
||||
# current browsers, and harmful in its last implementations.
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
@@ -118,9 +119,18 @@ http {
|
||||
# ---------------------------------------------------------------------
|
||||
# Proxy to Waitress (Flask)
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# 10000 is `PORT`'s default in wsgi.py, which is what serves this
|
||||
# application in production. This line said 5000 — run.py's default,
|
||||
# the development server — so anyone installing this file as shipped
|
||||
# got 502 Bad Gateway on every page, from a configuration that looks
|
||||
# entirely reasonable (STD-06).
|
||||
#
|
||||
# If PORT is set in the server's .env, this must match it.
|
||||
# tests/test_nginx_config.py fails if this drifts from wsgi.py again.
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
|
||||
proxy_pass http://127.0.0.1:10000;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -142,15 +152,41 @@ http {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Static Files (served directly by Nginx for performance)
|
||||
# Uncomment and adjust path if you want Nginx to serve static files
|
||||
# Static Files (PERF-006)
|
||||
#
|
||||
# 59 KB of CSS and JS on every page load, previously proxied through
|
||||
# Waitress. Nginx serves them from disk instead.
|
||||
#
|
||||
# ADJUST THIS ONE PATH to the deployment's checkout, absolute, forward
|
||||
# slashes even on Windows. Nginx resolves a relative path against its
|
||||
# own install prefix, not against this file. The trailing slash on both
|
||||
# the location and the alias is required: without it /static/css/x.css
|
||||
# resolves one directory too high.
|
||||
#
|
||||
# `immutable` is safe here and only here: url_for('static', …) appends
|
||||
# ?v=<mtime> (see version_static_urls in app/app.py), so a deployed file
|
||||
# is requested under a new URL and the cached copy of the old one is
|
||||
# never asked for again. Removing that stamp and leaving this block
|
||||
# gives every visitor a month-old stylesheet.
|
||||
# ---------------------------------------------------------------------
|
||||
# location /static/ {
|
||||
# alias C:/path/to/team-tryouts/static/;
|
||||
# expires 30d;
|
||||
# add_header Cache-Control "public, immutable";
|
||||
# access_log off;
|
||||
# }
|
||||
location /static/ {
|
||||
alias C:/team-tryouts/app/static/;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
|
||||
# These three are repeated on purpose. In nginx, add_header is
|
||||
# inherited from the enclosing block ONLY when the current block
|
||||
# declares none of its own — one add_header here silently drops
|
||||
# every security header set at server level. Dropping nosniff on
|
||||
# the JavaScript is the one that matters.
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
# A missing static file must 404, not fall through to Flask: the
|
||||
# fallthrough would hide a broken deploy behind a working page.
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
@@ -161,7 +197,7 @@ http {
|
||||
|
||||
# location /auth/login {
|
||||
# limit_req zone=login burst=5 nodelay;
|
||||
# proxy_pass http://127.0.0.1:5000;
|
||||
# proxy_pass http://127.0.0.1:10000;
|
||||
# }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Bounding what a list view loads (MNT-14).
|
||||
|
||||
Every list view ran `.all()` on its table and handed the whole thing to a
|
||||
template. The audit rated the impact as nil — correctly, at the scale of a
|
||||
student club — and recommended choosing the pattern now rather than
|
||||
retro-fitting one later. This is that pattern, in one place, so that the
|
||||
next list added to the application has something to copy.
|
||||
|
||||
Two decisions worth stating, because both are the kind that get made twice
|
||||
differently otherwise.
|
||||
|
||||
**`error_out=False`.** Page numbers arrive in the URL, so `?page=999` is a
|
||||
thing a person can type or a stale bookmark can hold. Flask-SQLAlchemy's
|
||||
default answers it with a 404, which is a confusing thing to show someone
|
||||
who has simply gone one page too far. An empty page is honest and the
|
||||
controls take them back.
|
||||
|
||||
**A cap on `per_page`.** It is also a URL parameter, and without a ceiling
|
||||
`?per_page=100000` re-creates by hand exactly the unbounded query this
|
||||
module exists to prevent — the sort of thing that turns a listing into a
|
||||
cheap way to make the server work hard.
|
||||
"""
|
||||
|
||||
from flask import request, url_for
|
||||
|
||||
#: Rows per page when nothing asks otherwise.
|
||||
DEFAULT_PER_PAGE = 50
|
||||
|
||||
#: Ceiling on the `per_page` query parameter. Generous enough that anyone
|
||||
#: wanting "everything" on one screen gets it for any realistic table, low
|
||||
#: enough that the query stays bounded.
|
||||
MAX_PER_PAGE = 200
|
||||
|
||||
|
||||
def paginate(query, per_page=DEFAULT_PER_PAGE):
|
||||
"""Return one page of `query`, honouring `?page=` and `?per_page=`.
|
||||
|
||||
Args:
|
||||
query: A SQLAlchemy query, already ordered. Ordering matters: a
|
||||
paginated query without ORDER BY may return the same row on two
|
||||
pages and never return another.
|
||||
per_page: Default page size for this listing.
|
||||
|
||||
Returns:
|
||||
flask_sqlalchemy.pagination.Pagination
|
||||
"""
|
||||
page = request.args.get('page', 1, type=int) or 1
|
||||
requested = request.args.get('per_page', per_page, type=int) or per_page
|
||||
size = max(1, min(requested, MAX_PER_PAGE))
|
||||
return query.paginate(page=max(1, page), per_page=size, error_out=False)
|
||||
|
||||
|
||||
def page_url(page):
|
||||
"""URL of the current listing at another page number.
|
||||
|
||||
Rebuilt from the live request rather than composed in the template,
|
||||
because the part that gets forgotten is the rest of the query string:
|
||||
the evaluations list carries `sort` and `order`, the team matches list
|
||||
carries `team_id`. A pagination link that drops them silently resets the
|
||||
view the person was looking at.
|
||||
"""
|
||||
args = request.args.to_dict()
|
||||
args.pop('page', None)
|
||||
return url_for(request.endpoint, page=page, **(request.view_args or {}), **args)
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Shared access-control rules — the single point of truth (ARCH-002).
|
||||
|
||||
Authorisation used to live inline in eight route modules, and the same
|
||||
question could get a different answer depending on which URL you reached.
|
||||
This module now holds the rules themselves; routes and models call it.
|
||||
|
||||
The subtlety it hides from callers: a coach or a manager can be attached to
|
||||
a team two different ways.
|
||||
|
||||
OrgTeam.coach_id the original single-coach column
|
||||
OrgTeam.coaches the many-to-many relationship added later
|
||||
|
||||
Both are still populated. Reading only ``coach_id`` — which most of
|
||||
users.py did — silently locked out every coach who was not the first one on
|
||||
their team, and reading ``.first()`` on top of it locked a coach out of
|
||||
every team but one. Both defects were live in production. Every function
|
||||
here considers both attachment routes and every team, so the fix applies
|
||||
once instead of at each call site.
|
||||
|
||||
ARCH-001 will collapse the two columns into one for good; that needs a data
|
||||
migration, so until then this module is what makes the duplication
|
||||
harmless.
|
||||
|
||||
Functions take the acting user explicitly rather than reading
|
||||
``current_user``: it keeps them callable from models, from the Discord bot,
|
||||
and from tests without a request context.
|
||||
"""
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team attachment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def coach_org_teams(coach):
|
||||
"""Organisation teams a coach is attached to, ordered by name.
|
||||
|
||||
Considers the many-to-many relationship *and* the legacy column, so the
|
||||
second coach of a team is not treated as belonging to nothing.
|
||||
|
||||
Args:
|
||||
coach: The user to inspect.
|
||||
|
||||
Returns:
|
||||
list[OrgTeam]: Possibly empty.
|
||||
"""
|
||||
from app.models import OrgTeam
|
||||
|
||||
return (
|
||||
OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=coach.id),
|
||||
OrgTeam.coach_id == coach.id,
|
||||
)
|
||||
)
|
||||
.order_by(OrgTeam.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def coach_org_team_ids(coach):
|
||||
"""IDs of the organisation teams a coach is attached to.
|
||||
|
||||
Args:
|
||||
coach: The user to inspect.
|
||||
|
||||
Returns:
|
||||
list[int]: Team IDs, possibly empty.
|
||||
"""
|
||||
return [team.id for team in coach_org_teams(coach)]
|
||||
|
||||
|
||||
def manager_org_teams(manager):
|
||||
"""Organisation teams a manager is attached to, ordered by name.
|
||||
|
||||
Symmetric to :func:`coach_org_teams`: ``manager_id`` and ``managers``
|
||||
carry the same duplication.
|
||||
|
||||
Args:
|
||||
manager: The user to inspect.
|
||||
|
||||
Returns:
|
||||
list[OrgTeam]: Possibly empty.
|
||||
"""
|
||||
from app.models import OrgTeam
|
||||
|
||||
return (
|
||||
OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.managers.any(id=manager.id),
|
||||
OrgTeam.manager_id == manager.id,
|
||||
)
|
||||
)
|
||||
.order_by(OrgTeam.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def attached_org_teams(user):
|
||||
"""Teams the user is personally attached to, whatever their role.
|
||||
|
||||
A president is attached to none in particular — they administer them
|
||||
all — so this returns an empty list for them. Callers that want "the
|
||||
teams to display" want :func:`visible_org_teams` instead.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
|
||||
Returns:
|
||||
list[OrgTeam]: Possibly empty.
|
||||
"""
|
||||
from app.models import Coach, Manager, Player
|
||||
|
||||
if isinstance(user, Coach):
|
||||
return coach_org_teams(user)
|
||||
if isinstance(user, Manager):
|
||||
return manager_org_teams(user)
|
||||
if isinstance(user, Player):
|
||||
return user.get_org_teams()
|
||||
return []
|
||||
|
||||
|
||||
def visible_org_teams(user):
|
||||
"""Organisation teams the user may see, ordered by name.
|
||||
|
||||
A president sees every team; a coach or a manager sees the ones they
|
||||
are attached to; a player sees the ones they play on; anyone else sees
|
||||
none.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
|
||||
Returns:
|
||||
list[OrgTeam]: Possibly empty.
|
||||
"""
|
||||
from app.models import Admin, OrgTeam
|
||||
|
||||
if isinstance(user, Admin):
|
||||
return OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
return attached_org_teams(user)
|
||||
|
||||
|
||||
def can_manage_org_team(user, org_team):
|
||||
"""Whether the user may administer this organisation team.
|
||||
|
||||
Delegates to the polymorphic model method, which is the role-level
|
||||
rule; this wrapper exists so route code has one name to call and never
|
||||
has to know which subclass it is holding.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
org_team: The team concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
return bool(org_team) and user.can_manage_this_org_team(org_team)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reach over players
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def org_team_player_ids(team_ids):
|
||||
"""IDs of the players placed on any of these teams.
|
||||
|
||||
Args:
|
||||
team_ids: Team primary keys.
|
||||
|
||||
Returns:
|
||||
list[int]: Player IDs, possibly empty, without duplicates.
|
||||
"""
|
||||
from app.models import TeamPlayer
|
||||
|
||||
if not team_ids:
|
||||
return []
|
||||
rows = TeamPlayer.query.filter(TeamPlayer.org_team_id.in_(team_ids)).all()
|
||||
return list({row.player_id for row in rows})
|
||||
|
||||
|
||||
def coach_player_ids(coach):
|
||||
"""IDs of the players on *all* of a coach's teams.
|
||||
|
||||
The routes this replaces looked at one team — the first row matching
|
||||
the legacy column — so a coach of two teams could act on half of their
|
||||
squad and no more.
|
||||
|
||||
Args:
|
||||
coach: The acting coach.
|
||||
|
||||
Returns:
|
||||
list[int]: Player IDs, possibly empty.
|
||||
"""
|
||||
return org_team_player_ids(coach_org_team_ids(coach))
|
||||
|
||||
|
||||
def coach_can_access_player(coach, player_id):
|
||||
"""Whether a coach may read or write information about a player.
|
||||
|
||||
True when the player sits on one of the coach's teams, or takes part in
|
||||
a tryout the coach manages. Anything else means the two have no working
|
||||
relationship, and a note or a contract about that player is none of the
|
||||
coach's business.
|
||||
|
||||
Args:
|
||||
coach: The acting coach.
|
||||
player_id: Primary key of the player concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
from app.models import Match, MatchParticipant, TeamPlayer, TryoutRegistration
|
||||
|
||||
if not player_id:
|
||||
return False
|
||||
|
||||
team_ids = coach_org_team_ids(coach)
|
||||
if team_ids:
|
||||
on_team = TeamPlayer.query.filter(
|
||||
TeamPlayer.player_id == player_id,
|
||||
TeamPlayer.org_team_id.in_(team_ids),
|
||||
).first()
|
||||
if on_team:
|
||||
return True
|
||||
|
||||
tryout_ids = coach_tryout_ids(coach, team_ids=team_ids)
|
||||
if not tryout_ids:
|
||||
return False
|
||||
|
||||
registered = TryoutRegistration.query.filter(
|
||||
TryoutRegistration.player_id == player_id,
|
||||
TryoutRegistration.tryout_id.in_(tryout_ids),
|
||||
).first()
|
||||
if registered:
|
||||
return True
|
||||
|
||||
plays_a_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == player_id,
|
||||
Match.tryout_id.in_(tryout_ids),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return plays_a_match is not None
|
||||
|
||||
|
||||
def can_manage_player_contract(user, player_id):
|
||||
"""Whether the user may upload or replace a contract for this player.
|
||||
|
||||
Presidents and managers may do so for anyone. A coach may do so for the
|
||||
players on their teams — the working relationship a contract implies.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
player_id: Primary key of the player concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
from app.models import Admin, Coach, Manager
|
||||
|
||||
if not player_id:
|
||||
return False
|
||||
if isinstance(user, (Admin, Manager)):
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
return player_id in coach_player_ids(user)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tryouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def coach_tryouts(coach, team_ids=None):
|
||||
"""Tryouts a coach manages, ordered by date.
|
||||
|
||||
A coach reaches a tryout through any of the three routes the model
|
||||
supports: it targets one of their teams, they are named on the
|
||||
many-to-many relationship, or the deprecated ``coach_id`` points at
|
||||
them.
|
||||
|
||||
Args:
|
||||
coach: The acting coach.
|
||||
team_ids: Pre-computed team IDs, to avoid querying twice when the
|
||||
caller already has them.
|
||||
|
||||
Returns:
|
||||
list[Tryout]: Possibly empty.
|
||||
"""
|
||||
from app.models import Tryout
|
||||
|
||||
if team_ids is None:
|
||||
team_ids = coach_org_team_ids(coach)
|
||||
|
||||
conditions = [
|
||||
Tryout.coaches.any(id=coach.id),
|
||||
Tryout.coach_id == coach.id,
|
||||
]
|
||||
if team_ids:
|
||||
conditions.append(Tryout.target_org_team_id.in_(team_ids))
|
||||
|
||||
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
|
||||
|
||||
|
||||
def coach_tryout_ids(coach, team_ids=None):
|
||||
"""IDs of the tryouts a coach manages.
|
||||
|
||||
Args:
|
||||
coach: The acting coach.
|
||||
team_ids: Pre-computed team IDs, see :func:`coach_tryouts`.
|
||||
|
||||
Returns:
|
||||
list[int]: Possibly empty.
|
||||
"""
|
||||
return [tryout.id for tryout in coach_tryouts(coach, team_ids=team_ids)]
|
||||
|
||||
|
||||
def coach_manages_tryout(coach, tryout):
|
||||
"""Whether a coach manages this particular tryout.
|
||||
|
||||
Same three routes as :func:`coach_tryouts`, asked about one row.
|
||||
Written as a membership test rather than a query so that a tryout not
|
||||
yet flushed to the database still answers correctly.
|
||||
|
||||
Args:
|
||||
coach: The acting coach.
|
||||
tryout: The tryout concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
if tryout is None:
|
||||
return False
|
||||
if tryout.coach_id == coach.id:
|
||||
return True
|
||||
if any(c.id == coach.id for c in tryout.coaches):
|
||||
return True
|
||||
if tryout.target_org_team_id:
|
||||
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
||||
return False
|
||||
+398
-167
@@ -2,24 +2,57 @@
|
||||
|
||||
This module handles user authentication including login with account lockout
|
||||
protection, logout with session clearing, and new user registration with
|
||||
password policy enforcement and CAPTCHA verification.
|
||||
password policy enforcement and sign-up screening.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.extensions import db, hash_password, check_password, limiter
|
||||
from app.models import User, Player, ESPORT_GAMES
|
||||
from app.validators import RegisterSchema, LoginSchema
|
||||
from marshmallow import ValidationError
|
||||
from urllib.parse import urlparse
|
||||
import requests
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
# Account lockout settings
|
||||
import requests
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import check_password, db, hash_password, limiter
|
||||
from app.i18n import LOCALE_SESSION_KEY
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import ESPORT_GAMES, Player, User
|
||||
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
||||
|
||||
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||
DISCORD_STATE_KEY = 'discord_oauth_state'
|
||||
#: Whether the OAuth result should create a registration draft or relink the
|
||||
#: signed-in account. Kept server-side and covered by the same signed session
|
||||
#: as the anti-forgery state.
|
||||
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
|
||||
|
||||
# Failed-attempt tracking. The tally is kept for the audit trail and for the
|
||||
# cool-off marker below; it no longer refuses a correct password (SEC-018).
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
#: Ceiling on the doubling cool-off window.
|
||||
MAX_LOCKOUT_MINUTES = 240
|
||||
|
||||
#: Hash of a value nobody can submit. Verifying against it when the username
|
||||
#: is unknown makes that path cost the same scrypt work as a real one, so the
|
||||
#: response time stops telling a caller which usernames exist (SEC-017).
|
||||
_ABSENT_USER_HASH = None
|
||||
|
||||
#: Session key recording when the registration form was handed out.
|
||||
REGISTRATION_ISSUED_KEY = 'registration_form_issued_at'
|
||||
|
||||
#: Name of the honeypot input. Plausible enough that a form-filler wants it,
|
||||
#: absent from the visible form. Hidden by .honeypot in style.css — not by an
|
||||
#: inline style, so that the rule survives a tightening of style-src.
|
||||
REGISTRATION_HONEYPOT_FIELD = 'website'
|
||||
|
||||
#: Floor on how long a genuine registration takes. Eleven fields and a
|
||||
#: password typed twice; three seconds is generous.
|
||||
MIN_REGISTRATION_SECONDS = 3
|
||||
|
||||
# Discord OAuth2 configuration
|
||||
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
|
||||
@@ -40,53 +73,124 @@ DISCORD_PLATFORM_TO_GAMES = {
|
||||
def is_safe_url(url):
|
||||
"""Validate that a URL is safe for redirection (same origin).
|
||||
|
||||
Accepts an absolute URL on this host, or a path beginning with exactly
|
||||
one slash. Everything else is refused, including the two forms that
|
||||
read differently to urlparse and to a browser:
|
||||
|
||||
/\\evil.com several browsers normalise the backslash to a slash,
|
||||
turning this into the protocol-relative //evil.com.
|
||||
urlparse reports no netloc at all, so the old check
|
||||
let it through and the redirect left the site.
|
||||
/\\n//evil.com control characters are stripped before parsing.
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the URL is safe (relative or same origin).
|
||||
bool: True if the URL is safe.
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
|
||||
return False
|
||||
|
||||
parsed = urlparse(url)
|
||||
# Allow relative URLs (no netloc) or same-origin URLs
|
||||
return not parsed.netloc or parsed.netloc == request.host
|
||||
if parsed.netloc:
|
||||
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
|
||||
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
|
||||
# not paths on this site.
|
||||
return url.startswith('/')
|
||||
|
||||
|
||||
def generate_captcha():
|
||||
"""Generate a simple math CAPTCHA challenge.
|
||||
def _absent_user_hash():
|
||||
"""A hash to verify against when the submitted username does not exist.
|
||||
|
||||
Creates a random addition problem and stores the answer in the session.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
||||
check_password() used to be reached only when a user row was found, so
|
||||
an unknown username answered as fast as the database lookup, and a known
|
||||
one as slowly as scrypt. The gap is measurable and enumerates accounts.
|
||||
Computed once per process, from a random secret, so no submitted password
|
||||
can ever match it.
|
||||
"""
|
||||
import random
|
||||
a = random.randint(1, 10)
|
||||
b = random.randint(1, 10)
|
||||
captcha_id = str(uuid.uuid4())
|
||||
session['captcha_id'] = captcha_id
|
||||
session['captcha_answer'] = a + b
|
||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
||||
global _ABSENT_USER_HASH
|
||||
if _ABSENT_USER_HASH is None:
|
||||
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
|
||||
return _ABSENT_USER_HASH
|
||||
|
||||
|
||||
def verify_captcha(user_answer):
|
||||
"""Verify the CAPTCHA answer from the session.
|
||||
def cooloff_minutes(failed_attempts):
|
||||
"""Length of the cool-off window earned by this many failed attempts.
|
||||
|
||||
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
|
||||
|
||||
Args:
|
||||
user_answer: The user's submitted answer (string or int).
|
||||
failed_attempts: Consecutive failures recorded on the account.
|
||||
|
||||
Returns:
|
||||
bool: True if the answer matches the stored CAPTCHA, False otherwise.
|
||||
int: Minutes.
|
||||
"""
|
||||
try:
|
||||
expected = session.pop('captcha_answer', None)
|
||||
session.pop('captcha_id', None)
|
||||
if expected is None:
|
||||
return False
|
||||
return int(user_answer) == expected
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
|
||||
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
|
||||
|
||||
|
||||
def issue_registration_challenge():
|
||||
"""Mark that the registration form has been handed out, and when.
|
||||
|
||||
Kept in the signed session rather than in a form field, so that the
|
||||
timestamp is not something the submitter can choose. Left in place across
|
||||
a failed submission: someone correcting a typo should not be told to slow
|
||||
down, and a robot has already paid for the round trip by then.
|
||||
"""
|
||||
session.setdefault(REGISTRATION_ISSUED_KEY, time.time())
|
||||
|
||||
|
||||
def check_registration_challenge(form):
|
||||
"""Say why this registration should be refused, or None to accept.
|
||||
|
||||
What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008).
|
||||
|
||||
`a + b = ?` with both operands between 1 and 10 has nineteen possible
|
||||
answers and is solvable by reading the string. It stopped no automated
|
||||
registration whatsoever. What it did do was add a step for every human,
|
||||
including anyone using a screen reader, in exchange for an appearance of
|
||||
protection — which is worse than no protection, because it gets counted
|
||||
as one.
|
||||
|
||||
The audit's alternative was a real CAPTCHA service. That means a third
|
||||
party, an API key, a request on every page load, and putting a foreign
|
||||
script back into script-src — undoing the CSP work that closed
|
||||
SEC-WEB-001. Disproportionate for a club site.
|
||||
|
||||
So: two checks that cost the visitor nothing.
|
||||
|
||||
- a honeypot field, hidden in the stylesheet, that a form-filling
|
||||
robot completes and a person never sees;
|
||||
- a minimum dwell time between being handed the form and sending it
|
||||
back. Eleven fields and a password typed twice do not get filled in
|
||||
under three seconds, and a POST with no issued form at all never
|
||||
fetched the page.
|
||||
|
||||
Be clear about the ceiling: this stops commodity spam, not somebody who
|
||||
looks at the form for five minutes. The thing that would actually gate
|
||||
registration is staff activation of new accounts, which does not exist —
|
||||
`is_active_account` defaults to True. That is a product decision, not one
|
||||
to slip in here.
|
||||
|
||||
The session-forgery angle in the constat is moot: with SECRET_KEY
|
||||
compromised (SEC-001) an attacker forges a logged-in session for any
|
||||
account and has no reason to register at all.
|
||||
|
||||
Returns:
|
||||
str | None: a short reason for the log, or None to let it through.
|
||||
"""
|
||||
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
|
||||
return 'honeypot'
|
||||
|
||||
issued_at = session.get(REGISTRATION_ISSUED_KEY)
|
||||
if issued_at is None:
|
||||
return 'no-form-issued'
|
||||
if time.time() - issued_at < MIN_REGISTRATION_SECONDS:
|
||||
return 'too-fast'
|
||||
return None
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
@@ -95,16 +199,17 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
"""Handle user login authentication with account lockout protection.
|
||||
"""Handle user login authentication.
|
||||
|
||||
GET: Render the login form.
|
||||
POST: Authenticate user credentials with lockout check and audit logging.
|
||||
POST: Authenticate user credentials, with audit logging.
|
||||
|
||||
Account lockout: After 5 consecutive failed attempts, the account is
|
||||
locked for 15 minutes. Successful login resets the counter.
|
||||
|
||||
Redirects authenticated users to dashboard. Validates credentials and checks
|
||||
account status before login.
|
||||
Failed attempts are counted and open a cool-off window, recorded in
|
||||
``locked_until`` and in the authentication log. The window does not
|
||||
refuse correct credentials: when it did, five wrong guesses against a
|
||||
known username took that account out of service for fifteen minutes,
|
||||
repeatably, and on a president's account that meant no administration
|
||||
at all. Guess rate is bounded by the rate limit on this view.
|
||||
|
||||
Returns:
|
||||
Response: Login form or redirect to dashboard/next page.
|
||||
@@ -120,80 +225,124 @@ def login():
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
username = validated['username']
|
||||
password = validated['password']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Check if account is locked
|
||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
||||
flash(
|
||||
f'Account is locked due to too many failed attempts. '
|
||||
f'Please try again in {remaining} minute(s).',
|
||||
'danger'
|
||||
)
|
||||
return render_template('pages/login.html')
|
||||
# Verified before anything else is decided, and on both branches.
|
||||
# Reaching this only when a row exists made the response time a
|
||||
# reliable oracle for which usernames are registered (SEC-017).
|
||||
credentials_ok = check_password(
|
||||
user.password_hash if user else _absent_user_hash(), password
|
||||
)
|
||||
|
||||
if user and check_password(user.password_hash, password):
|
||||
if user and credentials_ok:
|
||||
if not user.is_active_account:
|
||||
flash('This account has been deactivated.', 'danger')
|
||||
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
|
||||
flash(_('This account has been deactivated.'), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
# Reset failed login attempts on successful login
|
||||
# Correct credentials clear the tally, cool-off window included.
|
||||
# The window used to refuse them too, which is what turned it
|
||||
# into a way to lock a known account out at will (SEC-018).
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.session.commit()
|
||||
|
||||
# Clear old session data and preserve CSRF token to prevent
|
||||
# session fixation attacks (Flask-Login rotates the session ID)
|
||||
_csrf_token = session.get('csrf_token')
|
||||
# session fixation attacks (Flask-Login rotates the session ID).
|
||||
#
|
||||
# The language choice is carried across too. Someone who reads the
|
||||
# login page in English and signs in would otherwise be dropped
|
||||
# back into French — the preference lives in the session, and
|
||||
# clearing it discards a decision the user just made.
|
||||
_preserved = {
|
||||
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
|
||||
}
|
||||
session.clear()
|
||||
if _csrf_token:
|
||||
session['csrf_token'] = _csrf_token
|
||||
session.update(_preserved)
|
||||
|
||||
# Mark the session permanent so PERMANENT_SESSION_LIFETIME applies.
|
||||
# Without this, Flask emits a browser-session cookie with no expiry
|
||||
# and the configured lifetime is silently ignored.
|
||||
session.permanent = True
|
||||
|
||||
login_user(user)
|
||||
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
|
||||
|
||||
# Validate redirect URL to prevent open redirect vulnerability
|
||||
next_page = request.args.get('next')
|
||||
if next_page and not is_safe_url(next_page):
|
||||
next_page = None
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||
# One message for every failure. The old code said "N attempts
|
||||
# remaining" to a real account and "check username and password"
|
||||
# to an unknown one, which listed the club's accounts to anyone
|
||||
# who asked (SEC-017).
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
log_auth_event(
|
||||
'login.failure',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
log_auth_event(
|
||||
'account.throttled',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
minutes=minutes,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
# Track failed login attempt
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
flash(
|
||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
||||
'danger'
|
||||
)
|
||||
else:
|
||||
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
|
||||
flash(
|
||||
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
|
||||
'danger'
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
log_auth_event('login.failure.unknown_user', username=username)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'Login unsuccessful. Please check your username and '
|
||||
'password, or ask a president for help.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
|
||||
return render_template('pages/login.html')
|
||||
|
||||
|
||||
def _rerender_registration(form_data):
|
||||
"""Re-render the registration form after a refusal.
|
||||
|
||||
Was copied out four times, near-identically (ARCH-005). Dropping the two
|
||||
password fields is the part that must not be forgotten in the fifth copy:
|
||||
echoing a password back into the HTML puts it in the browser's cache and
|
||||
in any proxy log along the way.
|
||||
"""
|
||||
form_data = dict(form_data)
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
honeypot_field=REGISTRATION_HONEYPOT_FIELD,
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit("20 per hour")
|
||||
def register():
|
||||
"""Handle new player registration with CAPTCHA and password policy.
|
||||
"""Handle new player registration.
|
||||
|
||||
GET: Render the registration form with E-Sports games list and CAPTCHA.
|
||||
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
|
||||
and create a new player account.
|
||||
GET: Render the registration form with the E-Sports games list.
|
||||
POST: Screen the submission (see check_registration_challenge), validate
|
||||
every input against RegisterSchema, and create a new player account.
|
||||
|
||||
Only players can register through this form. Validates username/email
|
||||
uniqueness and password confirmation.
|
||||
@@ -209,20 +358,24 @@ def register():
|
||||
form_data = dict(request.form)
|
||||
form_data['games'] = request.form.getlist('games')
|
||||
|
||||
# Validate CAPTCHA first
|
||||
captcha_answer = request.form.get('captcha_answer', '')
|
||||
if not verify_captcha(captcha_answer):
|
||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields only on CAPTCHA failure
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
# Once Discord has authenticated the identity, neither its display
|
||||
# name nor its snowflake is input data anymore. Remove any client
|
||||
# copies before validation as well as before persistence: otherwise a
|
||||
# forged, malformed hidden value can still make the verified flow fail.
|
||||
discord_oauth = session.get('discord_oauth') or {}
|
||||
if discord_oauth.get('id'):
|
||||
form_data.pop('discord_username', None)
|
||||
form_data.pop('discord_user_id', None)
|
||||
|
||||
refusal = check_registration_challenge(request.form)
|
||||
if refusal is not None:
|
||||
# Logged, because this is the only place abuse of the sign-up
|
||||
# form becomes visible at all. Deliberately vague to the sender:
|
||||
# naming the honeypot tells whoever tripped it how to avoid it.
|
||||
log_auth_event('account.registration_refused', reason=refusal)
|
||||
flash(_('Your registration could not be processed. Please try again.'), 'danger')
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
# Validate input with marshmallow schema
|
||||
register_schema = RegisterSchema()
|
||||
@@ -231,17 +384,8 @@ def register():
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields on validation failure
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
username = validated['username']
|
||||
email = validated['email']
|
||||
@@ -249,33 +393,34 @@ def register():
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
discord_username = validated.get('discord_username')
|
||||
discord_user_id = validated.get('discord_user_id')
|
||||
# The OAuth identity is server-side state. It used to be copied into
|
||||
# hidden inputs and read back from request.form, which let anyone
|
||||
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||
# A manual registration may still provide a display name, but never a
|
||||
# Discord snowflake: that identifier is an authentication factor for
|
||||
# bot reactions and must come from Discord itself.
|
||||
discord_user_id = discord_oauth.get('id')
|
||||
if discord_user_id:
|
||||
discord_user_id = str(discord_user_id)
|
||||
discord_username = (
|
||||
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
|
||||
)
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
flash(_('Email already registered.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
# The database constraint belongs to DB-002, after production has
|
||||
# been backed up and deduplicated. Refuse new duplicates now instead
|
||||
# of leaving the critical impersonation path open until then.
|
||||
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user = Player(
|
||||
@@ -291,10 +436,15 @@ def register():
|
||||
league_os_profile=league_os_profile,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
# flush, not commit: the id is needed for the gamertag rows below,
|
||||
# and signing up is one operation. Committing here made it two, so a
|
||||
# failure while writing the gamertags left an account whose declared
|
||||
# games were silently absent (ARCH-006).
|
||||
db.session.flush()
|
||||
|
||||
# Create UserGamertag records for each selected game
|
||||
from app.models import UserGamertag
|
||||
|
||||
for game in selected_games:
|
||||
field_name = f'gamertag_{game}'
|
||||
gamertag_value = request.form.get(field_name, '').strip()
|
||||
@@ -309,18 +459,16 @@ def register():
|
||||
|
||||
# Clear Discord OAuth data from session after successful registration
|
||||
session.pop('discord_oauth', None)
|
||||
session.pop(REGISTRATION_ISSUED_KEY, None)
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||
|
||||
flash(_('Your account has been created! You can now log in.'), 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# GET request — render empty form
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data={},
|
||||
)
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration({})
|
||||
|
||||
|
||||
@auth_bp.route('/discord/login')
|
||||
@@ -333,18 +481,31 @@ def discord_login():
|
||||
Returns:
|
||||
Response: Redirect to Discord authorization URL.
|
||||
"""
|
||||
if not DISCORD_CLIENT_ID:
|
||||
flash('Discord OAuth2 is not configured.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
purpose = 'profile' if current_user.is_authenticated else 'registration'
|
||||
session[DISCORD_PURPOSE_KEY] = purpose
|
||||
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
|
||||
|
||||
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||
# raise inside the query builder rather than report a configuration error.
|
||||
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||
# attacker could have the victim's browser consume an authorization code
|
||||
# obtained for the attacker's own Discord account, silently binding that
|
||||
# identity to the victim's registration form.
|
||||
state = secrets.token_urlsafe(32)
|
||||
session[DISCORD_STATE_KEY] = state
|
||||
|
||||
params = {
|
||||
'client_id': DISCORD_CLIENT_ID,
|
||||
'redirect_uri': DISCORD_REDIRECT_URI,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify connections',
|
||||
'state': state,
|
||||
}
|
||||
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
@@ -352,18 +513,41 @@ def discord_login():
|
||||
def discord_callback():
|
||||
"""Handle the OAuth2 callback from Discord.
|
||||
|
||||
Exchanges the authorization code for an access token, then fetches
|
||||
the user's profile (/users/@me) and connections (/users/@me/connections).
|
||||
Results are stored in the session and the user is redirected back to
|
||||
the registration form where fields will be pre-filled.
|
||||
Exchanges the authorization code for an access token, then fetches the
|
||||
user's profile. During registration, connected game accounts are also
|
||||
loaded into server-side draft state. For a signed-in profile relink, the
|
||||
verified identity is written directly without passing through a form.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to registration page.
|
||||
Response: Redirect to the registration form or profile editor.
|
||||
"""
|
||||
# The state is consumed whatever happens next: a token is single-use, and
|
||||
# leaving it in the session would allow a replay.
|
||||
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
|
||||
if purpose == 'profile' and current_user.is_authenticated:
|
||||
return_endpoint = 'users.edit_profile'
|
||||
elif purpose == 'profile':
|
||||
return_endpoint = 'auth.login'
|
||||
else:
|
||||
return_endpoint = 'auth.register'
|
||||
|
||||
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||
received_state = request.args.get('state', '')
|
||||
|
||||
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||
flash(
|
||||
_(
|
||||
'Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
flash('Discord authorization failed. No code received.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Exchange the authorization code for an access token
|
||||
token_data = {
|
||||
@@ -385,13 +569,13 @@ def discord_callback():
|
||||
token_response.raise_for_status()
|
||||
token_json = token_response.json()
|
||||
access_token = token_json.get('access_token')
|
||||
except requests.RequestException as e:
|
||||
flash(f'Failed to connect to Discord. Please try again.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
except requests.RequestException:
|
||||
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
if not access_token:
|
||||
flash('Failed to obtain Discord access token.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
@@ -405,8 +589,44 @@ def discord_callback():
|
||||
user_response.raise_for_status()
|
||||
user_data = user_response.json()
|
||||
except requests.RequestException:
|
||||
flash('Failed to fetch Discord user profile.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
discord_user_id = user_data.get('id')
|
||||
try:
|
||||
if not discord_user_id:
|
||||
raise ValidationError('missing Discord user id')
|
||||
discord_user_id = str(discord_user_id)
|
||||
validate_discord_user_id(discord_user_id)
|
||||
except ValidationError:
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
if purpose == 'profile':
|
||||
# If the session expired while Discord was open, do not turn a profile
|
||||
# relink into registration state for an anonymous browser.
|
||||
if not current_user.is_authenticated:
|
||||
flash(_('Please log in to connect your Discord account.'), 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
clash = User.query.filter(
|
||||
User.discord_user_id == discord_user_id,
|
||||
User.id != current_user.id,
|
||||
).first()
|
||||
if clash:
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return redirect(url_for('users.edit_profile'))
|
||||
|
||||
current_user.discord_user_id = discord_user_id
|
||||
current_user.discord_username = user_data.get('username') or None
|
||||
db.session.commit()
|
||||
log_auth_event(
|
||||
'account.discord_linked',
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
flash(_('Discord account connected!'), 'success')
|
||||
return redirect(url_for('users.edit_profile'))
|
||||
|
||||
# Fetch the user's connected gaming accounts
|
||||
connections = []
|
||||
@@ -445,29 +665,40 @@ def discord_callback():
|
||||
|
||||
# Store in session for the registration form to use
|
||||
session['discord_oauth'] = {
|
||||
'id': user_data.get('id'),
|
||||
'id': discord_user_id,
|
||||
'username': user_data.get('username'),
|
||||
'avatar': user_data.get('avatar'),
|
||||
'gamertag_suggestions': gamertag_suggestions,
|
||||
'auto_select_games': auto_select_games,
|
||||
}
|
||||
|
||||
flash('Discord account connected! Your profile has been pre-filled.', 'success')
|
||||
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
@login_required
|
||||
def logout():
|
||||
"""Log out the current user and clear the session.
|
||||
|
||||
POST, not GET: a GET route is not covered by CSRF protection, so any
|
||||
page on the internet could sign a user out with an <img> tag pointing
|
||||
here. A nuisance rather than a compromise, but it costs one form to
|
||||
close (SEC-019).
|
||||
|
||||
Clears the user session and regenerates session ID to prevent
|
||||
session fixation/replay after logout.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login page with logout message.
|
||||
"""
|
||||
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
|
||||
logout_user()
|
||||
# Same reasoning as at login: the language is a display preference, not
|
||||
# session state belonging to the account being signed out.
|
||||
_locale = session.get(LOCALE_SESSION_KEY)
|
||||
session.clear()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
if _locale:
|
||||
session[LOCALE_SESSION_KEY] = _locale
|
||||
flash(_('You have been logged out.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
+113
-116
@@ -3,33 +3,30 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Coach, Manager, Player,
|
||||
User, Tryout, Evaluation, TryoutRegistration,
|
||||
OrgTeam, GAME_POSITIONS,
|
||||
)
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Evaluation,
|
||||
Player,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.validators import EvaluationSchema
|
||||
|
||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||
|
||||
|
||||
def validate_score(score_value):
|
||||
"""Validate that a score is between 1 and 10."""
|
||||
if score_value is None:
|
||||
return None
|
||||
try:
|
||||
score = int(score_value)
|
||||
if 1 <= score <= 10:
|
||||
return score
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@evaluations_bp.route('')
|
||||
@login_required
|
||||
def list_evaluations():
|
||||
@@ -37,7 +34,7 @@ def list_evaluations():
|
||||
user = current_user
|
||||
|
||||
if isinstance(user, Player):
|
||||
flash('You do not have permission to view evaluations.', 'danger')
|
||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
sort_column = request.args.get('sort', 'created_at')
|
||||
@@ -73,44 +70,52 @@ def list_evaluations():
|
||||
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,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).all()
|
||||
evaluations_page = paginate(
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.order_by(sort_expr, Evaluation.id)
|
||||
)
|
||||
avg_scores = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p, 'count': row.eval_count,
|
||||
'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()
|
||||
# Everyone still here evaluates: players were redirected above, and
|
||||
# can_evaluate() is true for the four remaining roles. The former
|
||||
# `else` branch listed evaluations *received* — a player's view,
|
||||
# unreachable from this point (ARCH-007).
|
||||
evaluations_page = paginate(
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.filter(Evaluation.evaluator_id == user.id)
|
||||
.order_by(sort_expr, Evaluation.id)
|
||||
)
|
||||
player_scores = {}
|
||||
|
||||
return render_template('pages/evaluations.html',
|
||||
evaluations=evaluations, player_scores=player_scores,
|
||||
sort_column=sort_column, sort_order=sort_order)
|
||||
return render_template(
|
||||
'pages/evaluations.html',
|
||||
evaluations=evaluations_page.items,
|
||||
pagination=evaluations_page,
|
||||
player_scores=player_scores,
|
||||
sort_column=sort_column,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
||||
@@ -118,92 +123,83 @@ def list_evaluations():
|
||||
def evaluate_player(tryout_id, player_id):
|
||||
"""Evaluate a specific player in a tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash('You do not have permission to evaluate players.', 'danger')
|
||||
flash(_('You do not have permission to evaluate players.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
).first() is not None
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
flash('Player is not registered for this tryout.', 'danger')
|
||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
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))
|
||||
|
||||
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()
|
||||
|
||||
def render_evaluation_form():
|
||||
evaluators = None
|
||||
if isinstance(current_user, Admin):
|
||||
all_evaluations = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).all()
|
||||
evaluators = [
|
||||
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'pages/evaluate_player.html',
|
||||
tryout=tryout,
|
||||
player=player,
|
||||
existing_eval=existing_eval,
|
||||
evaluators=evaluators,
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
mecanics = validate_score(request.form.get('mecanics_score'))
|
||||
cohesion = validate_score(request.form.get('cohesion_score'))
|
||||
communication = validate_score(request.form.get('communication_score'))
|
||||
gamesense = validate_score(request.form.get('gamesense_score'))
|
||||
versatility = validate_score(request.form.get('versatility_score'))
|
||||
discipline = validate_score(request.form.get('discipline_score'))
|
||||
analysis = validate_score(request.form.get('analysis_score'))
|
||||
sport_ethics = validate_score(request.form.get('sport_ethics_score'))
|
||||
mental = validate_score(request.form.get('mental_score'))
|
||||
comments = request.form.get('comments')
|
||||
position = request.form.get('position_recommendation')
|
||||
try:
|
||||
data = EvaluationSchema().load(form_payload(list_fields=(), optional_blank=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_evaluation_form()
|
||||
|
||||
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 = existing_eval
|
||||
if evaluation is None:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
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')
|
||||
flash(_('Evaluation submitted successfully!'), 'success')
|
||||
else:
|
||||
flash(_('Evaluation updated!'), 'success')
|
||||
|
||||
evaluation.apply_scores(data)
|
||||
evaluation.comments = data['comments']
|
||||
evaluation.position_recommendation = data['position_recommendation']
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
evaluators = None
|
||||
if isinstance(current_user, Admin):
|
||||
all_evaluations = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
).all()
|
||||
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
|
||||
for e in all_evaluations]
|
||||
|
||||
return render_template('pages/evaluate_player.html',
|
||||
tryout=tryout, player=player,
|
||||
existing_eval=existing_eval,
|
||||
evaluators=evaluators,
|
||||
game_positions=GAME_POSITIONS)
|
||||
return render_evaluation_form()
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||
@@ -211,12 +207,12 @@ def evaluate_player(tryout_id, player_id):
|
||||
def players_to_evaluate(tryout_id):
|
||||
"""List players that need evaluation in a specific tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
@@ -225,9 +221,10 @@ def players_to_evaluate(tryout_id):
|
||||
p = User.query.get(reg.player_id)
|
||||
if p and isinstance(p, Player):
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=p.id,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
players.append({'player': p, 'evaluated': existing is not None,
|
||||
'registration': reg})
|
||||
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
|
||||
+154
-51
@@ -3,16 +3,29 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
|
||||
Match, MatchParticipant, OrgTeam,
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
Player,
|
||||
Scout,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from datetime import date
|
||||
from app.permissions import coach_tryout_ids
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -23,6 +36,32 @@ def index():
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/lang/<locale>')
|
||||
def set_language(locale):
|
||||
"""Switch the interface language and return where the user came from.
|
||||
|
||||
Available to anonymous visitors too: the login page has to be readable
|
||||
before anyone can sign in.
|
||||
|
||||
A GET link rather than a form: the only thing a forged request could
|
||||
achieve is changing the visitor's own display language, which carries
|
||||
no consequence worth a token. The redirect target is still validated —
|
||||
an unchecked `Referer` would make this an open redirect.
|
||||
"""
|
||||
from app.i18n import set_locale
|
||||
from app.routes.auth import is_safe_url
|
||||
|
||||
if not set_locale(locale):
|
||||
flash(_('That language is not available.'), 'warning')
|
||||
|
||||
target = request.referrer
|
||||
if target and is_safe_url(target):
|
||||
return redirect(target)
|
||||
return redirect(
|
||||
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
@@ -43,46 +82,95 @@ def dashboard():
|
||||
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
||||
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
||||
today = date.today()
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all()
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
elif isinstance(user, Manager):
|
||||
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
||||
stats['active_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id, status='in_progress').count()
|
||||
created_by=user.id, status='in_progress'
|
||||
).count()
|
||||
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
stats['my_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||
stats['my_tryouts'] = (
|
||||
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||
)
|
||||
today = date.today()
|
||||
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.tryout_id.in_(manager_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(manager_tryout_ids),
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
if manager_tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
elif isinstance(user, Coach):
|
||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
registrations = TryoutRegistration.query.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended'])).all()
|
||||
registered_player_ids = [r.player_id for r in registrations]
|
||||
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||
|
||||
# A count, computed as a count. This used to load every registration
|
||||
# row in the club and every evaluation this coach had written, build
|
||||
# two Python sets and subtract them — two full table reads to produce
|
||||
# one integer (PERF-004).
|
||||
already_evaluated = (
|
||||
db.session.query(Evaluation.player_id)
|
||||
.filter(
|
||||
Evaluation.evaluator_id == user.id,
|
||||
Evaluation.player_id == TryoutRegistration.player_id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
stats['pending_evaluations'] = (
|
||||
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
|
||||
.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended']),
|
||||
~already_evaluated,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
stats['my_recent_evaluations'] = (
|
||||
Evaluation.query.filter_by(evaluator_id=user.id)
|
||||
.order_by(Evaluation.created_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
today = date.today()
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
||||
target_org_team_id=org_team.id).all()] if org_team else []
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.tryout_id.in_(coach_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
|
||||
# Was: the first team matching the legacy coach_id column, and only
|
||||
# the tryouts targeting it. A coach attached by the many-to-many
|
||||
# relationship, or coaching a second team, saw no upcoming match.
|
||||
tryout_ids = coach_tryout_ids(user)
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(tryout_ids),
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
elif isinstance(user, Player):
|
||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
||||
stats['my_registrations'] = TryoutRegistration.query.filter_by(
|
||||
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
|
||||
stats['my_registrations'] = (
|
||||
TryoutRegistration.query.filter_by(player_id=user.id)
|
||||
.order_by(TryoutRegistration.registered_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
next_matches = []
|
||||
@@ -93,10 +181,15 @@ def dashboard():
|
||||
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
||||
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
||||
|
||||
upcoming_matches = Match.query.filter(
|
||||
Match.tryout_id.in_(registered_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).all()
|
||||
upcoming_matches = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(registered_tryout_ids),
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
for match in upcoming_matches:
|
||||
is_participant = False
|
||||
@@ -104,36 +197,46 @@ def dashboard():
|
||||
if match.match_type == 'team_vs_team':
|
||||
if match.team1_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next((tm for tm in player_team_memberships
|
||||
if tm.team_id == match.team1_id), None)
|
||||
team = next(
|
||||
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
|
||||
)
|
||||
elif match.team2_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next((tm for tm in player_team_memberships
|
||||
if tm.team_id == match.team2_id), None)
|
||||
team = next(
|
||||
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
|
||||
)
|
||||
else:
|
||||
if match.id in player_match_ids:
|
||||
is_participant = True
|
||||
|
||||
if is_participant:
|
||||
next_matches.append({
|
||||
'tryout': match.tryout, 'match': match,
|
||||
'team': team.team if team else None,
|
||||
})
|
||||
next_matches.append(
|
||||
{
|
||||
'tryout': match.tryout,
|
||||
'match': match,
|
||||
'team': team.team if team else None,
|
||||
}
|
||||
)
|
||||
|
||||
stats['next_matches'] = next_matches
|
||||
|
||||
elif isinstance(user, Scout):
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['avg_scores'] = db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).order_by(
|
||||
func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
||||
stats['avg_scores'] = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.group_by(Evaluation.player_id)
|
||||
.order_by(func.avg(Evaluation.overall_score).desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
stats['top_players'] = []
|
||||
for row in stats['avg_scores']:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
stats['top_players'].append((p, round(row.avg_score, 1)))
|
||||
|
||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||
|
||||
+427
-320
@@ -3,21 +3,91 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Match, MatchParticipant, Team, TeamMember,
|
||||
OrgTeam, TryoutRegistration, PlayerDisponibility,
|
||||
Admin,
|
||||
Coach,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
PersonalNote,
|
||||
Player,
|
||||
PlayerDisponibility,
|
||||
Scout,
|
||||
Team,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from datetime import datetime, time, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.validators import MatchEditSchema, MatchSchema
|
||||
|
||||
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||
|
||||
|
||||
def match_form_payload():
|
||||
"""The match form, shaped for marshmallow.
|
||||
|
||||
`player_ids` is a repeated checkbox, so it needs getlist(); `games` — the
|
||||
default list field — has nothing to do with this form.
|
||||
"""
|
||||
return form_payload(list_fields=('player_ids',), optional_blank=())
|
||||
|
||||
|
||||
#: How long a match lasts when the form gives a start and no end.
|
||||
DEFAULT_MATCH_MINUTES = 30
|
||||
|
||||
|
||||
def default_end_time(date, start_time):
|
||||
"""End time for a match whose form left it blank."""
|
||||
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
|
||||
|
||||
|
||||
def create_participants(match, data):
|
||||
"""Attach participants to a match, per its type.
|
||||
|
||||
Was written out twice, in create_match and in edit_match, and had already
|
||||
drifted: the copy in edit_match kept its player ids as strings and called
|
||||
int() on them one line later, the one in create_match did not (ARCH-005).
|
||||
|
||||
Returns:
|
||||
tuple: (player ids to notify, the participant rows created).
|
||||
"""
|
||||
sides = []
|
||||
if match.match_type == 'team_vs_team':
|
||||
for side, team_id in ((1, match.team1_id), (2, match.team2_id)):
|
||||
if team_id:
|
||||
members = TeamMember.query.filter_by(team_id=team_id).all()
|
||||
sides.append((side, [m.player_id for m in members]))
|
||||
elif match.match_type == 'player_vs_player':
|
||||
sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])]
|
||||
elif match.match_type == 'player_scrim':
|
||||
sides = [(None, data['player_ids'])]
|
||||
|
||||
player_ids = []
|
||||
participant_ids = []
|
||||
for side, ids in sides:
|
||||
for player_id in ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
participant_ids.append(participant.id)
|
||||
player_ids.append(player_id)
|
||||
return player_ids, participant_ids
|
||||
|
||||
|
||||
def can_schedule_match():
|
||||
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
|
||||
return isinstance(current_user, (Admin, Manager, Coach, Scout))
|
||||
@@ -38,17 +108,84 @@ def calendar():
|
||||
return render_template('pages/calendar.html')
|
||||
|
||||
|
||||
def calendar_window(args):
|
||||
"""The date range FullCalendar is asking about, if it said.
|
||||
|
||||
A URL event source appends `start` and `end` automatically, in ISO 8601
|
||||
with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
|
||||
needed here, and a value that does not parse is treated as absent
|
||||
rather than as an error: a calendar that shows too much is a
|
||||
performance problem, one that 400s is a broken page.
|
||||
|
||||
Args:
|
||||
args: request.args.
|
||||
|
||||
Returns:
|
||||
tuple[date | None, date | None]: Inclusive bounds.
|
||||
"""
|
||||
|
||||
def _parse(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
return _parse(args.get('start')), _parse(args.get('end'))
|
||||
|
||||
|
||||
@matches_bp.route('/api/events')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_events():
|
||||
"""API endpoint returning calendar events for FullCalendar."""
|
||||
"""Calendar events for FullCalendar.
|
||||
|
||||
Bounded and batched (PERF-002). This used to walk `tryout.matches` for
|
||||
every visible tryout — every tryout the club has ever run, for a
|
||||
president — and then issue one MatchParticipant query per match to find
|
||||
out whether the viewer was in it. The calendar's cost grew with the
|
||||
whole history, on every navigation.
|
||||
"""
|
||||
events = []
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||
|
||||
for tryout in tryouts:
|
||||
for match in tryout.matches:
|
||||
if tryouts_by_id:
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||
if window_start:
|
||||
query = query.filter(Match.date >= window_start)
|
||||
if window_end:
|
||||
query = query.filter(Match.date <= window_end)
|
||||
matches = query.all()
|
||||
|
||||
# Participants for every match in the window, in one query rather
|
||||
# than one per match. `participants` is a dynamic relationship, so
|
||||
# eager loading options do not apply to it.
|
||||
match_ids = [match.id for match in matches]
|
||||
participants_by_match = {}
|
||||
mine_by_match = {}
|
||||
if match_ids:
|
||||
rows = (
|
||||
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
|
||||
.options(joinedload(MatchParticipant.player))
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
participants_by_match.setdefault(row.match_id, []).append(row)
|
||||
if row.player_id == current_user.id:
|
||||
mine_by_match[row.match_id] = row
|
||||
|
||||
for match in matches:
|
||||
tryout = tryouts_by_id[match.tryout_id]
|
||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||
match_desc = match.description or ''
|
||||
# 'description' used to be participants_str + '<br>' + description.
|
||||
# Building presentation markup inside a JSON field is what carried
|
||||
# the stored XSS: the browser dropped it straight into innerHTML,
|
||||
# and player usernames travelled through it unescaped. The two
|
||||
# values are already separate keys, so the concatenation also made
|
||||
# the modal show the participants twice.
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
@@ -56,74 +193,80 @@ def api_events():
|
||||
teams.append(match.team1.name)
|
||||
if match.team2:
|
||||
teams.append(match.team2.name)
|
||||
participants_str = f"{' vs '.join(teams)}"
|
||||
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
||||
participants_str = ' vs '.join(teams)
|
||||
else:
|
||||
player_names = []
|
||||
for p in match.participants.all():
|
||||
player_names.append(p.player.username if p.player else 'Unknown Player')
|
||||
player_names = [
|
||||
p.player.username if p.player else 'Unknown Player'
|
||||
for p in participants_by_match.get(match.id, [])
|
||||
]
|
||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
||||
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
user_participant = MatchParticipant.query.filter_by(
|
||||
match_id=match.id, player_id=current_user.id,
|
||||
).first()
|
||||
user_participant = mine_by_match.get(match.id)
|
||||
|
||||
events.append({
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match', 'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status, 'description': match_desc,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id, 'match_id': match.id,
|
||||
'start_time': start_time_str, 'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
|
||||
},
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'description': match.description or '',
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
if user_participant
|
||||
else False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Add approved One on One sessions for the current user (player or coach)
|
||||
if isinstance(current_user, Player):
|
||||
one_on_ones = OneOnOneRequest.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
status='approved'
|
||||
player_id=current_user.id, status='approved'
|
||||
).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
one_on_ones = OneOnOneRequest.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
status='approved'
|
||||
coach_id=current_user.id, status='approved'
|
||||
).all()
|
||||
else:
|
||||
one_on_ones = []
|
||||
|
||||
for ooo in one_on_ones:
|
||||
events.append({
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
},
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@matches_bp.route('/api/events/<int:tryout_id>')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_events_for_tryout(tryout_id):
|
||||
"""API endpoint returning calendar events for a specific tryout."""
|
||||
@@ -133,13 +276,21 @@ def api_events_for_tryout(tryout_id):
|
||||
is_registered = False
|
||||
player_in_match = False
|
||||
if isinstance(current_user, Player):
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id,
|
||||
).first() is not None
|
||||
player_matches = Match.query.join(MatchParticipant).filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).all()
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
player_in_match = len(player_matches) > 0
|
||||
|
||||
if not can_view and not is_registered and not player_in_match:
|
||||
@@ -148,7 +299,9 @@ def api_events_for_tryout(tryout_id):
|
||||
events = []
|
||||
|
||||
for match in tryout.matches:
|
||||
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
||||
match_color = (
|
||||
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
||||
)
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
@@ -158,8 +311,16 @@ def api_events_for_tryout(tryout_id):
|
||||
teams.append(match.team2.name)
|
||||
participants_str = f"{' vs '.join(teams)}"
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
|
||||
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
|
||||
team1_players = [
|
||||
p.player.username
|
||||
for p in match.participants.filter_by(team_side=1).all()
|
||||
if p.player
|
||||
]
|
||||
team2_players = [
|
||||
p.player.username
|
||||
for p in match.participants.filter_by(team_side=2).all()
|
||||
if p.player
|
||||
]
|
||||
if team1_players and team2_players:
|
||||
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
|
||||
else:
|
||||
@@ -171,19 +332,25 @@ def api_events_for_tryout(tryout_id):
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
events.append({
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match', 'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status, 'match_type': match.match_type,
|
||||
'tryout_id': tryout.id, 'match_id': match.id,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str, 'end_time': end_time_str,
|
||||
},
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
@@ -194,127 +361,76 @@ def create_match(tryout_id):
|
||||
"""Create a new match / scrimmage within a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to schedule matches for this tryout.', 'danger')
|
||||
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.is_ended:
|
||||
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
|
||||
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
|
||||
all_players = [
|
||||
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)
|
||||
]
|
||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||
prefill_date = request.args.get('date', '')
|
||||
|
||||
def rerender():
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location')
|
||||
match_type = request.form.get('match_type')
|
||||
|
||||
if not start_time_str:
|
||||
flash('Start time is required. Please select a time slot.', 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
payload = match_form_payload()
|
||||
# A tryout match with no date of its own happens on the tryout's day.
|
||||
payload.setdefault('date', tryout.date.isoformat())
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(date_obj, start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash('Invalid time format.', 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||
data = MatchSchema().load(payload)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout_id, title=title, description=description,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location, match_type=match_type, created_by=current_user.id,
|
||||
tryout_id=tryout_id,
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
date=data['date'],
|
||||
start_time=data['start_time'],
|
||||
end_time=data['end_time'] or default_end_time(data['date'], data['start_time']),
|
||||
location=data['location'],
|
||||
match_type=data['match_type'],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
|
||||
notified_player_ids = []
|
||||
notified_participant_ids = []
|
||||
if data['match_type'] == 'team_vs_team':
|
||||
match.team1_id = data['team1_id']
|
||||
match.team2_id = data['team2_id']
|
||||
|
||||
if match_type == 'team_vs_team':
|
||||
team1_id = request.form.get('team1_id')
|
||||
team2_id = request.form.get('team2_id')
|
||||
match.team1_id = int(team1_id) if team1_id else None
|
||||
match.team2_id = int(team2_id) if team2_id else None
|
||||
if match.team1_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
if match.team2_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
elif match_type == 'player_vs_player':
|
||||
team1_player_ids = request.form.get('team1_player_ids', '')
|
||||
team2_player_ids = request.form.get('team2_player_ids', '')
|
||||
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
|
||||
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
|
||||
for pid in team1_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
for pid in team2_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = team1_ids + team2_ids
|
||||
elif match_type == 'player_scrim':
|
||||
player_ids = request.form.getlist('player_ids')
|
||||
for pid in player_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = [int(p) for p in player_ids]
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
for i, player_id in enumerate(notified_player_ids):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
send_schedule_notification(
|
||||
user_id=player_id, event_type='match', event_title=match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
notify_participants(
|
||||
title=match.title,
|
||||
date=match.date,
|
||||
start_time=match.start_time,
|
||||
end_time=match.end_time,
|
||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
||||
fallback_id=match.id,
|
||||
)
|
||||
|
||||
flash('Match scheduled successfully!', 'success')
|
||||
flash(_('Match scheduled successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
return rerender()
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||
@@ -325,11 +441,11 @@ def edit_match(match_id):
|
||||
tryout = match.tryout
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to edit this match.', 'danger')
|
||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||
return redirect(url_for('matches.calendar'))
|
||||
|
||||
if tryout.is_ended:
|
||||
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
|
||||
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||
@@ -340,140 +456,94 @@ def edit_match(match_id):
|
||||
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
|
||||
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
|
||||
|
||||
def rerender():
|
||||
"""The form, with everything the template needs.
|
||||
|
||||
One context, used by the GET and by a rejected POST alike. The
|
||||
rejection paths used to pass a shorter list, and match_form.html
|
||||
serialises participants_map into a <script> block — so a rejected
|
||||
edit died in `tojson` on an Undefined, turning a validation message
|
||||
into a 500.
|
||||
"""
|
||||
participants_map = {
|
||||
p.player_id: {
|
||||
'participant_id': p.id,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
'team_side': p.team_side,
|
||||
}
|
||||
for p in match.participants.all()
|
||||
}
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
match=match,
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
team1_player_ids=team1_player_ids,
|
||||
team2_player_ids=team2_player_ids,
|
||||
participants_map=participants_map,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
match.title = request.form.get('title')
|
||||
match.description = request.form.get('description')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location')
|
||||
status = request.form.get('status')
|
||||
|
||||
try:
|
||||
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids)
|
||||
data = MatchEditSchema().load(match_form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
if not start_time_str:
|
||||
flash('Start time is required.', 'danger')
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids)
|
||||
|
||||
try:
|
||||
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(match.date, match.start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
match.end_time = end_dt.time()
|
||||
except ValueError:
|
||||
match.start_time = None
|
||||
|
||||
match.location = location
|
||||
if status in ['scheduled', 'completed', 'cancelled']:
|
||||
match.status = status
|
||||
# Assigned only once the whole form has been accepted. Assigning as
|
||||
# each field was read meant a form rejected halfway had already
|
||||
# changed the record in the session.
|
||||
match.title = data['title']
|
||||
match.description = data['description']
|
||||
match.date = data['date']
|
||||
match.start_time = data['start_time']
|
||||
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||
match.location = data['location']
|
||||
match.status = data['status']
|
||||
|
||||
notified_player_ids = []
|
||||
notified_participant_ids = []
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
team1_id = request.form.get('team1_id')
|
||||
team2_id = request.form.get('team2_id')
|
||||
new_team1_id = int(team1_id) if team1_id else None
|
||||
new_team2_id = int(team2_id) if team2_id else None
|
||||
|
||||
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
|
||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
||||
if teams_changed:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
match.team1_id = new_team1_id
|
||||
match.team2_id = new_team2_id
|
||||
if match.team1_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
if match.team2_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
match.team1_id = data['team1_id']
|
||||
match.team2_id = data['team2_id']
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
else:
|
||||
if match.team1_id:
|
||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
|
||||
if match.team2_id:
|
||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
|
||||
elif match.match_type == 'player_vs_player':
|
||||
# Same teams: the roster stands, but everyone is told again,
|
||||
# because the date or the time may have moved.
|
||||
for team_id in (match.team1_id, match.team2_id):
|
||||
if team_id:
|
||||
notified_player_ids.extend(
|
||||
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
||||
)
|
||||
else:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
team1_str = request.form.get('team1_player_ids', '')
|
||||
team2_str = request.form.get('team2_player_ids', '')
|
||||
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
|
||||
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
|
||||
for pid in t1_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
for pid in t2_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
|
||||
elif match.match_type == 'player_scrim':
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
player_ids = request.form.getlist('player_ids')
|
||||
for pid in player_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = [int(p) for p in player_ids]
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
end_time_val = match.end_time or (match.start_time if match.start_time else None)
|
||||
if match.start_time and end_time_val:
|
||||
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
|
||||
else:
|
||||
event_time_str = 'TBD'
|
||||
event_date_str = match.date.strftime('%Y-%m-%d')
|
||||
for i, player_id in enumerate(notified_player_ids):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
send_schedule_notification(
|
||||
user_id=player_id, event_type='match', event_title=match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
notify_participants(
|
||||
title=match.title,
|
||||
date=match.date,
|
||||
start_time=match.start_time,
|
||||
end_time=match.end_time,
|
||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
||||
fallback_id=match.id,
|
||||
)
|
||||
|
||||
flash('Match updated successfully!', 'success')
|
||||
flash(_('Match updated successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
participants_map = {}
|
||||
for p in match.participants.all():
|
||||
participants_map[p.player_id] = {
|
||||
'participant_id': p.id,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
'team_side': p.team_side,
|
||||
}
|
||||
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
team1_player_ids=team1_player_ids,
|
||||
team2_player_ids=team2_player_ids,
|
||||
participants_map=participants_map)
|
||||
return rerender()
|
||||
|
||||
|
||||
@matches_bp.route('/api/manageable-tryouts')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_manageable_tryouts():
|
||||
"""API endpoint returning tryouts the current user can manage."""
|
||||
@@ -484,11 +554,14 @@ def api_manageable_tryouts():
|
||||
manageable = []
|
||||
for t in tryouts:
|
||||
if current_user.can_manage_this_tryout(t):
|
||||
manageable.append({
|
||||
'id': t.id, 'title': t.title,
|
||||
'date': t.date.strftime('%Y-%m-%d'),
|
||||
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
|
||||
})
|
||||
manageable.append(
|
||||
{
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'date': t.date.strftime('%Y-%m-%d'),
|
||||
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
|
||||
}
|
||||
)
|
||||
return jsonify(manageable)
|
||||
|
||||
|
||||
@@ -499,45 +572,76 @@ def delete_match(match_id):
|
||||
match = Match.query.get_or_404(match_id)
|
||||
tryout = match.tryout
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to delete this match.', 'danger')
|
||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||
return redirect(url_for('matches.calendar'))
|
||||
if tryout.is_ended:
|
||||
flash('This tryout has ended. Matches can no longer be deleted.', 'danger')
|
||||
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
# Notes outlive the match they were taken during: a coach's observation
|
||||
# keeps its value, and deleting it here would destroy unrelated content.
|
||||
# Only the context link is dropped. Participants go through the
|
||||
# relationship's delete-orphan cascade.
|
||||
PersonalNote.query.filter_by(match_id=match_id).update(
|
||||
{'match_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.delete(match)
|
||||
db.session.commit()
|
||||
flash('Match deleted successfully.', 'success')
|
||||
flash(_('Match deleted successfully.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
|
||||
def get_players_available_at_time(date_str, time_str):
|
||||
"""Get list of player IDs available at a specific date and time."""
|
||||
"""Player IDs whose weekly availability covers this date and time.
|
||||
|
||||
Two queries, whatever the size of the club. This used to load every
|
||||
active player and then run one PlayerDisponibility query per player, on
|
||||
an unindexed column — sixty players meant sixty-one round trips to
|
||||
answer a question the database can answer in one (PERF-003).
|
||||
|
||||
Args:
|
||||
date_str: 'YYYY-MM-DD'.
|
||||
time_str: 'HH:MM'.
|
||||
|
||||
Returns:
|
||||
list[int]: Player IDs, empty when the input does not parse.
|
||||
"""
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
date_for_day = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
day_of_week = date_for_day.weekday()
|
||||
day_of_week = parsed_date.weekday()
|
||||
active_player_ids = {
|
||||
row.id
|
||||
for row in User.query.with_entities(User.id)
|
||||
.filter_by(role='player', is_active_account=True)
|
||||
.all()
|
||||
}
|
||||
if not active_player_ids:
|
||||
return []
|
||||
|
||||
players = User.query.filter_by(role='player', is_active_account=True).all()
|
||||
available_players = []
|
||||
for player in players:
|
||||
disponibilities = PlayerDisponibility.query.filter_by(
|
||||
player_id=player.id, day_of_week=day_of_week,
|
||||
).all()
|
||||
for disp in disponibilities:
|
||||
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||
match_time = time_obj.hour * 60 + time_obj.minute
|
||||
if disp_start <= match_time < disp_end:
|
||||
available_players.append(player.id)
|
||||
break
|
||||
return available_players
|
||||
# The comparison stays in Python: start_time and end_time are stored as
|
||||
# time columns, and comparing them in SQL across three backends is not
|
||||
# worth the portability risk for a single day's rows.
|
||||
minutes = time_obj.hour * 60 + time_obj.minute
|
||||
available = []
|
||||
seen = set()
|
||||
for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
|
||||
if disp.player_id in seen or disp.player_id not in active_player_ids:
|
||||
continue
|
||||
start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||
if start <= minutes < end:
|
||||
available.append(disp.player_id)
|
||||
seen.add(disp.player_id)
|
||||
return available
|
||||
|
||||
|
||||
@matches_bp.route('/api/available_players/<date>/<time>')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_available_players(date, time):
|
||||
"""API endpoint to get players available at a specific date/time slot."""
|
||||
@@ -548,6 +652,7 @@ def api_available_players(date, time):
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle attendance_confirmed for a match participant."""
|
||||
@@ -564,8 +669,10 @@ def toggle_presence(match_id, participant_id):
|
||||
|
||||
participant.attendance_confirmed = not participant.attendance_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'participant_id': participant.id,
|
||||
'attendance_confirmed': participant.attendance_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'attendance_confirmed': participant.attendance_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
}
|
||||
)
|
||||
|
||||
+160
-154
@@ -3,31 +3,42 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||
Admin,
|
||||
Coach,
|
||||
Manager,
|
||||
OrgTeam,
|
||||
Player,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
from app.pagination import paginate
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
from app.routes.matches import default_end_time
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.validators import TeamMatchSchema
|
||||
|
||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||
|
||||
|
||||
def can_manage_team_match(team):
|
||||
"""Check if current user can manage matches for this team."""
|
||||
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
|
||||
"""Whether the current user can manage matches for this team.
|
||||
|
||||
Same rule as administering the team itself, so it is the same call.
|
||||
This function used to restate it, and the restatement drifted.
|
||||
"""
|
||||
return can_manage_org_team(current_user, team)
|
||||
|
||||
|
||||
@team_matches_bp.route('')
|
||||
@@ -36,29 +47,21 @@ def list_matches():
|
||||
"""List all team matches visible to the current user."""
|
||||
filter_team_id = request.args.get('team_id', type=int)
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
# A manager administers every team, so the listing shows them all;
|
||||
# visible_org_teams() only reports the teams they are attached to.
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, (Coach, Player)):
|
||||
teams = visible_org_teams(current_user)
|
||||
team_ids = [t.id for t in teams]
|
||||
matches_query = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(team_ids),
|
||||
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
elif isinstance(current_user, Player):
|
||||
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
|
||||
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
|
||||
matches_query = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(player_team_ids),
|
||||
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
matches_query = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(team_ids),
|
||||
)
|
||||
if team_ids
|
||||
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
else:
|
||||
teams = []
|
||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
@@ -66,25 +69,39 @@ def list_matches():
|
||||
if filter_team_id:
|
||||
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
||||
|
||||
matches = matches_query.order_by(TeamMatch.date.desc()).all()
|
||||
# Pagination also bounds the per-match participant loop below, which is
|
||||
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
|
||||
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
|
||||
matches = matches_page.items
|
||||
|
||||
match_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participants = []
|
||||
for p in tm.participants.all():
|
||||
participants.append({
|
||||
'id': p.id, 'player': p.player,
|
||||
'is_confirmed': p.is_confirmed,
|
||||
})
|
||||
match_data.append({
|
||||
'match': tm, 'participants': participants,
|
||||
'confirmed_count': confirmed, 'total_count': total,
|
||||
})
|
||||
participants.append(
|
||||
{
|
||||
'id': p.id,
|
||||
'player': p.player,
|
||||
'is_confirmed': p.is_confirmed,
|
||||
}
|
||||
)
|
||||
match_data.append(
|
||||
{
|
||||
'match': tm,
|
||||
'participants': participants,
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/team_matches.html',
|
||||
teams=teams, match_data=match_data,
|
||||
now=datetime.utcnow())
|
||||
return render_template(
|
||||
'pages/team_matches.html',
|
||||
teams=teams,
|
||||
match_data=match_data,
|
||||
pagination=matches_page,
|
||||
now=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
||||
@@ -93,15 +110,16 @@ def create_match(team_id):
|
||||
"""Create a new regular-season team match."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to schedule matches for this team.', 'danger')
|
||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
|
||||
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
||||
prefill_date = request.args.get('date', '')
|
||||
is_practice = request.args.get('type') == 'practice'
|
||||
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
||||
|
||||
if is_practice and request.method == 'GET':
|
||||
|
||||
class TryoutProxy:
|
||||
def __init__(self, team_obj):
|
||||
self.id = 0
|
||||
@@ -113,56 +131,49 @@ def create_match(team_id):
|
||||
proxy_tryout = TryoutProxy(team)
|
||||
all_players = [tp.player for tp in team_players if tp.player]
|
||||
|
||||
return render_template('pages/match_form.html',
|
||||
tryout=proxy_tryout, teams=[], all_players=all_players,
|
||||
prefill_date=prefill_date, is_practice=True,
|
||||
team_id=team_id, team=team)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=proxy_tryout,
|
||||
teams=[],
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=True,
|
||||
team_id=team_id,
|
||||
team=team,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', default_title)
|
||||
opponent = request.form.get('opponent', '').strip() if not is_practice else None
|
||||
description = request.form.get('description', '')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location', '')
|
||||
|
||||
if not date_str:
|
||||
flash('Date is required.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date)
|
||||
payload = form_payload(list_fields=(), optional_blank=())
|
||||
# A practice has no opponent, whatever the form sent.
|
||||
payload.setdefault('title', default_title)
|
||||
if is_practice:
|
||||
payload.pop('opponent', None)
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
data = TeamMatchSchema().load(payload)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/team_match_form.html',
|
||||
team=team,
|
||||
team_players=team_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=is_practice,
|
||||
)
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
if start_time_str:
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(date_obj, start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash('Invalid time format.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
start_time = data['start_time']
|
||||
end_time = data['end_time'] or default_end_time(data['date'], start_time)
|
||||
|
||||
team_match = TeamMatch(
|
||||
org_team_id=team_id, title=title,
|
||||
description=description or None,
|
||||
opponent=opponent or None,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location or None, created_by=current_user.id,
|
||||
org_team_id=team_id,
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
opponent=data['opponent'],
|
||||
date=data['date'],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
location=data['location'],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team_match)
|
||||
db.session.flush()
|
||||
@@ -170,7 +181,8 @@ def create_match(team_id):
|
||||
notified_participant_ids = []
|
||||
for tp in team_players:
|
||||
participant = TeamMatchParticipant(
|
||||
team_match_id=team_match.id, player_id=tp.player_id,
|
||||
team_match_id=team_match.id,
|
||||
player_id=tp.player_id,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
@@ -178,20 +190,20 @@ def create_match(team_id):
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
notify_participants(
|
||||
title=team_match.title,
|
||||
date=team_match.date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
participants=zip_participants(
|
||||
[tp.player_id for tp in team_players], notified_participant_ids
|
||||
),
|
||||
fallback_id=team_match.id,
|
||||
)
|
||||
|
||||
for i, tp in enumerate(team_players):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
|
||||
send_schedule_notification(
|
||||
user_id=tp.player_id, event_type='match',
|
||||
event_title=team_match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
flash(f'Team match "{title}" scheduled successfully!', 'success')
|
||||
flash(
|
||||
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
|
||||
)
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||
@@ -205,47 +217,42 @@ def edit_match(match_id):
|
||||
team = team_match.org_team
|
||||
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to edit this match.', 'danger')
|
||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if request.method == 'POST':
|
||||
team_match.title = request.form.get('title', team_match.title)
|
||||
team_match.description = request.form.get('description', '') or None
|
||||
team_match.opponent = request.form.get('opponent', '').strip() or None
|
||||
# The three date and time fields used to be checked one at a time,
|
||||
# each flashing and redirecting on its own: a form with two mistakes
|
||||
# took two round trips to be told about both. One schema now, every
|
||||
# problem reported at once and in place.
|
||||
#
|
||||
# Known limit: the re-render reads the stored record, so what was
|
||||
# typed is not echoed back. Repopulating the form from the
|
||||
# submission is a separate change to the template.
|
||||
try:
|
||||
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
)
|
||||
|
||||
date_str = request.form.get('date')
|
||||
if date_str:
|
||||
try:
|
||||
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return redirect(url_for('team_matches.edit_match', match_id=match_id))
|
||||
|
||||
start_time_str = request.form.get('start_time')
|
||||
if start_time_str:
|
||||
try:
|
||||
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
end_time_str = request.form.get('end_time')
|
||||
if end_time_str:
|
||||
try:
|
||||
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
team_match.location = request.form.get('location', '') or None
|
||||
status = request.form.get('status')
|
||||
if status in ['scheduled', 'completed', 'cancelled']:
|
||||
team_match.status = status
|
||||
team_match.title = data['title']
|
||||
team_match.description = data['description']
|
||||
team_match.opponent = data['opponent']
|
||||
team_match.date = data['date']
|
||||
team_match.start_time = data['start_time']
|
||||
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||
team_match.location = data['location']
|
||||
team_match.status = data['status']
|
||||
|
||||
db.session.commit()
|
||||
flash('Match updated successfully!', 'success')
|
||||
flash(_('Match updated successfully!'), 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html',
|
||||
match=team_match, team=team, team_players=[])
|
||||
return render_template(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@@ -255,15 +262,16 @@ def delete_match(match_id):
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team = team_match.org_team
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to delete this match.', 'danger')
|
||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
db.session.delete(team_match)
|
||||
db.session.commit()
|
||||
flash('Match deleted successfully.', 'success')
|
||||
flash(_('Match deleted successfully.'), 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
|
||||
@team_matches_bp.route('/api/manageable-teams')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def api_manageable_teams():
|
||||
"""API endpoint returning teams the current user can schedule matches for."""
|
||||
@@ -273,12 +281,7 @@ def api_manageable_teams():
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
teams = coach_org_teams(current_user)
|
||||
else:
|
||||
return jsonify([])
|
||||
|
||||
@@ -286,6 +289,7 @@ def api_manageable_teams():
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle is_confirmed for a team match participant."""
|
||||
@@ -302,8 +306,10 @@ def toggle_presence(match_id, participant_id):
|
||||
|
||||
participant.is_confirmed = not participant.is_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'participant_id': participant.id,
|
||||
'is_confirmed': participant.is_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'is_confirmed': participant.is_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
}
|
||||
)
|
||||
|
||||
+327
-170
@@ -3,16 +3,34 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, Team, TeamMember,
|
||||
PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin,
|
||||
Coach,
|
||||
Contract,
|
||||
Manager,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
TeamMatch,
|
||||
TeamNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
User,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
|
||||
@@ -22,34 +40,35 @@ def list_teams():
|
||||
"""List all organization teams visible to the current user."""
|
||||
can_manage = current_user.can_manage_teams()
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.managers.any(id=current_user.id),
|
||||
OrgTeam.manager_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Player):
|
||||
flash('Use My Team(s) to view your teams.', 'info')
|
||||
if isinstance(current_user, Player):
|
||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||
return redirect(url_for('teams.my_teams'))
|
||||
else:
|
||||
flash('You do not have permission to view teams.', 'danger')
|
||||
if not isinstance(current_user, (Admin, Coach, Manager)):
|
||||
flash(_('You do not have permission to view teams.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
return render_template('pages/teams.html', teams=teams, coaches=coaches,
|
||||
managers=managers, all_players=all_players, can_manage=can_manage)
|
||||
teams = visible_org_teams(current_user)
|
||||
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
# is_active_account, like the two queries above it. Without it the "add
|
||||
# player" select offered accounts that had been deactivated, and
|
||||
# add_player accepted them.
|
||||
all_players = (
|
||||
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
return render_template(
|
||||
'pages/teams.html',
|
||||
teams=teams,
|
||||
coaches=coaches,
|
||||
managers=managers,
|
||||
all_players=all_players,
|
||||
can_manage=can_manage,
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/my-teams')
|
||||
@@ -57,7 +76,7 @@ def list_teams():
|
||||
def my_teams():
|
||||
"""View the player's own teams with upcoming matches."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash('This page is for players.', 'info')
|
||||
flash(_('This page is for players.'), 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
from app.models import TeamMatch, TeamMatchParticipant
|
||||
@@ -67,74 +86,136 @@ def my_teams():
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
matches = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id == org_team.id,
|
||||
TeamMatch.status == 'scheduled',
|
||||
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
|
||||
matches = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id == org_team.id,
|
||||
TeamMatch.status == 'scheduled',
|
||||
)
|
||||
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
matches_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participant = TeamMatchParticipant.query.filter_by(
|
||||
team_match_id=tm.id, player_id=current_user.id,
|
||||
team_match_id=tm.id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
matches_data.append({
|
||||
'match': tm,
|
||||
'participant_id': participant.id if participant else None,
|
||||
'is_confirmed': participant.is_confirmed if participant else False,
|
||||
'confirmed_count': confirmed, 'total_count': total,
|
||||
})
|
||||
matches_data.append(
|
||||
{
|
||||
'match': tm,
|
||||
'participant_id': participant.id if participant else None,
|
||||
'is_confirmed': participant.is_confirmed if participant else False,
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
team_data.append({
|
||||
'team': org_team, 'matches': matches_data,
|
||||
'coaches': org_team.get_coaches(),
|
||||
'managers': org_team.get_managers(),
|
||||
})
|
||||
team_data.append(
|
||||
{
|
||||
'team': org_team,
|
||||
'matches': matches_data,
|
||||
'coaches': org_team.get_coaches(),
|
||||
'managers': org_team.get_managers(),
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
||||
|
||||
|
||||
def _posted(schema):
|
||||
"""Load a form through `schema`, or None when it will not load.
|
||||
|
||||
The five assignment routes below each answer a bad field with their own
|
||||
flash and a redirect to the same page, so a shared "it did not validate"
|
||||
return is enough; the field-level message is flashed on the way out.
|
||||
"""
|
||||
try:
|
||||
return schema.load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return None
|
||||
|
||||
|
||||
def _assignable(user, expected_class):
|
||||
"""Whether this account may be given a role on a team.
|
||||
|
||||
Deactivated accounts were offered by the selects and accepted by the
|
||||
routes. `is_active_account` is what stops someone logging in — a person
|
||||
who has left the club — so putting them on a roster contradicts the one
|
||||
control that says they are gone. The listings filtered it for coaches and
|
||||
managers and not for players, two lines apart, which is how it went
|
||||
unnoticed.
|
||||
"""
|
||||
return isinstance(user, expected_class) and bool(user.is_active_account)
|
||||
|
||||
|
||||
def _staff_member(user_id, expected_class):
|
||||
"""The user behind an id, only if they may hold the role being assigned.
|
||||
|
||||
Returns None for a missing id, an unknown id, an account of the wrong
|
||||
role, or a deactivated one. The role check is the point (SEC-16): the id
|
||||
comes from a `<select>` the browser rendered, so it is a value the client
|
||||
chooses, and nothing checked it in two of the three places that used it.
|
||||
A forged submission could therefore list a player among a team's coaches
|
||||
— the same defect wave G fixed in `tryouts.py`, left standing here.
|
||||
|
||||
Defers to `_assignable` rather than repeating `isinstance`: two functions
|
||||
in one file answering "may this account take this role" differently is
|
||||
the shape of every defect this module has had.
|
||||
|
||||
Args:
|
||||
user_id: Already an int or None, thanks to the schema.
|
||||
expected_class: Coach or Manager.
|
||||
|
||||
Returns:
|
||||
User | None: The account, when it may take the role.
|
||||
"""
|
||||
if not user_id:
|
||||
return None
|
||||
user = db.session.get(User, user_id)
|
||||
return user if user and _assignable(user, expected_class) else None
|
||||
|
||||
|
||||
@teams_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_team():
|
||||
"""Create a new organization team."""
|
||||
if not current_user.can_manage_teams():
|
||||
flash('You do not have permission to create teams.', 'danger')
|
||||
flash(_('You do not have permission to create teams.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
name = request.form.get('name')
|
||||
coach_id = request.form.get('coach_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
|
||||
if not name:
|
||||
flash('Team name is required.', 'danger')
|
||||
try:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = OrgTeam.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
flash(f'Team "{name}" already exists.', 'danger')
|
||||
name = data['name']
|
||||
if OrgTeam.query.filter_by(name=name).first():
|
||||
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach = _staff_member(data['coach_id'], Coach)
|
||||
manager = _staff_member(data['manager_id'], Manager)
|
||||
|
||||
team = OrgTeam(
|
||||
name=name,
|
||||
coach_id=int(coach_id) if coach_id else None,
|
||||
manager_id=int(manager_id) if manager_id else None,
|
||||
coach_id=coach.id if coach else None,
|
||||
manager_id=manager.id if manager else None,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
|
||||
if coach_id:
|
||||
coach_user = User.query.get(int(coach_id))
|
||||
if coach_user:
|
||||
team.coaches.append(coach_user)
|
||||
if manager_id:
|
||||
manager_user = User.query.get(int(manager_id))
|
||||
if manager_user:
|
||||
team.managers.append(manager_user)
|
||||
if coach:
|
||||
team.coaches.append(coach)
|
||||
if manager:
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" created successfully!', 'success')
|
||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -144,83 +225,110 @@ def edit_team(team_id):
|
||||
"""Edit an existing organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
name = request.form.get('name')
|
||||
coach_id = request.form.get('coach_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
|
||||
if not name:
|
||||
flash('Team name is required.', 'danger')
|
||||
try:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first()
|
||||
if existing:
|
||||
flash(f'Team "{name}" already exists.', 'danger')
|
||||
name = data['name']
|
||||
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
|
||||
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if request.form.get('sync_staff') == '1':
|
||||
coach_ids = request.form.getlist('coach_ids')
|
||||
manager_ids = request.form.getlist('manager_ids')
|
||||
team.name = name
|
||||
|
||||
team.coaches = []
|
||||
for cid in coach_ids:
|
||||
if cid and cid.strip():
|
||||
coach_user = User.query.get(int(cid))
|
||||
if coach_user and isinstance(coach_user, Coach):
|
||||
team.coaches.append(coach_user)
|
||||
if data['sync_staff'] == '1':
|
||||
team.coaches = [
|
||||
user for user in (_staff_member(cid, Coach) for cid in data['coach_ids']) if user
|
||||
]
|
||||
coach_list = team.coaches.all()
|
||||
team.coach_id = coach_list[0].id if coach_list else None
|
||||
|
||||
team.managers = []
|
||||
for mid in manager_ids:
|
||||
if mid and mid.strip():
|
||||
manager_user = User.query.get(int(mid))
|
||||
if manager_user and isinstance(manager_user, Manager):
|
||||
team.managers.append(manager_user)
|
||||
team.managers = [
|
||||
user for user in (_staff_member(mid, Manager) for mid in data['manager_ids']) if user
|
||||
]
|
||||
manager_list = team.managers.all()
|
||||
team.manager_id = manager_list[0].id if manager_list else None
|
||||
else:
|
||||
team.coach_id = int(coach_id) if coach_id else None
|
||||
team.manager_id = int(manager_id) if manager_id else None
|
||||
# This branch never checked the role, while the one above did — the
|
||||
# same file disagreeing with itself (SEC-16). _staff_member is the
|
||||
# single answer now.
|
||||
coach = _staff_member(data['coach_id'], Coach)
|
||||
manager = _staff_member(data['manager_id'], Manager)
|
||||
|
||||
if coach_id:
|
||||
coach_user = User.query.get(int(coach_id))
|
||||
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
|
||||
team.coaches.append(coach_user)
|
||||
if manager_id:
|
||||
manager_user = User.query.get(int(manager_id))
|
||||
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
|
||||
team.managers.append(manager_user)
|
||||
team.coach_id = coach.id if coach else None
|
||||
team.manager_id = manager.id if manager else None
|
||||
|
||||
if coach and not team.coaches.filter_by(id=coach.id).first():
|
||||
team.coaches.append(coach)
|
||||
if manager and not team.managers.filter_by(id=manager.id).first():
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" updated successfully!', 'success')
|
||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_team(team_id):
|
||||
"""Delete an organization team."""
|
||||
if not current_user.can_manage_teams():
|
||||
flash('You do not have permission to delete teams.', 'danger')
|
||||
"""Delete an organization team.
|
||||
|
||||
Two checks, not one, and not the one the audit recommended (SEC-AUTHZ-006).
|
||||
|
||||
The constat was right about the inconsistency: this was the only team
|
||||
operation guarded by the global `can_manage_teams()` while the other
|
||||
nine use `can_manage_this_org_team(team)`. It was wrong about the fix.
|
||||
Simply swapping to the per-object check **widens** access — `Coach`
|
||||
returns False for the global capability and True for its own teams, so
|
||||
the swap would hand every coach the power to delete the team they coach,
|
||||
along with its notes and its match history. The constat reasoned about
|
||||
`Manager`, where both return True, and missed the role where they differ.
|
||||
|
||||
Requiring both preserves today's behaviour exactly (admins and managers
|
||||
yes, coaches no) and still closes the debt the constat was about: the
|
||||
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
|
||||
deletion narrows with it instead of staying the one way in.
|
||||
"""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
|
||||
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
|
||||
flash(_('You do not have permission to delete teams.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
name = team.name
|
||||
|
||||
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
|
||||
for t in tryouts:
|
||||
t.target_org_team_id = None
|
||||
db.session.commit()
|
||||
# One transaction. This used to commit three times, so a failure at the
|
||||
# third step left the tryouts detached and the players removed without
|
||||
# the team being deleted — an inconsistent state nothing could undo.
|
||||
#
|
||||
# TeamNote.org_team_id and TeamMatch.org_team_id are NOT NULL, and were
|
||||
# not handled at all: deleting a team that had ever been used raised
|
||||
# IntegrityError. Contract.team_id and OneOnOneRequest.org_team_id are
|
||||
# nullable, and the rows outlive the team, so they are only detached.
|
||||
|
||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
|
||||
db.session.commit()
|
||||
# Entities that only make sense as part of the team.
|
||||
TeamNote.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
||||
for team_match in TeamMatch.query.filter_by(org_team_id=team_id).all():
|
||||
db.session.delete(team_match) # participants follow by cascade
|
||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
||||
|
||||
# Entities that survive it.
|
||||
Tryout.query.filter_by(target_org_team_id=team_id).update(
|
||||
{'target_org_team_id': None}, synchronize_session=False
|
||||
)
|
||||
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
|
||||
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
|
||||
{'org_team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" deleted successfully.', 'success')
|
||||
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -230,28 +338,40 @@ def add_coach(team_id):
|
||||
"""Add a coach to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
coach_id = request.form.get('coach_id')
|
||||
if not coach_id:
|
||||
flash('Please select a coach.', 'danger')
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
if not data['coach_id']:
|
||||
flash(_('Please select a coach.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach = User.query.get_or_404(int(coach_id))
|
||||
if not isinstance(coach, Coach):
|
||||
flash('Only coaches can be assigned as coach.', 'danger')
|
||||
coach = db.session.get(User, data['coach_id'])
|
||||
if not coach or not _assignable(coach, Coach):
|
||||
flash(_('Only coaches can be assigned as coach.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.coaches.filter_by(id=coach.id).first():
|
||||
flash(f'{coach.username} is already a coach of {team.name}.', 'info')
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a coach of %(name)s.',
|
||||
username=coach.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.coaches.append(coach)
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
flash(f'{coach.username} added as coach of {team.name}.', 'success')
|
||||
flash(
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -261,28 +381,40 @@ def add_manager(team_id):
|
||||
"""Add a manager to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
manager_id = request.form.get('manager_id')
|
||||
if not manager_id:
|
||||
flash('Please select a manager.', 'danger')
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
if not data['manager_id']:
|
||||
flash(_('Please select a manager.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
manager = User.query.get_or_404(int(manager_id))
|
||||
if not isinstance(manager, Manager):
|
||||
flash('Only managers can be assigned as manager.', 'danger')
|
||||
manager = db.session.get(User, data['manager_id'])
|
||||
if not manager or not _assignable(manager, Manager):
|
||||
flash(_('Only managers can be assigned as manager.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.managers.filter_by(id=manager.id).first():
|
||||
flash(f'{manager.username} is already a manager of {team.name}.', 'info')
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a manager of %(name)s.',
|
||||
username=manager.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.managers.append(manager)
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
flash(f'{manager.username} added as manager of {team.name}.', 'success')
|
||||
flash(
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -292,12 +424,15 @@ def remove_coach(team_id):
|
||||
"""Remove a coach from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
coach_id = request.form.get('coach_id')
|
||||
if coach_id:
|
||||
coach = User.query.get(int(coach_id))
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if data['coach_id']:
|
||||
coach = db.session.get(User, data['coach_id'])
|
||||
if coach and team.coaches.filter_by(id=coach.id).first():
|
||||
team.coaches.remove(coach)
|
||||
if team.coach_id == coach.id:
|
||||
@@ -307,7 +442,7 @@ def remove_coach(team_id):
|
||||
team.coach_id = None
|
||||
|
||||
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'))
|
||||
|
||||
|
||||
@@ -317,12 +452,15 @@ def remove_manager(team_id):
|
||||
"""Remove a manager from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
manager_id = request.form.get('manager_id')
|
||||
if manager_id:
|
||||
manager = User.query.get(int(manager_id))
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if data['manager_id']:
|
||||
manager = db.session.get(User, data['manager_id'])
|
||||
if manager and team.managers.filter_by(id=manager.id).first():
|
||||
team.managers.remove(manager)
|
||||
if team.manager_id == manager.id:
|
||||
@@ -332,7 +470,7 @@ def remove_manager(team_id):
|
||||
team.manager_id = None
|
||||
|
||||
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'))
|
||||
|
||||
|
||||
@@ -342,29 +480,34 @@ def add_player(team_id):
|
||||
"""Add a player to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
player_id = request.form.get('player_id')
|
||||
status = request.form.get('status', 'starter')
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'danger')
|
||||
data = _posted(TeamPlayerSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
if not data['player_id']:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(int(player_id))
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only assign players to teams.', 'danger')
|
||||
status = data['status']
|
||||
player = db.session.get(User, data['player_id'])
|
||||
if not player or not _assignable(player, Player):
|
||||
flash(_('Can only assign players to teams.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||
if existing:
|
||||
flash(f'{player.username} is already on {team.name}.', 'info')
|
||||
flash(
|
||||
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||
db.session.add(tp)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} added to {team.name}!', 'success')
|
||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -374,22 +517,29 @@ def remove_player(team_id, player_id):
|
||||
"""Remove a player from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
db.session.delete(tp)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} removed from {team.name}.', 'success')
|
||||
flash(
|
||||
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_player_status(team_id, player_id):
|
||||
"""Toggle a player's status between starter and substitute."""
|
||||
@@ -403,10 +553,14 @@ def toggle_player_status(team_id, player_id):
|
||||
|
||||
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True, 'player_id': player_id,
|
||||
'new_status': tp.status, 'player_name': tp.player.username,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
'success': True,
|
||||
'player_id': player_id,
|
||||
'new_status': tp.status,
|
||||
'player_name': tp.player.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||
@@ -415,7 +569,7 @@ def add_team_note(team_id):
|
||||
"""Add a team improvement note (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('You do not have permission to add notes to this team.', 'danger')
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
@@ -423,7 +577,7 @@ def add_team_note(team_id):
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash('Team notes added successfully!', 'success')
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -433,17 +587,20 @@ def add_player_note(team_id, player_id):
|
||||
"""Add a personal note for a player (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
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'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
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'))
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
@@ -451,5 +608,5 @@ def add_player_note(team_id, player_id):
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(f'Note added for {player.username}!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
+357
-215
@@ -4,17 +4,36 @@ This module handles CRUD operations for tryouts and player registrations.
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
|
||||
OrgTeam, Match, MatchParticipant,
|
||||
ESPORT_GAMES, GAME_POSITIONS,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
Scout,
|
||||
Team,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.validators import PlayerSelectionSchema, TryoutSchema
|
||||
|
||||
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
|
||||
|
||||
|
||||
@@ -23,6 +42,43 @@ def can_manage():
|
||||
return isinstance(current_user, (Admin, Manager))
|
||||
|
||||
|
||||
def tryout_form_payload():
|
||||
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
||||
return form_payload(list_fields=('coach_ids',), optional_blank=())
|
||||
|
||||
|
||||
def coaches_from_ids(coach_ids):
|
||||
"""The coach accounts behind these ids.
|
||||
|
||||
Filtered by role, which the previous `User.id.in_(...)` was not: the form
|
||||
posts a list of ids and nothing stopped a hand-made submission from
|
||||
naming a player, who then appeared as a coach of the tryout and inherited
|
||||
every permission that comes with it.
|
||||
"""
|
||||
if not coach_ids:
|
||||
return []
|
||||
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load these users in one query, keyed by id.
|
||||
|
||||
Replaces the `User.query.get()`-inside-a-loop that view_tryout used in
|
||||
three separate places (PERF-001). Missing ids are simply absent from
|
||||
the result, which is what a per-row get() returning None amounted to.
|
||||
|
||||
Args:
|
||||
user_ids: Iterable of primary keys, may repeat and may be empty.
|
||||
|
||||
Returns:
|
||||
dict[int, User]
|
||||
"""
|
||||
wanted = {user_id for user_id in user_ids if user_id}
|
||||
if not wanted:
|
||||
return {}
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
@tryouts_bp.route('')
|
||||
@login_required
|
||||
def list_tryouts():
|
||||
@@ -39,68 +95,57 @@ def list_tryouts():
|
||||
def create_tryout():
|
||||
"""Create a new tryout event. Requires Admin or Manager."""
|
||||
if not can_manage():
|
||||
flash('You do not have permission to create tryouts.', 'danger')
|
||||
flash(_('You do not have permission to create tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
|
||||
def rerender():
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=None,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
game = request.form.get('game')
|
||||
date_str = request.form.get('date')
|
||||
end_date_str = request.form.get('end_date')
|
||||
location = request.form.get('location')
|
||||
max_players = request.form.get('max_players')
|
||||
target_org_team_id = request.form.get('target_org_team_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
coach_ids = request.form.getlist('coach_ids')
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid start date format.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
|
||||
end_date_obj = None
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
if end_date_obj < date_obj:
|
||||
flash('End date cannot be before start date.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid end date format.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
data = TryoutSchema().load(tryout_form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
tryout = Tryout(
|
||||
title=title, description=description, game=game, date=date_obj,
|
||||
end_date=end_date_obj,
|
||||
location=location,
|
||||
max_players=int(max_players) if max_players else None,
|
||||
created_by=current_user.id, status='upcoming',
|
||||
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
|
||||
manager_id=int(manager_id) if manager_id else None,
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
game=data['game'],
|
||||
date=data['date'],
|
||||
end_date=data['end_date'],
|
||||
location=data['location'],
|
||||
max_players=data['max_players'],
|
||||
created_by=current_user.id,
|
||||
status='upcoming',
|
||||
target_org_team_id=data['target_org_team_id'],
|
||||
manager_id=data['manager_id'],
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
# Assign coaches via many-to-many
|
||||
if coach_ids:
|
||||
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
|
||||
tryout.coaches = coach_users
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash('Tryout created successfully!', 'success')
|
||||
flash(_('Tryout created successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return rerender()
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
|
||||
@@ -110,72 +155,54 @@ def edit_tryout(tryout_id):
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to edit this tryout.', 'danger')
|
||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
if tryout.is_ended:
|
||||
flash('This tryout has ended and can no longer be modified.', 'danger')
|
||||
flash(_('This tryout has ended and can no longer be modified.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
|
||||
)
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
|
||||
)
|
||||
|
||||
def rerender():
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=tryout,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
game = request.form.get('game')
|
||||
date_str = request.form.get('date')
|
||||
end_date_str = request.form.get('end_date')
|
||||
location = request.form.get('location')
|
||||
max_players = request.form.get('max_players')
|
||||
target_org_team_id = request.form.get('target_org_team_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
coach_ids = request.form.getlist('coach_ids')
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid start date format.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
data = TryoutSchema().load(tryout_form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return rerender()
|
||||
|
||||
end_date_obj = None
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
if end_date_obj < date_obj:
|
||||
flash('End date cannot be before start date.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid end date format.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
|
||||
tryout.title = title
|
||||
tryout.description = description
|
||||
tryout.game = game
|
||||
tryout.date = date_obj
|
||||
tryout.end_date = end_date_obj
|
||||
tryout.location = location
|
||||
tryout.max_players = int(max_players) if max_players else None
|
||||
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
|
||||
tryout.manager_id = int(manager_id) if manager_id else None
|
||||
|
||||
# Update coaches via many-to-many
|
||||
if coach_ids:
|
||||
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
|
||||
tryout.coaches = coach_users
|
||||
else:
|
||||
tryout.coaches = []
|
||||
tryout.title = data['title']
|
||||
tryout.description = data['description']
|
||||
tryout.game = data['game']
|
||||
tryout.date = data['date']
|
||||
tryout.end_date = data['end_date']
|
||||
tryout.location = data['location']
|
||||
tryout.max_players = data['max_players']
|
||||
tryout.target_org_team_id = data['target_org_team_id']
|
||||
tryout.manager_id = data['manager_id']
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash('Tryout updated successfully!', 'success')
|
||||
flash(_('Tryout updated successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return rerender()
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>')
|
||||
@@ -192,61 +219,102 @@ def view_tryout(tryout_id):
|
||||
elif isinstance(current_user, Coach):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Player):
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id).first() is not None
|
||||
player_in_match = MatchParticipant.query.join(Match).filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).first() is not None
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
player_in_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
can_view = is_registered or player_in_match
|
||||
elif isinstance(current_user, Scout):
|
||||
can_view = True
|
||||
|
||||
if not can_view:
|
||||
flash('You do not have permission to view this tryout.', 'danger')
|
||||
flash(_('You do not have permission to view this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
# Everything below used to run one query per row (PERF-001): one
|
||||
# User.query.get() per registration, one Evaluation lookup per player,
|
||||
# one TeamMember query per team and one more User.query.get() per
|
||||
# member. Thirty registrants and four teams put this page well past a
|
||||
# hundred round trips, on unindexed columns.
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
|
||||
registered_player_ids = [r.player_id for r in registrations if r.player_id]
|
||||
players_by_id = _users_by_id(registered_player_ids)
|
||||
registered_players = [
|
||||
players_by_id[player_id]
|
||||
for player_id in registered_player_ids
|
||||
if player_id in players_by_id
|
||||
]
|
||||
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
|
||||
|
||||
player_eval_status = {}
|
||||
if current_user.can_evaluate():
|
||||
for p in registered_players:
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
).first()
|
||||
player_eval_status[p.id] = existing is not None
|
||||
evaluated_by_me = {
|
||||
row.player_id
|
||||
for row in evaluations
|
||||
if row.evaluator_id == current_user.id and row.player_id
|
||||
}
|
||||
player_eval_status = {p.id: p.id in evaluated_by_me for p in registered_players}
|
||||
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id,
|
||||
).first() is not None
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
team_data = []
|
||||
for team in teams:
|
||||
members = TeamMember.query.filter_by(team_id=team.id).all()
|
||||
team_data.append({
|
||||
'team': team,
|
||||
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
|
||||
for m in members],
|
||||
})
|
||||
team_ids = [team.id for team in teams]
|
||||
members_by_team = {}
|
||||
if team_ids:
|
||||
member_rows = TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).all()
|
||||
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
||||
for row in member_rows:
|
||||
members_by_team.setdefault(row.team_id, []).append(
|
||||
{'player': member_players.get(row.player_id), 'position': row.position}
|
||||
)
|
||||
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
|
||||
|
||||
can_edit = current_user.can_manage_this_tryout(tryout)
|
||||
|
||||
can_view_calendar = can_edit
|
||||
if isinstance(current_user, Player):
|
||||
player_in_match = MatchParticipant.query.join(Match).filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).first() is not None
|
||||
player_in_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
can_view_calendar = is_registered or player_in_match
|
||||
|
||||
all_players = None
|
||||
if can_edit:
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
# is_active_account, like the manager and coach queries in this same
|
||||
# module. Offering a deactivated account in a roster select
|
||||
# contradicts the one control that says the person has left.
|
||||
all_players = (
|
||||
User.query.filter_by(role='player', is_active_account=True)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
matches = (
|
||||
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
)
|
||||
match_data = []
|
||||
for match in matches:
|
||||
all_participants = list(match.participants.all())
|
||||
@@ -256,47 +324,82 @@ def view_tryout(tryout_id):
|
||||
player_presence = []
|
||||
for p in all_participants:
|
||||
if p.player:
|
||||
player_presence.append({
|
||||
'participant_id': p.id, 'player_id': p.player_id,
|
||||
'player_name': p.player.username,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
})
|
||||
player_presence.append(
|
||||
{
|
||||
'participant_id': p.id,
|
||||
'player_id': p.player_id,
|
||||
'player_name': p.player.username,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
}
|
||||
)
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
participants = {
|
||||
'team1': match.team1.name if match.team1 else 'TBD',
|
||||
'team2': match.team2.name if match.team2 else 'TBD',
|
||||
'team1_players': [{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team1.members.all()] if match.team1 else [],
|
||||
'team2_players': [{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team2.members.all()] if match.team2 else [],
|
||||
'team1_players': [
|
||||
{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team1.members.all()
|
||||
]
|
||||
if match.team1
|
||||
else [],
|
||||
'team2_players': [
|
||||
{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team2.members.all()
|
||||
]
|
||||
if match.team2
|
||||
else [],
|
||||
}
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=1).all() if p.player]
|
||||
team2_players = [{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=2).all() if p.player]
|
||||
# Filtered from the list already in hand. Asking the dynamic
|
||||
# relationship again cost two more round trips per match for
|
||||
# rows that were loaded a dozen lines above.
|
||||
team1_players = [
|
||||
{'name': p.player.username, 'position': p.position}
|
||||
for p in all_participants
|
||||
if p.team_side == 1 and p.player
|
||||
]
|
||||
team2_players = [
|
||||
{'name': p.player.username, 'position': p.position}
|
||||
for p in all_participants
|
||||
if p.team_side == 2 and p.player
|
||||
]
|
||||
participants = {
|
||||
'team1': 'Team 1', 'team2': 'Team 2',
|
||||
'team1_players': team1_players, 'team2_players': team2_players,
|
||||
'team1': 'Team 1',
|
||||
'team2': 'Team 2',
|
||||
'team1_players': team1_players,
|
||||
'team2_players': team2_players,
|
||||
}
|
||||
else:
|
||||
participants = [p.player.username for p in match.participants.all()]
|
||||
|
||||
match_data.append({
|
||||
'match': match, 'participants': participants,
|
||||
'confirmed_count': confirmed_count, 'total_count': total_count,
|
||||
'player_presence': player_presence,
|
||||
})
|
||||
match_data.append(
|
||||
{
|
||||
'match': match,
|
||||
'participants': participants,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_count': total_count,
|
||||
'player_presence': player_presence,
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/view_tryout.html',
|
||||
tryout=tryout, registered_players=registered_players,
|
||||
evaluations=evaluations, player_eval_status=player_eval_status,
|
||||
is_registered=is_registered, registrations=registrations,
|
||||
team_data=team_data, can_edit=can_edit,
|
||||
can_view_calendar=can_view_calendar, all_players=all_players,
|
||||
matches=matches, match_data=match_data,
|
||||
game_positions=GAME_POSITIONS, now=datetime.utcnow())
|
||||
return render_template(
|
||||
'pages/view_tryout.html',
|
||||
tryout=tryout,
|
||||
registered_players=registered_players,
|
||||
evaluations=evaluations,
|
||||
player_eval_status=player_eval_status,
|
||||
is_registered=is_registered,
|
||||
registrations=registrations,
|
||||
team_data=team_data,
|
||||
can_edit=can_edit,
|
||||
can_view_calendar=can_view_calendar,
|
||||
all_players=all_players,
|
||||
matches=matches,
|
||||
match_data=match_data,
|
||||
game_positions=GAME_POSITIONS,
|
||||
now=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
|
||||
@@ -305,29 +408,30 @@ def register_for_tryout(tryout_id):
|
||||
"""Register a player for a tryout. Only Players can self-register."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not isinstance(current_user, Player):
|
||||
flash('Only players can register for tryouts.', 'danger')
|
||||
flash(_('Only players can register for tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.status not in ['upcoming', 'in_progress']:
|
||||
flash('This tryout is not accepting registrations.', 'danger')
|
||||
flash(_('This tryout is not accepting registrations.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id).first()
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
if existing:
|
||||
flash('You are already registered for this tryout.', 'info')
|
||||
flash(_('You are already registered for this tryout.'), 'info')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
flash('This tryout is full.', 'danger')
|
||||
flash(_('This tryout is full.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
flash('Successfully registered for tryout!', 'success')
|
||||
flash(_('Successfully registered for tryout!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -337,13 +441,13 @@ def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
||||
tryout.status = new_status
|
||||
db.session.commit()
|
||||
flash(f'Tryout status updated to {new_status}.', 'success')
|
||||
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -353,16 +457,17 @@ def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first_or_404()
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first_or_404()
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['registered', 'attended', 'no_show']:
|
||||
registration.status = new_status
|
||||
db.session.commit()
|
||||
flash('Registration status updated.', 'success')
|
||||
flash(_('Registration status updated.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -372,34 +477,44 @@ def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
player_id = request.form.get('player_id')
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'danger')
|
||||
try:
|
||||
data = PlayerSelectionSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
player = User.query.get_or_404(int(player_id))
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only register players.', 'danger')
|
||||
if not data['player_id']:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player.id).first()
|
||||
# Same two checks as the team roster (SEC-16): the right role, and an
|
||||
# account that has not been deactivated. The select this comes from now
|
||||
# filters both, but the select is not the control.
|
||||
player = db.session.get(User, data['player_id'])
|
||||
if not player or not isinstance(player, Player) or not player.is_active_account:
|
||||
flash(_('Can only register players.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
||||
if existing:
|
||||
flash(f'{player.username} is already registered for this tryout.', 'info')
|
||||
flash(
|
||||
_('%(username)s is already registered for this tryout.', username=player.username),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
flash('This tryout is full.', 'danger')
|
||||
flash(_('This tryout is full.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} registered for tryout!', 'success')
|
||||
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -409,13 +524,14 @@ def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first()
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first()
|
||||
if registration:
|
||||
db.session.delete(registration)
|
||||
|
||||
@@ -434,7 +550,7 @@ def remove_player(tryout_id, player_id):
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'{player.username} removed from tryout.', 'success')
|
||||
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -444,7 +560,7 @@ def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
team_name = request.form.get('team_name')
|
||||
@@ -452,7 +568,7 @@ def create_team(tryout_id):
|
||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(f'Team "{team_name}" created!', 'success')
|
||||
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -463,19 +579,38 @@ def add_to_team(tryout_id, team_id):
|
||||
team = Team.query.get_or_404(team_id)
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
# The two ids arrive independently in the URL. Without this check, being
|
||||
# allowed to manage tryout A was enough to modify a team belonging to
|
||||
# tryout B, since only the tryout was authorised.
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
if not player_id:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
# Only players registered for this tryout may be placed on its teams.
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
player_id = request.form.get('player_id')
|
||||
position = request.form.get('position', '')
|
||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||
if existing:
|
||||
flash('Player is already on this team.', 'info')
|
||||
flash(_('Player is already on this team.'), 'info')
|
||||
else:
|
||||
member = TeamMember(team_id=team_id, player_id=int(player_id), position=position)
|
||||
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
flash('Player added to team!', 'success')
|
||||
flash(_('Player added to team!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -485,34 +620,41 @@ def delete_tryout(tryout_id):
|
||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to delete this tryout.', 'danger')
|
||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
# Delete match participants for all matches in this tryout
|
||||
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
||||
|
||||
# Personal notes outlive the tryout: they are a coach's observations
|
||||
# about a player, not tryout data. Only their context links are cleared.
|
||||
# Missing this step made the deletion fail on the foreign keys below.
|
||||
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
|
||||
{'tryout_id': None}, synchronize_session=False
|
||||
)
|
||||
if match_ids:
|
||||
MatchParticipant.query.filter(
|
||||
MatchParticipant.match_id.in_(match_ids)
|
||||
).delete(synchronize_session=False)
|
||||
# Delete matches
|
||||
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
|
||||
{'match_id': None}, synchronize_session=False
|
||||
)
|
||||
if team_ids:
|
||||
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
|
||||
{'team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
if match_ids:
|
||||
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Delete team members for all teams in this tryout
|
||||
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
||||
if team_ids:
|
||||
TeamMember.query.filter(
|
||||
TeamMember.team_id.in_(team_ids)
|
||||
).delete(synchronize_session=False)
|
||||
# Delete teams
|
||||
TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
|
||||
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Delete registrations
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
|
||||
|
||||
# Delete evaluations
|
||||
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
|
||||
|
||||
db.session.delete(tryout)
|
||||
db.session.commit()
|
||||
flash('Tryout deleted successfully.', 'success')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
flash(_('Tryout deleted successfully.'), 'success')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
-1265
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,85 @@
|
||||
"""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 import request
|
||||
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 GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
|
||||
|
||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
||||
|
||||
#: Every PDF starts with this. Checking the name alone accepted a file
|
||||
#: called anything.pdf holding anything at all.
|
||||
PDF_SIGNATURE = b'%PDF-'
|
||||
|
||||
#: USER_TYPE → model class, for create_user.
|
||||
USER_CLASS_MAP = {
|
||||
'admin': Admin,
|
||||
'manager': Manager,
|
||||
'coach': Coach,
|
||||
'player': Player,
|
||||
'scout': Scout,
|
||||
}
|
||||
|
||||
|
||||
def pdf_upload_error(file, allowed_extensions):
|
||||
"""Why this upload is not an acceptable PDF, or None if it is.
|
||||
|
||||
upload_signed_contract checked nothing beyond a non-empty filename —
|
||||
ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player
|
||||
could put an arbitrary file on the server under a name the application
|
||||
later hands back for download (SEC-021).
|
||||
|
||||
Args:
|
||||
file: The uploaded FileStorage, or None.
|
||||
allowed_extensions: Extensions to accept, lowercase and without dot.
|
||||
|
||||
Returns:
|
||||
str | None: A message to flash, or None when the file is acceptable.
|
||||
"""
|
||||
if file is None or not file.filename:
|
||||
return _('No file selected.')
|
||||
|
||||
stem, dot, extension = file.filename.rpartition('.')
|
||||
if not (stem and dot) or extension.lower() not in allowed_extensions:
|
||||
return _('Only PDF files are allowed for contracts.')
|
||||
|
||||
head = file.stream.read(len(PDF_SIGNATURE))
|
||||
file.stream.seek(0)
|
||||
if head != PDF_SIGNATURE:
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def update_user_gamertags(user, selected_games):
|
||||
"""Update gamertags for a user based on form input."""
|
||||
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||
for game in selected_games:
|
||||
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
||||
platform = (
|
||||
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
||||
)
|
||||
existing = existing_gamertags.get(game)
|
||||
if gamertag:
|
||||
if existing:
|
||||
existing.gamertag = gamertag
|
||||
existing.platform = platform
|
||||
else:
|
||||
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=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,379 @@
|
||||
"""Account administration — the president's view of the user list.
|
||||
|
||||
Creating, editing, deleting and viewing accounts. Everything here is
|
||||
admin-only except view_user, which renders a public profile.
|
||||
"""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db, hash_password
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import (
|
||||
ESPORT_GAMES,
|
||||
GAME_PLATFORMS,
|
||||
USER_TYPES,
|
||||
Admin,
|
||||
CoachAvailability,
|
||||
Contract,
|
||||
Evaluation,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
PlayerDisponibility,
|
||||
Team,
|
||||
TeamMember,
|
||||
TeamNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
UserGamertag,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.routes.users._shared import (
|
||||
USER_CLASS_MAP,
|
||||
flash_validation_errors,
|
||||
form_payload,
|
||||
update_user_gamertags,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.storage import discard_documents
|
||||
from app.validators import CreateUserSchema, EditUserSchema
|
||||
|
||||
|
||||
@users_bp.route('')
|
||||
@login_required
|
||||
def list_users():
|
||||
"""List all users for management (Admin only)."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash(_('Only the president can manage users.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# Ordered before paginated, and by a unique-enough key: a paginated
|
||||
# query without a stable ORDER BY can show the same row twice and never
|
||||
# show another (MNT-14).
|
||||
page = paginate(User.query.order_by(User.role, User.username, User.id))
|
||||
return render_template('pages/users.html', users=page.items, pagination=page, roles=USER_TYPES)
|
||||
|
||||
|
||||
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_user(user_id):
|
||||
"""Edit an existing user (Admin only)."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash(_('Only the president can edit users.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
user = User.query.get_or_404(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()
|
||||
|
||||
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
|
||||
|
||||
update_user_gamertags(user, selected_games)
|
||||
|
||||
# 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 = User.query.get_or_404(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 = User.query.get_or_404(user_id)
|
||||
return render_template('pages/view_user.html', profile_user=user)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""When people are free.
|
||||
|
||||
Two calendars that share a shape without sharing a purpose: a player's
|
||||
weekly availability blocks, and a coach's bookable slots for one-on-one
|
||||
sessions.
|
||||
"""
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import db
|
||||
from app.forms import form_payload
|
||||
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.timeslots import day_name, slot_end
|
||||
from app.validators import TimeSlotSchema
|
||||
|
||||
|
||||
def _load_slots(payload_slots):
|
||||
"""Validate a batch of posted slots, keeping the rejects.
|
||||
|
||||
Both bulk endpoints used to `continue` past anything malformed and then
|
||||
answer `{'success': True}`. The client had no way to learn that a slot
|
||||
had been dropped — and for coach availability that is destructive, since
|
||||
the route deletes every existing slot before re-adding the ones it
|
||||
accepted. A payload the browser mangled could therefore wipe a coach's
|
||||
bookable hours and report success (MNT-12).
|
||||
|
||||
Args:
|
||||
payload_slots: Whatever arrived under the `slots` key.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], list[str]]: Accepted slots, and one message per
|
||||
rejected one.
|
||||
"""
|
||||
schema = TimeSlotSchema()
|
||||
accepted, rejected = [], []
|
||||
for index, raw in enumerate(payload_slots or []):
|
||||
if not isinstance(raw, dict):
|
||||
rejected.append(f'slot {index}: expected an object')
|
||||
continue
|
||||
try:
|
||||
accepted.append(schema.load(raw))
|
||||
except ValidationError as err:
|
||||
details = '; '.join(
|
||||
f'{field}: {" ".join(str(m) for m in messages)}'
|
||||
for field, messages in err.messages.items()
|
||||
)
|
||||
rejected.append(f'slot {index}: {details}')
|
||||
return accepted, rejected
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def get_disponibilities():
|
||||
"""API endpoint to get all player disponibilities for scheduling."""
|
||||
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
players = (
|
||||
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
result = {}
|
||||
for player in players:
|
||||
disponibilities = list(player.disponibilities)
|
||||
result[player.id] = {
|
||||
'username': player.username,
|
||||
'disponibilities': [
|
||||
{
|
||||
'id': d.id,
|
||||
'day_of_week': d.day_of_week,
|
||||
'day_name': day_name(d.day_of_week),
|
||||
'start_time': d.start_time.strftime('%H:%M'),
|
||||
'end_time': d.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for d in disponibilities
|
||||
],
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/my')
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def get_my_disponibilities():
|
||||
"""API endpoint for players to get their own disponibilities."""
|
||||
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
|
||||
result = {}
|
||||
for d in disponibilities:
|
||||
day = d.day_of_week
|
||||
if day not in result:
|
||||
result[day] = []
|
||||
result[day].append(
|
||||
{
|
||||
'id': d.id,
|
||||
'day_of_week': d.day_of_week,
|
||||
'day_name': day_name(d.day_of_week),
|
||||
'start_time': d.start_time.strftime('%H:%M'),
|
||||
'end_time': d.end_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/add', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def add_disponibility():
|
||||
"""Add a disponibility block for the current player."""
|
||||
try:
|
||||
slot = TimeSlotSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
return jsonify({'error': 'Invalid slot', 'details': err.messages}), 400
|
||||
|
||||
day_of_week = slot['day_of_week']
|
||||
start_time = slot['start_time']
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': day_name(disponibility.day_of_week),
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
'end_time': disponibility.end_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def add_disponibilities_bulk():
|
||||
"""Replace the current player's disponibility blocks atomically."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
|
||||
if rejected:
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Invalid slots; nothing was changed.',
|
||||
'rejected': rejected,
|
||||
}
|
||||
), 400
|
||||
|
||||
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
|
||||
|
||||
created = []
|
||||
for slot in accepted:
|
||||
start_time = slot['start_time']
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.flush()
|
||||
created.append(
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': day_name(disponibility.day_of_week),
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'created': created, 'rejected': []})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def clear_disponibilities():
|
||||
"""Clear all disponibilities for the current player."""
|
||||
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def delete_disponibility(disponibility_id):
|
||||
"""Delete a disponibility block."""
|
||||
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
|
||||
if disponibility.player_id != current_user.id:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
db.session.delete(disponibility)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def manage_coach_availability():
|
||||
"""Manage coach availability for One on One sessions."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage availability.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
|
||||
# Validate everything before deleting anything.
|
||||
#
|
||||
# This route replaces the coach's availability: it deleted every
|
||||
# existing slot and then re-added the ones it could parse, skipping
|
||||
# the rest in silence and answering `{'success': true}`. A payload
|
||||
# the browser mangled therefore wiped a coach's bookable hours and
|
||||
# reported success — and one-on-one requests are refused against
|
||||
# exactly this table, so the coach became unbookable with nothing to
|
||||
# show for it. Refusing the whole batch is the only safe answer when
|
||||
# the operation is a replacement (MNT-12).
|
||||
if rejected:
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Invalid slots; nothing was changed.',
|
||||
'rejected': rejected,
|
||||
}
|
||||
), 400
|
||||
|
||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||
|
||||
for slot in accepted:
|
||||
start_time = slot['start_time']
|
||||
db.session.add(
|
||||
CoachAvailability(
|
||||
coach_id=current_user.id,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'saved': len(accepted)})
|
||||
|
||||
existing_availability = CoachAvailability.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).all()
|
||||
|
||||
return render_template(
|
||||
'pages/coach_availability.html', existing_availability=existing_availability
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def clear_coach_availability():
|
||||
"""Clear all coach availability slots."""
|
||||
if not isinstance(current_user, Coach):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
@@ -0,0 +1,16 @@
|
||||
"""The `users` blueprint object, on its own.
|
||||
|
||||
Every route module in this package imports it from here rather than from
|
||||
the package __init__, so there is no import cycle to reason about and no
|
||||
ordering constraint between the modules.
|
||||
|
||||
The blueprint stays a *single* blueprint even though the package holds six
|
||||
route modules. Splitting it into `users_accounts`, `users_contracts` and so
|
||||
on would rename 137 endpoints, and every one of them is spelled out in a
|
||||
`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a
|
||||
file you can read, not a URL map you have to relearn.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
users_bp = Blueprint('users', __name__, url_prefix='/users')
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Player contracts: upload, sign, download."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||
from app.routes.users._shared import (
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
pdf_upload_error,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.storage import CONTRACTS_DIR, document_path
|
||||
from app.validators import UploadContractSchema
|
||||
|
||||
|
||||
def manageable_players():
|
||||
"""Players the current user may attach a contract to.
|
||||
|
||||
A coach used to see the squad of one team — the first row matching the
|
||||
legacy coach_id column — so a coach of two teams could file a contract
|
||||
for half of their players and no more, and a coach attached only by the
|
||||
many-to-many relationship for none at all.
|
||||
"""
|
||||
if isinstance(current_user, Coach):
|
||||
player_ids = coach_player_ids(current_user)
|
||||
return (
|
||||
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
|
||||
if player_ids
|
||||
else []
|
||||
)
|
||||
# is_active_account: a contract select that still lists people who have
|
||||
# left the club invites filing paperwork against them (SEC-16).
|
||||
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
|
||||
|
||||
@users_bp.route('/contracts')
|
||||
@login_required
|
||||
def list_contracts():
|
||||
"""View contracts for the current user or players they manage."""
|
||||
contracts = None
|
||||
players = None
|
||||
|
||||
if isinstance(current_user, Player):
|
||||
contracts = (
|
||||
Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
elif isinstance(current_user, (Admin, Manager, Coach)):
|
||||
players = manageable_players()
|
||||
|
||||
if players:
|
||||
player_ids = [p.id for p in players]
|
||||
contracts = (
|
||||
Contract.query.filter(
|
||||
Contract.player_id.in_(player_ids),
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/contracts.html',
|
||||
contracts=contracts,
|
||||
players=players if isinstance(current_user, (Admin, Manager, Coach)) else None,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_contract():
|
||||
"""Upload a contract for a player."""
|
||||
if not isinstance(current_user, (Admin, Manager, Coach)):
|
||||
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
players = manageable_players()
|
||||
|
||||
if request.method == 'POST':
|
||||
contract_schema = UploadContractSchema()
|
||||
try:
|
||||
validated = contract_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
player_id = validated['player_id']
|
||||
notes = validated.get('notes')
|
||||
|
||||
if not can_manage_player_contract(current_user, player_id):
|
||||
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
file = request.files.get('contract_file')
|
||||
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player_teams = player.get_org_teams()
|
||||
team = player_teams[0] if player_teams else None
|
||||
|
||||
original_filename = secure_filename(file.filename)
|
||||
stored_filename = f"{uuid.uuid4()}.pdf"
|
||||
|
||||
# Kept relative to the document root, not absolute (see app/storage.py):
|
||||
# an absolute path pins the file to the directory the process was
|
||||
# started from, which is the one thing a release-directory deploy
|
||||
# changes.
|
||||
relative_path = os.path.join(CONTRACTS_DIR, stored_filename)
|
||||
if team:
|
||||
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
||||
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file.save(absolute_path)
|
||||
|
||||
contract = Contract(
|
||||
player_id=player_id,
|
||||
team_id=team.id if team else None,
|
||||
uploaded_by_id=current_user.id,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=relative_path,
|
||||
notes=notes if notes else None,
|
||||
)
|
||||
db.session.add(contract)
|
||||
db.session.commit()
|
||||
flash(
|
||||
_('Contract uploaded successfully for %(username)s!', username=player.username),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/upload_signed', methods=['POST'])
|
||||
@login_required
|
||||
def upload_signed_contract(contract_id):
|
||||
"""Upload a signed contract (player only)."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
if not contract.can_upload_signed(current_user):
|
||||
flash(_('Only the player can upload their signed contract.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
file = request.files.get('signed_file')
|
||||
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
signed_filename = f"signed_{contract.stored_filename}"
|
||||
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
file.save(document_path(signed_path))
|
||||
|
||||
contract.signed_filename = signed_filename
|
||||
contract.signed_file_path = signed_path
|
||||
contract.status = 'signed'
|
||||
contract.signed_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash(_('Signed contract uploaded successfully!'), 'success')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download')
|
||||
@login_required
|
||||
def download_contract(contract_id):
|
||||
"""Download a contract file."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
if not contract.can_view(current_user):
|
||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.original_filename,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download_signed')
|
||||
@login_required
|
||||
def download_signed_contract(contract_id):
|
||||
"""Download a signed contract file."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
if not contract.can_view(current_user):
|
||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
if not contract.signed_file_path:
|
||||
flash(_('No signed contract available.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.signed_file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.signed_filename,
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""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 app.extensions import db
|
||||
from app.models import (
|
||||
Coach,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
TeamNote,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
|
||||
from app.routes.users.blueprint import users_bp
|
||||
|
||||
|
||||
@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
|
||||
matches = (
|
||||
Match.query.filter(
|
||||
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
||||
)
|
||||
.order_by(Match.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
tryouts = (
|
||||
Tryout.query.filter_by(
|
||||
created_by=current_user.id,
|
||||
)
|
||||
.order_by(Tryout.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
|
||||
return render_template(
|
||||
'pages/notes.html',
|
||||
org_team=org_team,
|
||||
players=players,
|
||||
team_notes=team_notes,
|
||||
latest_team_note=latest_team_note,
|
||||
personal_notes=personal_notes,
|
||||
one_on_one_requests=one_on_one_requests,
|
||||
matches=matches,
|
||||
tryouts=tryouts,
|
||||
teams=teams,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manage Team Notes (POST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/team-notes/manage', methods=['POST'])
|
||||
@login_required
|
||||
def manage_team_notes():
|
||||
"""Create or update team notes for the coach's org team."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage team notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# Same team the dashboard displays notes for, resolved the same way.
|
||||
org_teams = coach_org_teams(current_user)
|
||||
if not org_teams:
|
||||
flash(_('You are not assigned to a team.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
org_team = org_teams[0]
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(
|
||||
org_team_id=org_team.id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes saved successfully!'), 'success')
|
||||
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manage Personal Notes (POST, simple form)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/personal-notes/manage', methods=['POST'])
|
||||
@login_required
|
||||
def manage_personal_notes():
|
||||
"""Create a personal note for a player (coach only, simple form)."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Add Personal Note (POST, full form with context)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/personal-notes/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_personal_note():
|
||||
"""Create a personal note for a player with optional context (coach only)."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
match_id = request.form.get('match_id', type=int)
|
||||
tryout_id = request.form.get('tryout_id', type=int)
|
||||
team_id_str = request.form.get('team_id')
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
match_id=match_id if match_id else None,
|
||||
tryout_id=tryout_id if tryout_id else None,
|
||||
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Add Note from Tryout context (GET + POST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/personal-notes/tryout/<int:tryout_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_note_from_tryout(tryout_id):
|
||||
"""Add a personal note for a player in the context of a tryout."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
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':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), '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))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=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 = Match.query.get_or_404(match_id)
|
||||
|
||||
# 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':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), '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))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=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,263 @@
|
||||
"""One-on-one sessions between a player and their coach."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.services.notifications import send_discord_notification
|
||||
from app.validators import OneOnOneRequestSchema
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def one_on_one():
|
||||
"""One on One request page for players."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('Only players can request One on One sessions.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_teams = current_user.get_org_teams()
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
# Reading org_team.coach_id directly told every player whose team lists
|
||||
# its coaches through the many-to-many relationship — the newer of the
|
||||
# two ways — that they had no coach, and closed the page to them.
|
||||
# get_coaches() falls back to the legacy column when the list is empty.
|
||||
team_coaches = org_team.get_coaches() if org_team else []
|
||||
coach = team_coaches[0] if team_coaches else None
|
||||
|
||||
if not coach:
|
||||
flash(_('You do not have a coach assigned to your team.'), 'info')
|
||||
|
||||
team_notes = []
|
||||
if org_team:
|
||||
team_notes = (
|
||||
TeamNote.query.filter_by(org_team_id=org_team.id)
|
||||
.order_by(TeamNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
personal_notes = (
|
||||
PersonalNote.query.filter_by(player_id=current_user.id)
|
||||
.order_by(PersonalNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Kept as model objects for the availability check below, and serialised
|
||||
# separately for the page. They used to be the same list of strings,
|
||||
# which is what made the check compare '9:00' with '10:00' as text.
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all() if coach else []
|
||||
coach_availability = [
|
||||
{
|
||||
'day_of_week': av.day_of_week,
|
||||
'start_time': av.start_time.strftime('%H:%M'),
|
||||
'end_time': av.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for av in availabilities
|
||||
]
|
||||
|
||||
if request.method == 'POST':
|
||||
if not coach:
|
||||
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
try:
|
||||
data = OneOnOneRequestSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
date_obj = data['date']
|
||||
start_time = data['start_time']
|
||||
end_time = data['end_time']
|
||||
points = data['points'] or ''
|
||||
|
||||
# Compared as times, not as strings. The old code parsed the three
|
||||
# form fields into objects and then compared the *original strings*
|
||||
# against the serialised availability — which worked only because
|
||||
# both sides happened to be zero-padded HH:MM.
|
||||
is_available = any(
|
||||
av.day_of_week == date_obj.weekday()
|
||||
and av.start_time <= start_time
|
||||
and av.end_time >= end_time
|
||||
for av in availabilities
|
||||
)
|
||||
|
||||
if not is_available:
|
||||
flash(_("The requested time is not within the coach's availability."), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
request_obj = OneOnOneRequest(
|
||||
player_id=current_user.id,
|
||||
coach_id=coach.id,
|
||||
org_team_id=org_team.id if org_team else None,
|
||||
date=date_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
points=points if points else None,
|
||||
)
|
||||
db.session.add(request_obj)
|
||||
db.session.commit()
|
||||
|
||||
send_discord_notification(
|
||||
player_name=current_user.full_name,
|
||||
points=points,
|
||||
date_str=date_obj.strftime('%Y-%m-%d'),
|
||||
start_time_str=start_time.strftime('%H:%M'),
|
||||
end_time_str=end_time.strftime('%H:%M'),
|
||||
team_name=org_team.name if org_team else 'Unknown Team',
|
||||
coach_name=coach.full_name,
|
||||
coach_discord=coach.discord_username or '',
|
||||
coach_discord_id=coach.discord_user_id or '',
|
||||
request_id=request_obj.id,
|
||||
)
|
||||
|
||||
flash(_('Your One on One request has been submitted!'), 'success')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
# Build list of upcoming dates that have coach availability
|
||||
from datetime import date as date_cls
|
||||
from datetime import timedelta as td
|
||||
|
||||
today = date_cls.today()
|
||||
available_days = {av['day_of_week'] for av in coach_availability}
|
||||
dates = []
|
||||
for i in range(14): # Next 14 days
|
||||
d = today + td(days=i)
|
||||
if d.weekday() in available_days:
|
||||
dates.append(
|
||||
{
|
||||
'value': d.strftime('%Y-%m-%d'),
|
||||
'day_of_week': d.weekday(),
|
||||
'display': d.strftime('%B %d, %Y (%A)'),
|
||||
}
|
||||
)
|
||||
|
||||
# Player's own One on One request history
|
||||
my_requests = (
|
||||
OneOnOneRequest.query.filter_by(player_id=current_user.id)
|
||||
.order_by(OneOnOneRequest.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/one_on_one.html',
|
||||
org_team=org_team,
|
||||
coach=coach,
|
||||
team_notes=team_notes,
|
||||
personal_notes=personal_notes,
|
||||
coach_availability=coach_availability,
|
||||
dates=dates,
|
||||
my_requests=my_requests,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one/<int:request_id>/accept', methods=['POST'])
|
||||
@login_required
|
||||
def accept_one_on_one(request_id):
|
||||
"""Coach accepts a One on One request."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can accept One on One requests.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
request_obj = OneOnOneRequest.query.get_or_404(request_id)
|
||||
|
||||
if request_obj.coach_id != current_user.id:
|
||||
flash(_('This request is not for you.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if request_obj.status != 'pending':
|
||||
flash(_('This request has already been processed.'), 'info')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = request_obj.player
|
||||
request_obj.status = 'approved'
|
||||
request_obj.responded_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=current_user.full_name,
|
||||
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=request_obj.start_time.strftime('%I:%M %p')
|
||||
if request_obj.start_time
|
||||
else 'TBD',
|
||||
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
|
||||
points=request_obj.points or 'No specific points provided',
|
||||
approved=True,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'One on One request from %(player)s has been approved!',
|
||||
player=player.username if player else 'Unknown',
|
||||
),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
|
||||
@login_required
|
||||
def reject_one_on_one(request_id):
|
||||
"""Coach rejects a One on One request."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can reject One on One requests.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
request_obj = OneOnOneRequest.query.get_or_404(request_id)
|
||||
|
||||
if request_obj.coach_id != current_user.id:
|
||||
flash(_('This request is not for you.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if request_obj.status != 'pending':
|
||||
flash(_('This request has already been processed.'), 'info')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
rejection_reason = request.form.get('rejection_reason', '').strip()
|
||||
player = request_obj.player
|
||||
|
||||
request_obj.status = 'rejected'
|
||||
request_obj.responded_at = datetime.utcnow()
|
||||
if rejection_reason:
|
||||
request_obj.coach_rejection_message = rejection_reason
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if rejected through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=current_user.full_name,
|
||||
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=request_obj.start_time.strftime('%I:%M %p')
|
||||
if request_obj.start_time
|
||||
else 'TBD',
|
||||
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
|
||||
points=request_obj.points or 'No specific points provided',
|
||||
approved=False,
|
||||
refusal_note=rejection_reason or None,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
'One on One request from %(player)s has been rejected.',
|
||||
player=player.username if player else 'Unknown',
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The signed-in user's own profile."""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db, hash_password
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import (
|
||||
ESPORT_GAMES,
|
||||
GAME_PLATFORMS,
|
||||
Coach,
|
||||
CoachAvailability,
|
||||
Contract,
|
||||
Player,
|
||||
User,
|
||||
)
|
||||
from app.routes.users._shared import (
|
||||
flash_validation_errors,
|
||||
form_payload,
|
||||
update_user_gamertags,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import EditProfileSchema
|
||||
|
||||
|
||||
@users_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
"""View the current user's profile."""
|
||||
contracts = None
|
||||
if isinstance(current_user, Player):
|
||||
contracts = (
|
||||
Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
existing_availability = None
|
||||
if isinstance(current_user, Coach):
|
||||
existing_availability = CoachAvailability.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).all()
|
||||
|
||||
return render_template(
|
||||
'pages/profile.html',
|
||||
user=current_user,
|
||||
contracts=contracts,
|
||||
existing_availability=existing_availability,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/profile/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_profile():
|
||||
"""Edit the current user's profile."""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
validated = EditProfileSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
username = validated['username']
|
||||
full_name = validated['full_name']
|
||||
email = validated['email']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
discord_username = validated.get('discord_username')
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if username != current_user.username and User.query.filter_by(username=username).first():
|
||||
flash(_('Username already taken.'), 'danger')
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
if email != current_user.email and User.query.filter_by(email=email).first():
|
||||
flash(_('Email already in use.'), 'danger')
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
update_user_gamertags(current_user, selected_games)
|
||||
|
||||
# Blank means "keep the current password"; anything else has already
|
||||
# been checked against the policy by the schema.
|
||||
password = validated.get('password')
|
||||
if password:
|
||||
current_user.password_hash = hash_password(password)
|
||||
log_auth_event(
|
||||
'account.password_changed', username=current_user.username, user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Profile updated successfully!'), 'success')
|
||||
return redirect(url_for('users.profile'))
|
||||
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Business services: work that is neither a route nor a model."""
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Outbound notifications.
|
||||
|
||||
Extracted from app/routes/users.py, where it sat between two route
|
||||
definitions and pulled `requests`, `logging` and the Discord bot into a
|
||||
module whose subject is HTTP handlers (ARCH-003).
|
||||
|
||||
Failures here are swallowed and logged on purpose: a notification that does
|
||||
not reach Discord must not roll back the session it was announcing. That is
|
||||
a property of the caller — a Flask request whose work is already committed —
|
||||
not of the failure, which is why the breadth is argued for at each of the
|
||||
two boundaries below rather than assumed (ARCH-008 / QUA-004).
|
||||
|
||||
The webhook branch is narrower than it was: `requests.RequestException`
|
||||
covers every way an HTTP call can fail, and anything else coming out of it
|
||||
is a defect worth seeing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Either a webhook URL, or a bare Discord user id to DM instead.
|
||||
DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '')
|
||||
|
||||
|
||||
def send_discord_notification(
|
||||
player_name,
|
||||
points,
|
||||
date_str,
|
||||
start_time_str,
|
||||
end_time_str,
|
||||
team_name,
|
||||
coach_name,
|
||||
coach_discord,
|
||||
coach_discord_id,
|
||||
request_id=None,
|
||||
):
|
||||
"""Send a Discord notification for a One on One request.
|
||||
|
||||
Args:
|
||||
player_name: Who is asking.
|
||||
points: Free-text discussion points, possibly empty.
|
||||
date_str, start_time_str, end_time_str: Already formatted for display.
|
||||
team_name: The player's team, or None.
|
||||
coach_name: Who is being asked.
|
||||
coach_discord: The coach's Discord handle, for the webhook footer.
|
||||
coach_discord_id: The coach's Discord snowflake, for a direct message.
|
||||
request_id: OneOnOneRequest primary key, so reactions can find it back.
|
||||
"""
|
||||
|
||||
if coach_discord_id:
|
||||
try:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name,
|
||||
coach_discord_id=coach_discord_id,
|
||||
player_name=player_name,
|
||||
team_name=team_name,
|
||||
date_str=date_str,
|
||||
start_time=start_time_str,
|
||||
end_time=end_time_str,
|
||||
points=points,
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — the request that booked the meeting is already committed
|
||||
logger.warning('Failed to hand the One on One DM to the bot', exc_info=True)
|
||||
|
||||
if not DISCORD_WEBHOOK_URL:
|
||||
return
|
||||
|
||||
# A bare snowflake here means "DM this person instead", and only when the
|
||||
# coach has no id of their own. Queueing it cannot raise (see _enqueue).
|
||||
if DISCORD_WEBHOOK_URL.isdigit():
|
||||
if not coach_discord_id:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name,
|
||||
coach_discord_id=DISCORD_WEBHOOK_URL,
|
||||
player_name=player_name,
|
||||
team_name=team_name,
|
||||
date_str=date_str,
|
||||
start_time=start_time_str,
|
||||
end_time=end_time_str,
|
||||
points=points,
|
||||
)
|
||||
return
|
||||
|
||||
embed = {
|
||||
"embeds": [
|
||||
{
|
||||
"title": "One on One Request",
|
||||
"color": 3447003,
|
||||
"fields": [
|
||||
{"name": "Player", "value": player_name, "inline": True},
|
||||
{
|
||||
"name": "Team",
|
||||
"value": team_name or "Unknown Team",
|
||||
"inline": True,
|
||||
},
|
||||
{"name": "Date", "value": date_str, "inline": True},
|
||||
{
|
||||
"name": "Time",
|
||||
"value": f"{start_time_str} - {end_time_str}",
|
||||
"inline": True,
|
||||
},
|
||||
{
|
||||
"name": "Discussion Points",
|
||||
"value": points or "No specific points provided",
|
||||
"inline": False,
|
||||
},
|
||||
],
|
||||
"footer": {
|
||||
"text": f"Coach: {coach_name}"
|
||||
+ (f" (Discord: {coach_discord})" if coach_discord else ""),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
try:
|
||||
requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning('Failed to post the One on One webhook: %s', exc)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Announcing a scheduled match to the people who have to be there.
|
||||
|
||||
The same twenty lines appeared three times — matches.create_match,
|
||||
matches.edit_match and team_matches.create_match — each formatting the date
|
||||
and time itself, then walking two parallel lists in lockstep to pair a
|
||||
player with the participant row that a Discord reaction has to find again
|
||||
(ARCH-003).
|
||||
|
||||
Three copies meant three chances to drift, and they had:
|
||||
create_match read the times from local variables it had just parsed, while
|
||||
edit_match re-derived them from the saved row and substituted the start
|
||||
time for a missing end time. The rule kept here is the more careful of the
|
||||
two.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.discord_bot import send_schedule_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: What the bot shows when a match has no usable time.
|
||||
TIME_UNKNOWN = 'TBD'
|
||||
|
||||
|
||||
def format_event_time(start_time, end_time):
|
||||
"""Render a match's time range the way the Discord message expects.
|
||||
|
||||
Args:
|
||||
start_time: A time, or None.
|
||||
end_time: A time, or None. Falls back to start_time, so a match with
|
||||
only a start still announces something useful.
|
||||
|
||||
Returns:
|
||||
str: 'HH:MM AM - HH:MM PM', or TIME_UNKNOWN.
|
||||
"""
|
||||
if not start_time:
|
||||
return TIME_UNKNOWN
|
||||
finish = end_time or start_time
|
||||
return f'{start_time.strftime("%I:%M %p")} - {finish.strftime("%I:%M %p")}'
|
||||
|
||||
|
||||
def zip_participants(player_ids, participant_ids):
|
||||
"""Pair each player with the participant row that was created for them.
|
||||
|
||||
The routes build these as two parallel lists, appended in step. Pairing
|
||||
them by index is what the original code did, and it is only correct as
|
||||
long as they stay in step — hence one place to look at rather than
|
||||
three. A short participant list yields None, which
|
||||
:func:`notify_participants` turns into the fallback reference.
|
||||
|
||||
Args:
|
||||
player_ids: Player primary keys, in creation order.
|
||||
participant_ids: Participant row ids, in the same order.
|
||||
|
||||
Yields:
|
||||
tuple[int, int | None]: (player_id, participant_id).
|
||||
"""
|
||||
for index, player_id in enumerate(player_ids):
|
||||
yield player_id, participant_ids[index] if index < len(participant_ids) else None
|
||||
|
||||
|
||||
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
|
||||
"""Tell each participant that a match has been scheduled or changed.
|
||||
|
||||
Args:
|
||||
title: Match title, shown in the message.
|
||||
date: The match date.
|
||||
start_time: Start time, or None.
|
||||
end_time: End time, or None.
|
||||
participants: Iterable of (player_id, participant_id) pairs.
|
||||
participant_id is what a Discord reaction resolves back to, so
|
||||
attendance lands on the right row.
|
||||
fallback_id: Reference to use when a participant row has no id —
|
||||
the match's own, which the bot can still act on.
|
||||
|
||||
Returns:
|
||||
int: How many notifications were handed to the bot.
|
||||
"""
|
||||
event_date = date.strftime('%Y-%m-%d')
|
||||
event_time = format_event_time(start_time, end_time)
|
||||
|
||||
sent = 0
|
||||
for player_id, participant_id in participants:
|
||||
if not player_id:
|
||||
continue
|
||||
send_schedule_notification(
|
||||
user_id=player_id,
|
||||
event_type='match',
|
||||
event_title=title,
|
||||
event_date=event_date,
|
||||
event_time=event_time,
|
||||
reference_id=participant_id or fallback_id,
|
||||
)
|
||||
sent += 1
|
||||
return sent
|
||||
+107
-97
@@ -1,8 +1,8 @@
|
||||
:root {
|
||||
/* New Color Palette (cozy / e-sporty) */
|
||||
--primary: #6C4CFF;
|
||||
--primary-dark: #5530D9;
|
||||
--primary-light: #8B6CFF;
|
||||
/* New Color Palette */
|
||||
--primary: #00984C;
|
||||
--primary-dark: #003E21;
|
||||
--primary-light: #E5A939;
|
||||
--success: #00984C;
|
||||
--success-light: #F0F2F2;
|
||||
--warning: #E5A939;
|
||||
@@ -13,7 +13,7 @@
|
||||
--info-light: #eff6ff;
|
||||
--secondary: #6b7280;
|
||||
--secondary-light: #F0F2F2;
|
||||
--dark: #0D0D12;
|
||||
--dark: #12130F;
|
||||
--gray-50: #F0F2F2;
|
||||
--gray-100: #E8EAEB;
|
||||
--gray-200: #D1D5DB;
|
||||
@@ -22,8 +22,8 @@
|
||||
--gray-500: #4B5563;
|
||||
--gray-600: #374151;
|
||||
--gray-700: #1F2937;
|
||||
--gray-800: #16161D;
|
||||
--gray-900: #0D0D12;
|
||||
--gray-800: #12130F;
|
||||
--gray-900: #0A0B0A;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-collapsed: 0px;
|
||||
--radius: 12px;
|
||||
@@ -34,66 +34,21 @@
|
||||
--transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Local-first Sora font-face (local installed fonts preferred, then /static/fonts fallback). */
|
||||
/* To use local files, place these filenames in app/static/fonts: Sora-300.woff2, Sora-400.woff2, Sora-500.woff2, Sora-600.woff2, Sora-700.woff2, Sora-800.woff2 */
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: local('Sora'), local('Sora-Regular'), url('/static/fonts/Sora-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: local('Sora Medium'), local('Sora-Medium'), url('/static/fonts/Sora-500.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: local('Sora SemiBold'), local('Sora-SemiBold'), url('/static/fonts/Sora-600.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: local('Sora Bold'), local('Sora-Bold'), url('/static/fonts/Sora-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: local('Sora ExtraBold'), local('Sora-ExtraBold'), url('/static/fonts/Sora-800.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: local('Sora Light'), local('Sora-Light'), url('/static/fonts/Sora-300.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Dark Mode Variables */
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0D0D12;
|
||||
--bg-secondary: #16161D;
|
||||
--bg-tertiary: #23232E;
|
||||
--text-primary: #FFFFFF;
|
||||
--bg-primary: #12130F;
|
||||
--bg-secondary: #1A1D17;
|
||||
--bg-tertiary: #232820;
|
||||
--text-primary: #F0F2F2;
|
||||
--text-secondary: #D1D5DB;
|
||||
--text-muted: #9CA3AF;
|
||||
--border-color: #2D2A3A;
|
||||
--card-bg: #16161D;
|
||||
--sidebar-bg: #0D0D12;
|
||||
--border-color: #2D342A;
|
||||
--card-bg: #1A1D17;
|
||||
--sidebar-bg: #0A0B0A;
|
||||
--sidebar-text: #F0F2F2;
|
||||
--sidebar-hover: rgba(108, 76, 255, 0.08);
|
||||
--input-bg: #23232E;
|
||||
--input-border: #2D2A3A;
|
||||
--sidebar-hover: rgba(240, 242, 242, 0.08);
|
||||
--input-bg: #232820;
|
||||
--input-border: #2D342A;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.25), 0 2px 4px rgba(0,0,0,0.2);
|
||||
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3), 0 4px 6px rgba(0,0,0,0.2);
|
||||
@@ -102,40 +57,13 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Sora', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-800);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Headings use Sora Bold for stronger, e-sporty look */
|
||||
h1, h2, h3, h4, h5, h6, .page-header h1, .card-header h3 {
|
||||
font-family: 'Sora', sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary, var(--gray-900));
|
||||
}
|
||||
|
||||
/* Tweak letter-spacing when local Sora is available vs fallback */
|
||||
html.font-sora-available h1, html.font-sora-available h2, html.font-sora-available h3,
|
||||
html.font-sora-available h4, html.font-sora-available h5, html.font-sora-available h6,
|
||||
html.font-sora-available .page-header h1, html.font-sora-available .card-header h3 {
|
||||
letter-spacing: -0.02em; /* tighter when true Sora is present */
|
||||
}
|
||||
html.font-sora-fallback h1, html.font-sora-fallback h2, html.font-sora-fallback h3,
|
||||
html.font-sora-fallback h4, html.font-sora-fallback h5, html.font-sora-fallback h6,
|
||||
html.font-sora-fallback .page-header h1, html.font-sora-fallback .card-header h3 {
|
||||
letter-spacing: -0.01em; /* keep the default for fallback fonts */
|
||||
}
|
||||
|
||||
/* Logo text use extra-bold feel */
|
||||
.logo span, .auth-header h2 {
|
||||
font-family: 'Sora', sans-serif;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
a:hover { color: var(--primary-dark); }
|
||||
|
||||
@@ -211,7 +139,10 @@ a:hover { color: var(--primary-dark); }
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.nav-links li a {
|
||||
/* Logging out is a POST, so its nav entry is a button inside a form
|
||||
rather than a link. It has to read as one of the entries above it. */
|
||||
.nav-links li a,
|
||||
.nav-links li .nav-form button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -221,7 +152,17 @@ a:hover { color: var(--primary-dark); }
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-links li a:hover, .nav-links li a.active {
|
||||
.nav-links li .nav-form button {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-links li a:hover, .nav-links li a.active,
|
||||
.nav-links li .nav-form button:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: white;
|
||||
}
|
||||
@@ -231,7 +172,8 @@ a:hover { color: var(--primary-dark); }
|
||||
padding-left: 17px;
|
||||
}
|
||||
|
||||
.nav-links li a i { width: 20px; text-align: center; font-size: 1.1rem; }
|
||||
.nav-links li a i,
|
||||
.nav-links li .nav-form button i { width: 20px; text-align: center; font-size: 1.1rem; }
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
@@ -574,7 +516,7 @@ a:hover { color: var(--primary-dark); }
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(108, 76, 255, 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(0, 152, 76, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
@@ -645,12 +587,12 @@ a:hover { color: var(--primary-dark); }
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6C4CFF 0%, #16161D 100%);
|
||||
background: linear-gradient(135deg, #00984C 0%, #003E21 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .auth-wrapper {
|
||||
background: linear-gradient(135deg, #5530D9 0%, #0D0D12 100%);
|
||||
background: linear-gradient(135deg, #003E21 0%, #12130F 100%);
|
||||
}
|
||||
|
||||
.auth-wrapper .flash-messages {
|
||||
@@ -728,7 +670,7 @@ a:hover { color: var(--primary-dark); }
|
||||
|
||||
.auth-form .form-group input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(108, 76, 255, 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(0, 152, 76, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -2027,3 +1969,71 @@ a:hover { color: var(--primary-dark); }
|
||||
[data-theme="dark"] .error-container p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Language switcher
|
||||
========================================================================= */
|
||||
|
||||
.nav-language {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-language .lang-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-language .lang-link:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav-language .lang-current {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-language .lang-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.auth-language {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-language .lang-link { color: var(--text-secondary); text-decoration: none; }
|
||||
.auth-language .lang-link:hover { color: var(--primary); text-decoration: underline; }
|
||||
.auth-language .lang-current { font-weight: 600; color: var(--text-primary); }
|
||||
.auth-language .lang-separator { opacity: 0.4; }
|
||||
|
||||
/* Visually hidden, still announced by screen readers. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Honeypot: hidden from everyone, filled in only by a robot (SEC-AUTH-008).
|
||||
The opposite of .sr-only above — that one hides from the eye and keeps the
|
||||
announcement, this one has to hide from both. display:none is deliberate:
|
||||
an off-screen input is still reachable by keyboard and by a screen reader,
|
||||
and a person who lands in it gets refused with no idea why. */
|
||||
.honeypot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
+159
-1
@@ -436,4 +436,162 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
/* =========================================================================
|
||||
Declarative behaviours — replacing inline event handlers
|
||||
=========================================================================
|
||||
|
||||
A Content Security Policy without 'unsafe-inline' blocks `onclick="..."`
|
||||
attributes, and a nonce does not help: nonces apply to <script> elements,
|
||||
never to event handler attributes. Dropping 'unsafe-inline' therefore
|
||||
requires removing every one of them first.
|
||||
|
||||
Rather than one listener per widget, behaviours are declared in the
|
||||
markup with a data-action attribute and dispatched from a single
|
||||
delegated listener. New markup gets the behaviour for free, and nothing
|
||||
has to be re-bound after content is replaced dynamically.
|
||||
|
||||
<button data-action="toggle-sidebar">
|
||||
<button data-action="dismiss-alert">
|
||||
<div data-action="hide-modal" data-modal-id="confirmDelete">
|
||||
|
||||
Migration status is tracked by tests/test_csp.py.
|
||||
========================================================================= */
|
||||
|
||||
const DATA_ACTIONS = {
|
||||
'toggle-sidebar': function () {
|
||||
toggleSidebar();
|
||||
},
|
||||
'toggle-dark-mode': function () {
|
||||
toggleDarkMode();
|
||||
},
|
||||
'dismiss-alert': function (element) {
|
||||
const alert = element.closest('.alert');
|
||||
if (alert) {
|
||||
alert.remove();
|
||||
}
|
||||
},
|
||||
'hide-modal': function (element) {
|
||||
const id = element.getAttribute('data-modal-id');
|
||||
if (id && typeof hideModal === 'function') {
|
||||
hideModal(id);
|
||||
}
|
||||
},
|
||||
'history-back': function (element, event) {
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
},
|
||||
// Removes the nearest ancestor matching data-remove, or the parent.
|
||||
'remove-element': function (element) {
|
||||
const selector = element.getAttribute('data-remove');
|
||||
const target = selector ? element.closest(selector) : element.parentElement;
|
||||
if (target) {
|
||||
target.remove();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Register behaviours defined by a single page.
|
||||
*
|
||||
* Page-local functions live in that page's script block, so they cannot be
|
||||
* listed in DATA_ACTIONS above. Each page declares its own:
|
||||
*
|
||||
* registerActions({ 'clear-availability': clearAllAvailability });
|
||||
*
|
||||
* @param {Object} map - action name to handler(element, event).
|
||||
*/
|
||||
function registerActions(map) {
|
||||
Object.assign(DATA_ACTIONS, map);
|
||||
}
|
||||
|
||||
function dispatchAction(attribute, event) {
|
||||
const trigger = event.target.closest('[' + attribute + ']');
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const handler = DATA_ACTIONS[trigger.getAttribute(attribute)];
|
||||
if (handler) {
|
||||
handler(trigger, event);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
dispatchAction('data-action', event);
|
||||
});
|
||||
|
||||
// Separate attribute rather than one shared with click: a <select> would
|
||||
// otherwise fire its handler on the click that opens it.
|
||||
document.addEventListener('change', function (event) {
|
||||
dispatchAction('data-change', event);
|
||||
});
|
||||
|
||||
/**
|
||||
* Confirmation before a destructive submit.
|
||||
*
|
||||
* <form data-confirm="Delete this match?">
|
||||
*
|
||||
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
||||
* markup where it can be translated.
|
||||
*/
|
||||
document.addEventListener('submit', function (event) {
|
||||
const form = event.target.closest('[data-confirm]');
|
||||
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Live value display next to a range input.
|
||||
*
|
||||
* <input type="range" data-mirror>
|
||||
* <span>5</span>
|
||||
*
|
||||
* Replaces oninput="this.nextElementSibling.textContent = this.value",
|
||||
* which the evaluation form repeated on all nine score sliders.
|
||||
* data-mirror may name a selector; empty means the next sibling.
|
||||
*/
|
||||
document.addEventListener('input', function (event) {
|
||||
const input = event.target.closest('[data-mirror]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const selector = input.getAttribute('data-mirror');
|
||||
const target = selector
|
||||
? document.querySelector(selector)
|
||||
: input.nextElementSibling;
|
||||
if (target) {
|
||||
target.textContent = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Submit the surrounding form when a control changes.
|
||||
*
|
||||
* <select data-submit-on-change>
|
||||
*
|
||||
* Replaces onchange="this.form.submit()".
|
||||
*/
|
||||
document.addEventListener('change', function (event) {
|
||||
const control = event.target.closest('[data-submit-on-change]');
|
||||
if (control && control.form) {
|
||||
control.form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigate on selection.
|
||||
*
|
||||
* <select data-navigate="/team-matches/{value}/create">
|
||||
*
|
||||
* {value} is replaced by the chosen option, URL-encoded. An empty
|
||||
* selection navigates nowhere.
|
||||
*/
|
||||
document.addEventListener('change', function (event) {
|
||||
const select = event.target.closest('[data-navigate]');
|
||||
if (!select || !select.value) {
|
||||
return;
|
||||
}
|
||||
window.location.href = select.getAttribute('data-navigate')
|
||||
.replace('{value}', encodeURIComponent(select.value));
|
||||
});
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""Where the application's files live, and how the database refers to them.
|
||||
|
||||
Three roots: the project itself, the document store, and the log directory.
|
||||
They are here together because they are the same defect three times over
|
||||
(OBS-006) — `os.path.join(os.getcwd(), …)`, evaluated at import or upload
|
||||
time, so every one of them moved with whatever directory the process was
|
||||
started from. The document store was fixed first, in wave G, because it was
|
||||
the one that also blocked OPS-011; the other two were left behind, which is
|
||||
the repeated lesson of this project: a faulty pattern corrected in one layer
|
||||
stays in the others.
|
||||
|
||||
Contracts were stored at `os.path.join(os.getcwd(), 'documents', …)`,
|
||||
evaluated at upload time, and the resulting absolute path was written into
|
||||
`Contract.file_path`. The storage root therefore moved with whatever
|
||||
directory the process happened to be started from. Two consequences:
|
||||
|
||||
- one latent: start the server from elsewhere and new contracts land in a
|
||||
new tree while the old ones become unreadable — with the database still
|
||||
saying they are there, so the failure surfaces as a 500 on download
|
||||
rather than as anything a person could act on;
|
||||
- one blocking: it rules out a release-directory deployment (OPS-011)
|
||||
outright. Every stored path would point inside a release that is about
|
||||
to be replaced, so the first switch would take every contract ever
|
||||
uploaded with it.
|
||||
|
||||
New rows keep a path *relative* to the document root. Old rows keep their
|
||||
absolute path and are returned untouched, so this change needs no data
|
||||
migration and can ship before Alembic does (DB-002).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
#: Environment override for the document root. What a release-directory
|
||||
#: deployment sets, to a path outside the releases — alongside them, not
|
||||
#: inside whichever one is current.
|
||||
DOCUMENTS_ROOT_ENV = 'DOCUMENTS_ROOT'
|
||||
|
||||
#: Environment override for the log directory. Same reasoning: a release
|
||||
#: directory that carries its own logs loses them at the next switch.
|
||||
LOG_DIR_ENV = 'LOG_DIR'
|
||||
|
||||
#: Environment override for the backup directory.
|
||||
BACKUP_DIR_ENV = 'BACKUP_DIR'
|
||||
|
||||
#: Sub-directory holding uploaded contracts, under the document root.
|
||||
CONTRACTS_DIR = 'contrats signés'
|
||||
|
||||
|
||||
def project_root():
|
||||
"""Absolute path of the project, derived from this file's location.
|
||||
|
||||
The anchor every other root falls back to. Not `os.getcwd()`: the
|
||||
process is started by Waitress under Pterodactyl, by pytest, by a task
|
||||
scheduler and by a person in a shell, and only one of those four is
|
||||
reliably in the project directory.
|
||||
"""
|
||||
package_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.dirname(package_dir)
|
||||
|
||||
|
||||
def documents_root():
|
||||
"""Absolute path of the document store.
|
||||
|
||||
Falls back to `documents/` beside this package — the project root
|
||||
wherever it is installed, rather than wherever the process was launched.
|
||||
"""
|
||||
return _rooted(DOCUMENTS_ROOT_ENV, 'documents')
|
||||
|
||||
|
||||
def logs_root():
|
||||
"""Absolute path of the log directory.
|
||||
|
||||
Was `os.path.join(os.getcwd(), 'logs')`. Starting the server from
|
||||
another directory sent the logs somewhere new without a word, which is
|
||||
the worst possible failure mode for the one file you go and read when
|
||||
something else has gone wrong.
|
||||
"""
|
||||
return _rooted(LOG_DIR_ENV, 'logs')
|
||||
|
||||
|
||||
def backups_root():
|
||||
"""Absolute path of the backup directory."""
|
||||
return _rooted(BACKUP_DIR_ENV, 'backups')
|
||||
|
||||
|
||||
def _rooted(env_name, default_name):
|
||||
"""The configured path, or `default_name` under the project root."""
|
||||
configured = os.getenv(env_name)
|
||||
if configured:
|
||||
return os.path.abspath(configured)
|
||||
return os.path.join(project_root(), default_name)
|
||||
|
||||
|
||||
def discard_documents(stored_paths):
|
||||
"""Remove these documents from disk. Returns how many went (DATA-012).
|
||||
|
||||
`delete_user` removed the Contract rows and left the PDFs. Signed,
|
||||
named contracts therefore stayed on the server after the account was
|
||||
deleted, with nothing in the database pointing at them — invisible to
|
||||
the application, unmanageable through it, and still personal data.
|
||||
|
||||
Call this **after** the commit that removed the rows, never before: a
|
||||
failure between the two should leave a file with no row (recoverable,
|
||||
and what the previous behaviour produced anyway) rather than a row with
|
||||
no file (a download that 500s for ever).
|
||||
|
||||
A path that cannot be removed is logged and skipped. Nothing here should
|
||||
be able to abort the deletion of an account.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
removed = 0
|
||||
for stored_path in stored_paths:
|
||||
if not stored_path:
|
||||
continue
|
||||
target = document_path(stored_path)
|
||||
try:
|
||||
os.remove(target)
|
||||
removed += 1
|
||||
except FileNotFoundError:
|
||||
# Already gone. Two contracts sharing a stem, or a previous
|
||||
# attempt: not a problem, and not worth an error line.
|
||||
logger.info('Document already absent: %s', target)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
'Could not remove %s (%s). It is now an orphan: no database row '
|
||||
'refers to it, so nothing in the application will ever offer to '
|
||||
'delete it again.',
|
||||
target,
|
||||
exc,
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
def document_path(stored_path):
|
||||
"""Absolute path of a document, from what the database holds.
|
||||
|
||||
Args:
|
||||
stored_path: The value of Contract.file_path or signed_file_path.
|
||||
Relative for rows written since this module existed, absolute
|
||||
for the ones written before.
|
||||
|
||||
Returns:
|
||||
str: An absolute path.
|
||||
"""
|
||||
if os.path.isabs(stored_path):
|
||||
return stored_path
|
||||
return os.path.join(documents_root(), stored_path)
|
||||
@@ -1 +1 @@
|
||||
# supporting scripts package
|
||||
# supporting scripts package
|
||||
|
||||
+305
-112
@@ -1,27 +1,163 @@
|
||||
"""Database backup script for the Team Tryouts application.
|
||||
"""Database and document backup for the Team Tryouts application.
|
||||
|
||||
This module provides a simple backup mechanism for the SQLite database
|
||||
and uploaded contract documents. Designed to be run as a scheduled task
|
||||
(Windows Task Scheduler) or cron job.
|
||||
Dumps the PostgreSQL database with pg_dump and archives the uploaded
|
||||
contract documents. Designed to be run from a scheduled task (Windows Task
|
||||
Scheduler) or a cron job.
|
||||
|
||||
Usage:
|
||||
python backup.py
|
||||
|
||||
python app/supporting_scripts/backup.py
|
||||
python app/supporting_scripts/backup.py --verify-only <archive>
|
||||
|
||||
Configuration via environment variables:
|
||||
BACKUP_DIR: Directory to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
||||
DATABASE_URL PostgreSQL connection string (required)
|
||||
BACKUP_DIR Where to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS How long to keep them (default: 30)
|
||||
PG_DUMP Path to pg_dump if not on PATH
|
||||
PG_RESTORE Path to pg_restore if not on PATH
|
||||
|
||||
A note on what this file used to be
|
||||
-----------------------------------
|
||||
The previous version targeted **SQLite**: it imported sqlite3, read
|
||||
DATABASE_PATH defaulting to instance/team_tryouts.db, and used the sqlite3
|
||||
backup API. Production runs on PostgreSQL, so the file never existed, the
|
||||
script printed "[WARNING] Database not found... Skipping database backup"
|
||||
and — because main() only tracked the verification result — still exited 0.
|
||||
It reported success while backing up nothing at all. Any scheduled task
|
||||
watching the exit code saw green.
|
||||
|
||||
Restoring is documented in docs/restauration-base.md. A backup that has
|
||||
never been restored is not a backup.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
# Run as `python app/supporting_scripts/backup.py`, sys.path[0] is this
|
||||
# script's directory, so the application package is not importable. It has
|
||||
# to be — see DOCUMENTS_DIR below.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
|
||||
BACKUP_DIR = backups_root()
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
||||
DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db'))
|
||||
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
|
||||
PG_DUMP = os.getenv('PG_DUMP', 'pg_dump')
|
||||
PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
|
||||
|
||||
# There is deliberately no DOCUMENTS_DIR constant any more. It held
|
||||
# `os.path.join(os.getcwd(), 'documents')`, which had stopped being true:
|
||||
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
||||
# keep uploads outside the releases, and docs/deployment.md now tells the
|
||||
# operator to set it — at which point this script archived a directory the
|
||||
# application had never written to. It does not fail on a missing directory
|
||||
# either; it prints "No documents directory found", skips, and exits 0.
|
||||
#
|
||||
# So the more correctly an operator followed the deployment documentation,
|
||||
# the more certainly their contract backups were empty (OBS-006).
|
||||
#
|
||||
# backup_documents() now asks app.storage, at call time, the same question
|
||||
# the upload path asks. One source of truth, and one that a test can move.
|
||||
|
||||
|
||||
class BackupError(Exception):
|
||||
"""Raised when a backup step fails in a way that must stop the run."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_database_url(url):
|
||||
"""Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings.
|
||||
|
||||
Accepts the dialect suffixes SQLAlchemy uses (postgresql+psycopg://),
|
||||
which pg_dump does not understand.
|
||||
|
||||
Args:
|
||||
url: The connection string.
|
||||
|
||||
Returns:
|
||||
dict: host, port, dbname, user, password.
|
||||
|
||||
Raises:
|
||||
BackupError: If the URL is missing or is not a PostgreSQL one.
|
||||
"""
|
||||
if not url:
|
||||
raise BackupError('DATABASE_URL is not set.')
|
||||
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.split('+')[0]
|
||||
if scheme not in ('postgresql', 'postgres'):
|
||||
raise BackupError(
|
||||
f'DATABASE_URL is not a PostgreSQL connection string (scheme: {scheme!r}). '
|
||||
'This script only backs up PostgreSQL.'
|
||||
)
|
||||
|
||||
dbname = (parsed.path or '').lstrip('/')
|
||||
if not dbname:
|
||||
raise BackupError('DATABASE_URL does not name a database.')
|
||||
|
||||
return {
|
||||
'host': parsed.hostname or 'localhost',
|
||||
'port': str(parsed.port or 5432),
|
||||
'dbname': dbname,
|
||||
'user': unquote(parsed.username) if parsed.username else '',
|
||||
'password': unquote(parsed.password) if parsed.password else '',
|
||||
}
|
||||
|
||||
|
||||
def describe_target(conn):
|
||||
"""Human-readable target, deliberately without the password."""
|
||||
user = f'{conn["user"]}@' if conn['user'] else ''
|
||||
return f'{user}{conn["host"]}:{conn["port"]}/{conn["dbname"]}'
|
||||
|
||||
|
||||
def build_dump_command(conn, output_path):
|
||||
"""Assemble the pg_dump invocation.
|
||||
|
||||
--format=custom is compressed and lets pg_restore rebuild selectively;
|
||||
plain SQL would be larger and all-or-nothing.
|
||||
|
||||
The password is never placed on the command line — it would be visible
|
||||
to anyone able to list processes. It travels through PGPASSWORD instead,
|
||||
which is what pg_dump documents for non-interactive use.
|
||||
"""
|
||||
return [
|
||||
PG_DUMP,
|
||||
'--host',
|
||||
conn['host'],
|
||||
'--port',
|
||||
conn['port'],
|
||||
'--username',
|
||||
conn['user'],
|
||||
'--dbname',
|
||||
conn['dbname'],
|
||||
'--format=custom',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
'--file',
|
||||
output_path,
|
||||
]
|
||||
|
||||
|
||||
def dump_environment(conn):
|
||||
"""Environment for pg_dump/pg_restore, carrying the password out of argv."""
|
||||
env = os.environ.copy()
|
||||
if conn['password']:
|
||||
env['PGPASSWORD'] = conn['password']
|
||||
return env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_backup_dir():
|
||||
@@ -29,56 +165,134 @@ def create_backup_dir():
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def backup_database():
|
||||
"""Backup the SQLite database using sqlite3's built-in backup API.
|
||||
|
||||
Returns:
|
||||
str: Path to the created backup file, or None if failed.
|
||||
"""
|
||||
if not os.path.exists(DATABASE_PATH):
|
||||
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
|
||||
return None
|
||||
def backup_database(conn):
|
||||
"""Dump the PostgreSQL database.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive.
|
||||
|
||||
Raises:
|
||||
BackupError: If pg_dump is missing, fails, or produces nothing.
|
||||
"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'db_backup_{timestamp}.db'
|
||||
backup_path = os.path.join(BACKUP_DIR, backup_filename)
|
||||
backup_path = os.path.join(BACKUP_DIR, f'db_backup_{timestamp}.dump')
|
||||
|
||||
print(f'[INFO] Dumping {describe_target(conn)}')
|
||||
|
||||
try:
|
||||
source = sqlite3.connect(DATABASE_PATH)
|
||||
destination = sqlite3.connect(backup_path)
|
||||
source.backup(destination)
|
||||
source.close()
|
||||
destination.close()
|
||||
print(f'[OK] Database backed up to: {backup_path}')
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Database backup failed: {e}')
|
||||
return None
|
||||
result = subprocess.run(
|
||||
build_dump_command(conn, backup_path),
|
||||
env=dump_environment(conn),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
except FileNotFoundError as err:
|
||||
raise BackupError(
|
||||
f'{PG_DUMP} not found. Install the PostgreSQL client tools, or set '
|
||||
'PG_DUMP to its full path.'
|
||||
) from err
|
||||
except subprocess.TimeoutExpired as err:
|
||||
raise BackupError('pg_dump timed out after 15 minutes.') from err
|
||||
|
||||
if result.returncode != 0:
|
||||
raise BackupError(f'pg_dump failed: {result.stderr.strip()}')
|
||||
|
||||
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
|
||||
raise BackupError('pg_dump reported success but produced an empty file.')
|
||||
|
||||
size_mb = os.path.getsize(backup_path) / (1024 * 1024)
|
||||
print(f'[OK] Database backed up to: {backup_path} ({size_mb:.1f} MB)')
|
||||
return backup_path
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Check that the archive is readable and actually contains tables.
|
||||
|
||||
pg_restore --list parses the whole archive without touching any
|
||||
database. A dump that cannot be listed cannot be restored, and an
|
||||
archive holding no table would mean the dump ran against the wrong
|
||||
target — both are silent failures worth catching here rather than
|
||||
during an incident.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the archive to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if the archive looks restorable.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
print('[ERROR] Nothing to verify.')
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[PG_RESTORE, '--list', backup_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(f'[WARNING] {PG_RESTORE} not found: archive left unverified.')
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[ERROR] pg_restore --list timed out.')
|
||||
return False
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f'[ERROR] Archive is not readable: {result.stderr.strip()}')
|
||||
return False
|
||||
|
||||
table_count = sum(1 for line in result.stdout.splitlines() if ' TABLE DATA ' in line)
|
||||
if table_count == 0:
|
||||
print('[ERROR] Archive contains no table data.')
|
||||
return False
|
||||
|
||||
print(f'[OK] Archive verified: {table_count} table(s) present.')
|
||||
return True
|
||||
|
||||
|
||||
def backup_documents():
|
||||
"""Backup the uploaded contract documents directory.
|
||||
|
||||
"""Archive the uploaded contract documents directory.
|
||||
|
||||
The directory is resolved through `app.storage.documents_root()` — the
|
||||
same function the upload path uses — so that setting DOCUMENTS_ROOT
|
||||
moves both together. Resolved here rather than at import, so that what
|
||||
is backed up depends on the environment the run has, not on the one the
|
||||
module happened to be imported with.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if no documents exist.
|
||||
str: Path to the created archive, or None if there is nothing to
|
||||
archive. Signed contracts live only on disk, so losing this
|
||||
directory loses the documents themselves.
|
||||
"""
|
||||
if not os.path.exists(DOCUMENTS_DIR):
|
||||
print('[INFO] No documents directory found. Skipping document backup.')
|
||||
documents_dir = documents_root()
|
||||
|
||||
if not os.path.exists(documents_dir):
|
||||
# Says where it looked. The previous message named no path, so an
|
||||
# operator who had moved the documents read it as "there are no
|
||||
# documents" rather than "I am looking in the wrong place".
|
||||
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = f'documents_backup_{timestamp}'
|
||||
archive_path = os.path.join(BACKUP_DIR, archive_basename)
|
||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR)
|
||||
zip_path = f'{archive_path}.zip'
|
||||
print(f'[OK] Documents backed up to: {zip_path}')
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Document backup failed: {e}')
|
||||
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
||||
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
|
||||
# This runs after the database dump has already succeeded. Letting
|
||||
# anything through here would abort the script with a traceback and
|
||||
# take the one part that worked down with it. Reported to stdout, in
|
||||
# the format the rest of this script uses; it has no logger.
|
||||
print(f'[ERROR] Document backup failed: {exc}')
|
||||
return None
|
||||
|
||||
zip_path = f'{archive_basename}.zip'
|
||||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||
return zip_path
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||
@@ -90,95 +304,74 @@ def cleanup_old_backups():
|
||||
|
||||
for filename in os.listdir(BACKUP_DIR):
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||
except OSError as e:
|
||||
print(f'[WARNING] Could not remove {filename}: {e}')
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
if datetime.fromtimestamp(os.path.getmtime(file_path)) >= cutoff:
|
||||
continue
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||
except OSError as exc:
|
||||
print(f'[WARNING] Could not remove {filename}: {exc}')
|
||||
|
||||
if removed_count > 0:
|
||||
if removed_count:
|
||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||
else:
|
||||
print('[CLEANUP] No old backups to remove.')
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Verify a database backup by running a quick integrity check.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the backup file to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if backup is valid, False otherwise.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(backup_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('PRAGMA integrity_check')
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
is_valid = result[0] == 'ok'
|
||||
if is_valid:
|
||||
print(f'[OK] Backup integrity verified: {backup_path}')
|
||||
else:
|
||||
print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}')
|
||||
return is_valid
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Backup verification failed: {e}')
|
||||
return False
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
"""Run the full backup process.
|
||||
|
||||
Steps:
|
||||
1. Create backup directory
|
||||
2. Backup database
|
||||
3. Backup documents (if any)
|
||||
4. Verify database backup
|
||||
5. Clean up old backups
|
||||
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on failure.
|
||||
int: 0 when the database was dumped AND verified, 1 otherwise. The
|
||||
previous version returned 0 even when it had backed up nothing.
|
||||
"""
|
||||
print(f'=== Team Tryouts Backup ===')
|
||||
parser = argparse.ArgumentParser(description='Team Tryouts backup')
|
||||
parser.add_argument(
|
||||
'--verify-only', metavar='ARCHIVE', help='Verify an existing archive and exit'
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.verify_only:
|
||||
return 0 if verify_backup(args.verify_only) else 1
|
||||
|
||||
print('=== Team Tryouts Backup ===')
|
||||
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print(f'Backup directory: {BACKUP_DIR}')
|
||||
# Printed because it is the value that was wrong for months without
|
||||
# anyone being able to see it from the output.
|
||||
print(f'Document source: {documents_root()}')
|
||||
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
||||
print()
|
||||
|
||||
create_backup_dir()
|
||||
try:
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
create_backup_dir()
|
||||
backup_path = backup_database(conn)
|
||||
except BackupError as exc:
|
||||
print(f'[ERROR] {exc}')
|
||||
print('\n=== Backup FAILED — no database backup was produced ===')
|
||||
return 1
|
||||
|
||||
# 1. Backup database
|
||||
db_backup_path = backup_database()
|
||||
success = True
|
||||
|
||||
# 2. Verify database backup
|
||||
if db_backup_path:
|
||||
if not verify_backup(db_backup_path):
|
||||
success = False
|
||||
|
||||
# 3. Backup documents
|
||||
verified = verify_backup(backup_path)
|
||||
backup_documents()
|
||||
|
||||
# 4. Cleanup old backups
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if success:
|
||||
if verified:
|
||||
print('=== Backup completed successfully ===')
|
||||
else:
|
||||
print('=== Backup completed with warnings ===')
|
||||
return 0
|
||||
|
||||
return 0 if success else 1
|
||||
print('=== Backup FAILED verification — do not rely on this archive ===')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(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())
|
||||
@@ -12,29 +12,29 @@ Usage:
|
||||
python security_scan.py [--url http://localhost:5000]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def check_environment():
|
||||
"""Check required environment variables are set.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if all critical variables are set.
|
||||
"""
|
||||
print('=' * 60)
|
||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
|
||||
critical_vars = ['SECRET_KEY']
|
||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
||||
all_ok = True
|
||||
|
||||
|
||||
for var in critical_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
@@ -47,37 +47,37 @@ def check_environment():
|
||||
else:
|
||||
print(f'[FAIL] {var} is not set!')
|
||||
all_ok = False
|
||||
|
||||
|
||||
for var in recommended_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
print(f'[OK] {var} is set')
|
||||
else:
|
||||
print(f'[INFO] {var} is not set (using default)')
|
||||
|
||||
|
||||
# Check FLASK_DEBUG
|
||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||
if debug == 'true':
|
||||
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
|
||||
else:
|
||||
print('[OK] FLASK_DEBUG is disabled')
|
||||
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_https_headers(url):
|
||||
"""Check HTTP security headers from a running application.
|
||||
|
||||
|
||||
Args:
|
||||
url: The base URL of the application to check.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if all critical headers are present.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('2. HTTP SECURITY HEADERS CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
|
||||
required_headers = {
|
||||
'Strict-Transport-Security': 'HSTS enabled',
|
||||
'X-Content-Type-Options': 'Prevents MIME sniffing',
|
||||
@@ -87,31 +87,31 @@ def check_https_headers(url):
|
||||
'Permissions-Policy': 'Permissions control',
|
||||
'Cross-Origin-Opener-Policy': 'Cross-origin isolation',
|
||||
}
|
||||
|
||||
|
||||
all_ok = True
|
||||
|
||||
|
||||
try:
|
||||
# Create a context that doesn't verify SSL (for local testing)
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
|
||||
headers = response.headers
|
||||
status = response.status
|
||||
|
||||
|
||||
print(f'[INFO] Response status: {status}')
|
||||
|
||||
|
||||
for header, description in required_headers.items():
|
||||
if header in headers:
|
||||
print(f'[OK] {header}: {description}')
|
||||
else:
|
||||
print(f'[FAIL] {header} is missing: {description}')
|
||||
all_ok = False
|
||||
|
||||
|
||||
# Check cookie attributes if any set-cookie headers exist
|
||||
if 'Set-Cookie' in headers:
|
||||
cookie = headers['Set-Cookie']
|
||||
@@ -120,21 +120,23 @@ def check_https_headers(url):
|
||||
else:
|
||||
print('[WARN] Cookies missing Secure flag')
|
||||
all_ok = False
|
||||
|
||||
|
||||
if 'HttpOnly' in cookie:
|
||||
print('[OK] Cookies have HttpOnly flag')
|
||||
else:
|
||||
print('[WARN] Cookies missing HttpOnly flag')
|
||||
all_ok = False
|
||||
|
||||
|
||||
if 'SameSite' in cookie:
|
||||
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}')
|
||||
print(
|
||||
f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}'
|
||||
)
|
||||
else:
|
||||
print('[WARN] Cookies missing SameSite attribute')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[INFO] No Set-Cookie headers in response')
|
||||
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f'[INFO] Got HTTP {e.code} (may need authentication)')
|
||||
# Still check headers even on error responses
|
||||
@@ -144,53 +146,57 @@ def check_https_headers(url):
|
||||
else:
|
||||
print(f'[FAIL] {header} is missing: {description}')
|
||||
all_ok = False
|
||||
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
||||
print('[SKIP] Run with --url <application_url> to check headers')
|
||||
return True # Not a failure, just can't check
|
||||
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Run pip-audit to check for known vulnerabilities.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if no critical vulnerabilities found.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('3. DEPENDENCY VULNERABILITY SCAN')
|
||||
print('=' * 60)
|
||||
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
print('[OK] No known vulnerabilities found')
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
vulns = data.get('dependencies', [])
|
||||
if vulns:
|
||||
for vuln in vulns:
|
||||
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}')
|
||||
return False
|
||||
else:
|
||||
print('[OK] No vulnerabilities found')
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
if result.stdout:
|
||||
print(f'[INFO] {result.stdout.strip()}')
|
||||
if result.stderr:
|
||||
print(f'[WARN] {result.stderr.strip()}')
|
||||
return True
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
# pip-audit's "dependencies" array lists EVERY dependency, each
|
||||
# carrying a "vulns" list that is empty when the package is
|
||||
# clean. Treating the array itself as the vulnerability list
|
||||
# reported all ~45 installed packages as vulnerable on every
|
||||
# run, which is why this check was pure noise.
|
||||
affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
|
||||
if affected:
|
||||
for dep in affected:
|
||||
ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
|
||||
print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
|
||||
return False
|
||||
print('[OK] No vulnerabilities found')
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
if result.stdout:
|
||||
print(f'[INFO] {result.stdout.strip()}')
|
||||
if result.stderr:
|
||||
print(f'[WARN] {result.stderr.strip()}')
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
||||
return True
|
||||
@@ -201,23 +207,23 @@ def check_dependencies():
|
||||
|
||||
def check_file_permissions():
|
||||
"""Check for common security issues in the project structure.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if no critical issues found.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('4. PROJECT FILES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
|
||||
all_ok = True
|
||||
|
||||
|
||||
# Check .gitignore exists and contains important patterns
|
||||
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
|
||||
if os.path.exists(gitignore_path):
|
||||
required_patterns = ['.env', 'instance/', '*.db', '*.log']
|
||||
with open(gitignore_path, 'r') as f:
|
||||
with open(gitignore_path) as f:
|
||||
content = f.read()
|
||||
|
||||
|
||||
for pattern in required_patterns:
|
||||
if pattern in content:
|
||||
print(f'[OK] .gitignore contains: {pattern}')
|
||||
@@ -227,17 +233,17 @@ def check_file_permissions():
|
||||
else:
|
||||
print('[FAIL] .gitignore file not found!')
|
||||
all_ok = False
|
||||
|
||||
|
||||
# Check for .env in working directory (should NOT be committed)
|
||||
env_path = os.path.join(os.getcwd(), '.env')
|
||||
if os.path.exists(env_path):
|
||||
print('[INFO] .env file exists (ensure it is NOT committed)')
|
||||
else:
|
||||
print('[WARN] No .env file found')
|
||||
|
||||
|
||||
# Check for leftover .pyc or __pycache__
|
||||
pycache_count = 0
|
||||
for root, dirs, files in os.walk(os.getcwd()):
|
||||
for _root, dirs, files in os.walk(os.getcwd()):
|
||||
if '__pycache__' in dirs:
|
||||
pycache_count += 1
|
||||
for f in files:
|
||||
@@ -247,33 +253,47 @@ def check_file_permissions():
|
||||
print('[OK] No __pycache__ or .pyc files found')
|
||||
else:
|
||||
print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)')
|
||||
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_flask_config():
|
||||
"""Check Flask application configuration for security.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if configuration looks secure.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('5. FLASK CONFIGURATION CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
|
||||
all_ok = True
|
||||
|
||||
|
||||
try:
|
||||
# The script lives two levels below the project root; without this the
|
||||
# import fails and the whole check was silently skipped.
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
if root not in sys.path:
|
||||
sys.path.insert(0, root)
|
||||
|
||||
from app.app import create_app
|
||||
app = create_app()
|
||||
|
||||
|
||||
# Inspect configuration only: no schema creation, no Discord bot.
|
||||
app = create_app(
|
||||
{
|
||||
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
|
||||
'AUTO_CREATE_TABLES': False,
|
||||
'ENABLE_DISCORD_BOT': False,
|
||||
}
|
||||
)
|
||||
|
||||
# Check session cookie settings
|
||||
cookie_checks = [
|
||||
('SESSION_COOKIE_SECURE', True, 'Secure cookies'),
|
||||
('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'),
|
||||
('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'),
|
||||
]
|
||||
|
||||
|
||||
for config_key, expected, description in cookie_checks:
|
||||
value = app.config.get(config_key)
|
||||
if config_key == 'PERMANENT_SESSION_LIFETIME':
|
||||
@@ -287,7 +307,7 @@ def check_flask_config():
|
||||
else:
|
||||
print(f'[FAIL] {description}: {value}')
|
||||
all_ok = False
|
||||
|
||||
|
||||
# Check MAX_CONTENT_LENGTH
|
||||
max_content = app.config.get('MAX_CONTENT_LENGTH')
|
||||
if max_content:
|
||||
@@ -296,7 +316,7 @@ def check_flask_config():
|
||||
else:
|
||||
print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)')
|
||||
all_ok = False
|
||||
|
||||
|
||||
# Check CSRF
|
||||
csrf_enabled = app.config.get('WTF_CSRF_ENABLED')
|
||||
if csrf_enabled:
|
||||
@@ -304,69 +324,90 @@ def check_flask_config():
|
||||
else:
|
||||
print('[FAIL] CSRF protection: disabled')
|
||||
all_ok = False
|
||||
|
||||
|
||||
# Check if app is in DEBUG mode
|
||||
if app.debug:
|
||||
print('[FAIL] DEBUG mode is enabled!')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[OK] DEBUG mode: disabled')
|
||||
|
||||
except Exception as e:
|
||||
print(f'[SKIP] Cannot check Flask config: {e}')
|
||||
|
||||
|
||||
except Exception as e: # noqa: BLE001 — any failure to load the app is a failed check
|
||||
# Returning all_ok (still True) here meant that failing to load the
|
||||
# application at all was counted as a passing check — the most
|
||||
# important section of the report silently never ran.
|
||||
#
|
||||
# The breadth is the point: this section's question is "does the
|
||||
# application load with a safe configuration", and every way of not
|
||||
# loading answers it the same way. Reported on stdout because this
|
||||
# script is read by a CI job, not by a log collector.
|
||||
print(f'[FAIL] Cannot check Flask config: {e}')
|
||||
return False
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all security checks and produce a summary report.
|
||||
|
||||
|
||||
Returns:
|
||||
int: 0 if all checks pass, 1 if any fail.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description='Security validation scanner')
|
||||
parser.add_argument('--url', default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)')
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-http',
|
||||
action='store_true',
|
||||
help='Skip the live HTTP header check (no server running, e.g. in CI)',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print('╔══════════════════════════════════════════════════════════╗')
|
||||
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║')
|
||||
print('╠══════════════════════════════════════════════════════════╣')
|
||||
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print('╚══════════════════════════════════════════════════════════╝')
|
||||
|
||||
checks = [
|
||||
check_environment,
|
||||
lambda: check_https_headers(args.url),
|
||||
|
||||
# Plain ASCII: the box-drawing characters this banner used crashed the
|
||||
# script outright on a cp1252 Windows console, which is the platform the
|
||||
# project is developed and deployed on.
|
||||
print('=' * 60)
|
||||
print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER')
|
||||
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print('=' * 60)
|
||||
|
||||
checks = [check_environment]
|
||||
if args.skip_http:
|
||||
print('\n[SKIP] HTTP header check disabled via --skip-http')
|
||||
else:
|
||||
checks.append(lambda: check_https_headers(args.url))
|
||||
checks += [
|
||||
check_dependencies,
|
||||
check_file_permissions,
|
||||
check_flask_config,
|
||||
]
|
||||
|
||||
|
||||
results = []
|
||||
for check in checks:
|
||||
results.append(check())
|
||||
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('SUMMARY')
|
||||
print('=' * 60)
|
||||
|
||||
|
||||
passed = sum(1 for r in results if r)
|
||||
failed = sum(1 for r in results if not r)
|
||||
total = len(results)
|
||||
|
||||
|
||||
print(f'Passed: {passed}/{total}')
|
||||
print(f'Failed: {failed}/{total}')
|
||||
|
||||
|
||||
if failed == 0:
|
||||
print('\n[OK] All security checks passed!')
|
||||
return 0
|
||||
else:
|
||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||
return 1
|
||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}400 Bad Request - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Bad Request{% endblock %}
|
||||
{% block title %}{{ _('400 Bad Request') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Bad Request') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h2>400 — Bad Request</h2>
|
||||
<p>The request could not be understood by the server. Please check your input and try again.</p>
|
||||
<h2>{{ _('400 — Bad Request') }}</h2>
|
||||
<p>{{ _('The request could not be understood by the server. Please check your input and try again.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}403 Forbidden - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Access Denied{% endblock %}
|
||||
{% block title %}{{ _('403 Forbidden') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Access Denied') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h2>403 — Forbidden</h2>
|
||||
<p>You do not have permission to access this resource. If you believe this is an error, please contact an administrator.</p>
|
||||
<h2>{{ _('403 — Forbidden') }}</h2>
|
||||
<p>{{ _('You do not have permission to access this resource. If you believe this is an error, please contact an administrator.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}404 Not Found - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Page Not Found{% endblock %}
|
||||
{% block title %}{{ _('404 Not Found') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Page Not Found') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h2>404 — Not Found</h2>
|
||||
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
|
||||
<h2>{{ _('404 — Not Found') }}</h2>
|
||||
<p>{{ _('The page you are looking for does not exist. It may have been moved or deleted.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i> Return Home
|
||||
<i class="fas fa-home"></i> {{ _('Return Home') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}429 Too Many Requests - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Rate Limit Exceeded{% endblock %}
|
||||
{% block title %}{{ _('429 Too Many Requests') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Rate Limit Exceeded') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<h2>429 — Too Many Requests</h2>
|
||||
<p>You have sent too many requests in a short period. Please wait a moment and try again.</p>
|
||||
<h2>{{ _('429 — Too Many Requests') }}</h2>
|
||||
<p>{{ _('You have sent too many requests in a short period. Please wait a moment and try again.') }}</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}500 Server Error - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Internal Server Error{% endblock %}
|
||||
{% block title %}{{ _('500 Server Error') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Internal Server Error') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-cogs"></i>
|
||||
</div>
|
||||
<h2>500 — Internal Server Error</h2>
|
||||
<p>Something went wrong on our end. The error has been logged and will be investigated. Please try again later.</p>
|
||||
<h2>{{ _('500 — Internal Server Error') }}</h2>
|
||||
<p>{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}</p>
|
||||
{# The reference is what makes a report actionable: it names one request
|
||||
in errors.log. It identifies nothing else — no session, no account —
|
||||
so there is nothing to protect here (OBS-005). #}
|
||||
{% if request_id and request_id != '-' %}
|
||||
<p class="text-muted small">{{ _('Reference to quote if you report this:') }} <code>{{ request_id }}</code></p>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-redo-alt"></i> Try Again
|
||||
<i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% 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,50 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ current_locale }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}UdeS team manager{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
{# 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') }}">
|
||||
<script>
|
||||
// Font detection: prefer local Sora, add html.font-sora-available or html.font-sora-fallback
|
||||
(function(){
|
||||
var fontName = 'Sora';
|
||||
function mark(available){
|
||||
try { document.documentElement.classList.add(available ? 'font-sora-available' : 'font-sora-fallback'); } catch(e){}
|
||||
}
|
||||
if (document.fonts && document.fonts.check) {
|
||||
// Quick check for regular weight first
|
||||
try {
|
||||
if (document.fonts.check('1em "' + fontName + '"')) return mark(true);
|
||||
} catch(e){}
|
||||
// Wait briefly for the font to load (up to 1500ms)
|
||||
var settled = false;
|
||||
var timeout = setTimeout(function(){ if (!settled) { settled = true; mark(false); } }, 1500);
|
||||
document.fonts.load('1em "' + fontName + '"').then(function(loaded){
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
// document.fonts.load resolves when font is available; double-check with check()
|
||||
var ok = document.fonts.check('1em "' + fontName + '"');
|
||||
mark(!!ok);
|
||||
}).catch(function(){ if (!settled){ settled = true; clearTimeout(timeout); mark(false); } });
|
||||
} else {
|
||||
// Fallback: inject hidden element and compare computed family
|
||||
var span = document.createElement('span');
|
||||
span.style.fontFamily = fontName + ', monospace';
|
||||
span.style.position = 'absolute';
|
||||
span.style.left = '-9999px';
|
||||
span.style.visibility = 'hidden';
|
||||
span.textContent = 'Axm4';
|
||||
document.head.appendChild(span);
|
||||
var computed = window.getComputedStyle(span).fontFamily || '';
|
||||
document.head.removeChild(span);
|
||||
mark(computed.toLowerCase().indexOf(fontName.toLowerCase()) !== -1);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<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>
|
||||
<body>
|
||||
@@ -52,7 +28,7 @@
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="{{ url_for('static', filename='images/Lockin_logo.png') }}" alt="Lockin Logo" class="logo-img">
|
||||
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="logo-img">
|
||||
<span>UdeS team manager</span>
|
||||
</div>
|
||||
<div class="user-badge">
|
||||
@@ -69,26 +45,26 @@
|
||||
<li>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-th-large"></i>
|
||||
<span>Dashboard</span>
|
||||
<span>{{ _('Dashboard') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
<span>Tryouts</span>
|
||||
<span>{{ _('Tryouts') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
<span>{{ _('Calendar') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<li>
|
||||
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
<span>Evaluations</span>
|
||||
<span>{{ _('Evaluations') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -96,14 +72,14 @@
|
||||
<li>
|
||||
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>My Team(s)</span>
|
||||
<span>{{ _('My Team(s)') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Manage Teams</span>
|
||||
<span>{{ _('Manage Teams') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -111,7 +87,7 @@
|
||||
<li>
|
||||
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Manage Users</span>
|
||||
<span>{{ _('Manage Users') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -119,7 +95,7 @@
|
||||
<li>
|
||||
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span>My Notes</span>
|
||||
<span>{{ _('My Notes') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -127,42 +103,53 @@
|
||||
<li>
|
||||
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span>Notes & One on One</span>
|
||||
<span>{{ _('Notes & One on One') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-file-contract"></i>
|
||||
<span>Contracts</span>
|
||||
<span>{{ _('Contracts') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-divider"></li>
|
||||
<li>
|
||||
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>My Profile</span>
|
||||
<span>{{ _('My Profile') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('auth.logout') }}" class="logout-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
{# A form, not a link: logging out is a state change, and a
|
||||
GET route carries no CSRF token — any site could sign the
|
||||
user out with an <img> tag. Styled as a nav entry. #}
|
||||
<form method="POST" action="{{ url_for('auth.logout') }}" class="nav-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="logout-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>{{ _('Logout') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li class="nav-divider"></li>
|
||||
<li class="nav-language">
|
||||
{% include "layouts/_language_switcher.html" %}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="main-content" id="mainContent">
|
||||
<header class="top-bar">
|
||||
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()">
|
||||
<button class="sidebar-toggle" id="sidebarToggle" data-action="toggle-sidebar">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="page-header">
|
||||
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
|
||||
<h1>{% block page_title %}{{ _('Dashboard') }}{% endblock %}</h1>
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
</div>
|
||||
<button class="dark-mode-toggle" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark mode">
|
||||
<button class="dark-mode-toggle" id="darkModeToggle" data-action="toggle-dark-mode"
|
||||
title="{{ _('Toggle dark mode') }}">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
{% block header_actions %}{% endblock %}
|
||||
@@ -173,7 +160,8 @@
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
<button type="button" class="alert-close" data-action="dismiss-alert"
|
||||
aria-label="{{ _('Dismiss') }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -191,7 +179,8 @@
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
<button type="button" class="alert-close" data-action="dismiss-alert"
|
||||
aria-label="{{ _('Dismiss') }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -199,10 +188,27 @@
|
||||
</div>
|
||||
<div class="auth-container">
|
||||
<div class="auth-header">
|
||||
<img src="{{ url_for('static', filename='images/Lockin_logo.png') }}" alt="Lockin 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>
|
||||
<p>UdeS team manager</p>
|
||||
<p>{{ _('UdeS team manager') }}</p>
|
||||
</div>
|
||||
<div class="auth-language">
|
||||
{% include "layouts/_language_switcher.html" %}
|
||||
</div>
|
||||
{# The same `content` block as the signed-in branch, rendered
|
||||
here too — `self.content()` rather than a second
|
||||
`{% block %}`, which Jinja refuses.
|
||||
|
||||
The error pages (400, 403, 404, 429, 500) all fill `content`,
|
||||
and it existed only inside the `is_authenticated` branch: a
|
||||
signed-out visitor hitting any of them got the logo, the
|
||||
language switcher and no message whatsoever. The <title> still
|
||||
said "404", which is most of why nobody noticed.
|
||||
|
||||
Only one branch of the `if` runs, so this never double-renders.
|
||||
Sign-in pages fill `auth_content` instead and leave this
|
||||
empty. #}
|
||||
{{ self.content() }}
|
||||
{% block auth_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,4 +217,4 @@
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -108,11 +108,12 @@
|
||||
{# Modal Macro - renders a modal dialog #}
|
||||
{% macro modal(id, title, content, footer_buttons=None) %}
|
||||
<div id="{{ id }}" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideModal('{{ id }}')"></div>
|
||||
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="{{ id }}"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="modal-close" onclick="hideModal('{{ id }}')">×</button>
|
||||
<button class="modal-close" data-action="hide-modal" data-modal-id="{{ id }}"
|
||||
aria-label="{{ _('Close') }}">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ content }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Add Personal Note - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Add Personal Note{% endblock %}
|
||||
{% block title %}{{ _('Add Personal Note') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Add Personal Note') }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<span class="breadcrumb">
|
||||
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
|
||||
@@ -19,7 +19,7 @@
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-sticky-note"></i> Add Personal Note</h3>
|
||||
<h3><i class="fas fa-sticky-note"></i> {{ _('Add Personal Note') }}</h3>
|
||||
{% if context_type == 'tryout' %}
|
||||
<p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p>
|
||||
{% elif context_type == 'match' %}
|
||||
@@ -44,9 +44,9 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player_id">Player</label>
|
||||
<label for="player_id">{{ _('Player') }}</label>
|
||||
<select name="player_id" id="player_id" class="form-select" required>
|
||||
<option value="">-- Select a player --</option>
|
||||
<option value="">{{ _('-- Select a player --') }}</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}>
|
||||
{{ player.username }}
|
||||
@@ -56,15 +56,15 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Note Content</label>
|
||||
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="Enter your coaching notes..." required></textarea>
|
||||
<label for="content">{{ _('Note Content') }}</label>
|
||||
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="{{ _('Enter your coaching notes...') }}" required></textarea>
|
||||
</div>
|
||||
|
||||
{% if context_type != 'tryout' and context_type != 'match' %}
|
||||
<div class="form-group">
|
||||
<label for="tryout_id">Link to Tryout (Optional)</label>
|
||||
<label for="tryout_id">{{ _('Link to Tryout (Optional)') }}</label>
|
||||
<select name="tryout_id" id="tryout_id" class="form-select">
|
||||
<option value="">-- No tryout --</option>
|
||||
<option value="">{{ _('-- No tryout --') }}</option>
|
||||
{% for t in tryouts %}
|
||||
<option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
@@ -72,9 +72,9 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="match_id">Link to Match (Optional)</label>
|
||||
<label for="match_id">{{ _('Link to Match (Optional)') }}</label>
|
||||
<select name="match_id" id="match_id" class="form-select">
|
||||
<option value="">-- No match --</option>
|
||||
<option value="">{{ _('-- No match --') }}</option>
|
||||
{% for m in matches %}
|
||||
<option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
@@ -82,9 +82,9 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="team_id">Link to Team (Optional)</label>
|
||||
<label for="team_id">{{ _('Link to Team (Optional)') }}</label>
|
||||
<select name="team_id" id="team_id" class="form-select">
|
||||
<option value="">-- No team --</option>
|
||||
<option value="">{{ _('-- No team --') }}</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
{% if team_notes %}
|
||||
<div class="form-group">
|
||||
<label>Team Notes (Reference)</label>
|
||||
<label>{{ _('Team Notes (Reference)') }}</label>
|
||||
<div class="team-notes-reference">
|
||||
{% for note in team_notes %}
|
||||
<div class="note-reference-item">
|
||||
@@ -117,7 +117,7 @@
|
||||
{% endif %}
|
||||
" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Add Note
|
||||
<i class="fas fa-save"></i> {{ _('Add Note') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -149,4 +149,4 @@
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Calendar - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Calendar{% endblock %}
|
||||
{% block title %}{{ _('Calendar') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Calendar') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-alt"></i> Schedule</h3>
|
||||
<h3><i class="fas fa-calendar-alt"></i> {{ _('Schedule') }}</h3>
|
||||
<div class="header-actions">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('dayGridMonth')">
|
||||
<i class="fas fa-calendar"></i> Month
|
||||
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="dayGridMonth">
|
||||
<i class="fas fa-calendar"></i> {{ _('Month') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridWeek')">
|
||||
<i class="fas fa-calendar-week"></i> Week
|
||||
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridWeek">
|
||||
<i class="fas fa-calendar-week"></i> {{ _('Week') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridDay')">
|
||||
<i class="fas fa-calendar-day"></i> Day
|
||||
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridDay">
|
||||
<i class="fas fa-calendar-day"></i> {{ _('Day') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('listMonth')">
|
||||
<i class="fas fa-list"></i> List
|
||||
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="listMonth">
|
||||
<i class="fas fa-list"></i> {{ _('List') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,26 +31,26 @@
|
||||
|
||||
<!-- Create Event Modal (for clicking empty days) -->
|
||||
<div id="createEventModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div>
|
||||
<div class="modal-backdrop" data-action="hide-create-event"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3>
|
||||
<button class="modal-close" onclick="hideCreateEventModal()">×</button>
|
||||
<h3><i class="fas fa-plus-circle"></i> {{ _('Create New Event') }}</h3>
|
||||
<button class="modal-close" data-action="hide-create-event">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p>
|
||||
<p class="mb-3"><strong>{{ _('Date:') }}</strong> <span id="createEventDate"></span></p>
|
||||
<input type="hidden" id="createEventDateInput"/>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5><i class="fas fa-futbol"></i> Schedule Tryout Match</h5>
|
||||
<p class="text-muted small">Add a scrim/match inside an existing tryout</p>
|
||||
<h5><i class="fas fa-futbol"></i> {{ _('Schedule Tryout Match') }}</h5>
|
||||
<p class="text-muted small">{{ _('Add a scrim/match inside an existing tryout') }}</p>
|
||||
<div class="form-inline">
|
||||
<select id="createTryoutSelect" class="form-select" style="flex:1;">
|
||||
<option value="">-- Select a tryout --</option>
|
||||
<option value="">{{ _('-- Select a tryout --') }}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()">
|
||||
<i class="fas fa-arrow-right"></i> Go
|
||||
<button class="btn btn-sm btn-primary ml-2" data-action="go-tryout-match">
|
||||
<i class="fas fa-arrow-right"></i> {{ _('Go') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,14 +58,14 @@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5><i class="fas fa-users"></i> Schedule Team Match</h5>
|
||||
<p class="text-muted small">Regular season match for an org team</p>
|
||||
<h5><i class="fas fa-users"></i> {{ _('Schedule Team Match') }}</h5>
|
||||
<p class="text-muted small">{{ _('Regular season match for an org team') }}</p>
|
||||
<div class="form-inline">
|
||||
<select id="createTeamSelect" class="form-select" style="flex:1;">
|
||||
<option value="">-- Select a team --</option>
|
||||
<option value="">{{ _('-- Select a team --') }}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()">
|
||||
<i class="fas fa-arrow-right"></i> Go
|
||||
<button class="btn btn-sm btn-success ml-2" data-action="go-team-match">
|
||||
<i class="fas fa-arrow-right"></i> {{ _('Go') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,10 +73,10 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5>
|
||||
<p class="text-muted small">Create a brand new tryout event</p>
|
||||
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()">
|
||||
<i class="fas fa-plus"></i> Create Tryout
|
||||
<h5><i class="fas fa-calendar-plus"></i> {{ _('Create New Tryout') }}</h5>
|
||||
<p class="text-muted small">{{ _('Create a brand new tryout event') }}</p>
|
||||
<button class="btn btn-sm btn-info" data-action="go-create-tryout">
|
||||
<i class="fas fa-plus"></i> {{ _('Create Tryout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,26 +86,26 @@
|
||||
|
||||
<!-- Event Details Modal -->
|
||||
<div id="eventModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideEventModal()"></div>
|
||||
<div class="modal-backdrop" data-action="hide-event-modal"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">Event Details</h3>
|
||||
<button class="modal-close" onclick="hideEventModal()">×</button>
|
||||
<h3 id="modalTitle">{{ _('Event Details') }}</h3>
|
||||
<button class="modal-close" data-action="hide-event-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modalContent"></div>
|
||||
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
|
||||
<button class="btn btn-sm btn-outline" id="calPresenceBtn">Confirm</button>
|
||||
<button class="btn btn-sm btn-outline" id="calPresenceBtn">{{ _('Confirm') }}</button>
|
||||
</div>
|
||||
<div id="modalActions" class="form-actions mt-3" style="display: none;">
|
||||
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
|
||||
<i class="fas fa-trash"></i> Delete Match
|
||||
<i class="fas fa-trash"></i> {{ _('Delete Match') }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
|
||||
<i class="fas fa-edit"></i> Edit Match
|
||||
<i class="fas fa-edit"></i> {{ _('Edit Match') }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
|
||||
<i class="fas fa-eye"></i> View Tryout
|
||||
<i class="fas fa-eye"></i> {{ _('View Tryout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,9 +114,20 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
|
||||
<script>
|
||||
{# The stylesheet that used to sit here — index.global.min.css — does not
|
||||
exist. FullCalendar 6 bundles its styles into the JS, and that file is not
|
||||
in the published package: the link had been answering 404 on every calendar
|
||||
load since the upgrade. A failed stylesheet is silent in the browser, which
|
||||
is why it survived.
|
||||
|
||||
Integrity pins the bundle (QUA-004): this file is executable script from a
|
||||
third party, on the page that shows every match in the club. See the note
|
||||
in layouts/base.html for what SRI does and does not promise. #}
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"
|
||||
integrity="sha384-WfE/vOHqht3KDj6FvpwQUf3UxEPUHoGJ3w1yZ8rhpLWnVigt8HjXL2zXqtcfS7mf"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"></script>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -205,14 +216,16 @@ function fetchTryoutOptions() {
|
||||
fetch('/matches/api/manageable-tryouts')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
// new Option() sets the label as text; concatenating it into
|
||||
// innerHTML let a tryout title carry markup into the page.
|
||||
var sel = document.getElementById('createTryoutSelect');
|
||||
sel.innerHTML = '<option value="">-- Select a tryout --</option>';
|
||||
sel.replaceChildren(new Option('-- Select a tryout --', ''));
|
||||
var today = new Date().toISOString().split('T')[0];
|
||||
data.forEach(function(t) {
|
||||
// Only show tryouts that haven't ended
|
||||
var tryoutEndDate = t.end_date || t.date;
|
||||
if (tryoutEndDate >= today) {
|
||||
sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>';
|
||||
sel.appendChild(new Option(t.title + ' (' + t.date + ')', t.id));
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -224,70 +237,124 @@ function fetchTeamOptions() {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var sel = document.getElementById('createTeamSelect');
|
||||
sel.innerHTML = '<option value="">-- Select a team --</option>';
|
||||
sel.replaceChildren(new Option('-- Select a team --', ''));
|
||||
data.forEach(function(t) {
|
||||
sel.innerHTML += '<option value="' + t.id + '">' + t.name + '</option>';
|
||||
sel.appendChild(new Option(t.name, t.id));
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe DOM builders
|
||||
// ---------------------------------------------------------------------------
|
||||
// Everything rendered in the event modal originates from a JSON endpoint,
|
||||
// where no HTML escaping applies. Text therefore goes through textContent,
|
||||
// never through innerHTML.
|
||||
|
||||
function makeEl(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) { node.className = className; }
|
||||
if (text !== undefined && text !== null) { node.textContent = text; }
|
||||
return node;
|
||||
}
|
||||
|
||||
function detailItem(label, valueNode, fullWidth) {
|
||||
var item = makeEl('div', 'detail-item' + (fullWidth ? ' full-width' : ''));
|
||||
item.appendChild(makeEl('span', 'detail-label', label));
|
||||
var value = makeEl('span', 'detail-value');
|
||||
value.appendChild(valueNode);
|
||||
item.appendChild(value);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Renders newlines as <br> without letting any other markup through.
|
||||
function multilineNode(text) {
|
||||
var fragment = document.createDocumentFragment();
|
||||
String(text).split('\n').forEach(function(line, index) {
|
||||
if (index > 0) { fragment.appendChild(document.createElement('br')); }
|
||||
fragment.appendChild(document.createTextNode(line));
|
||||
});
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// A team block: its name, plus an optional list of player names.
|
||||
function teamNode(name, players) {
|
||||
var team = makeEl('div', 'match-team');
|
||||
team.appendChild(makeEl('span', 'team-name', name));
|
||||
if (players) {
|
||||
var list = makeEl('ul', 'team-players-list');
|
||||
players.forEach(function(player) {
|
||||
list.appendChild(makeEl('li', null, player));
|
||||
});
|
||||
team.appendChild(list);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
function versusNode(left, right) {
|
||||
var wrap = makeEl('div', 'match-teams');
|
||||
wrap.appendChild(left);
|
||||
wrap.appendChild(makeEl('div', 'match-vs', 'vs'));
|
||||
wrap.appendChild(right);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildTeamsNode(props) {
|
||||
var sides = props.participants.split(' vs ');
|
||||
|
||||
if (props.match_type === 'team_vs_team' && sides.length >= 2) {
|
||||
return versusNode(teamNode(sides[0]), teamNode(sides[1]));
|
||||
}
|
||||
|
||||
if (props.match_type === 'player_vs_player' && sides.length >= 2) {
|
||||
return versusNode(
|
||||
teamNode('Team 1', sides[0].split(', ')),
|
||||
teamNode('Team 2', sides[1].split(', '))
|
||||
);
|
||||
}
|
||||
|
||||
return document.createTextNode(props.participants);
|
||||
}
|
||||
|
||||
function showEventModal(event) {
|
||||
var props = event.extendedProps;
|
||||
var title = event.title;
|
||||
var type = props.type;
|
||||
var date = event.start ? event.start.toDateString() : '';
|
||||
|
||||
var content = '<div class="detail-grid">';
|
||||
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
|
||||
content += '<span class="badge badge-' + (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning') + '">';
|
||||
content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + '</span>';
|
||||
content += '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
|
||||
content += '<span class="badge badge-' + (props.status || 'scheduled') + '">' + (props.status || 'scheduled') + '</span>';
|
||||
content += '</span></div>';
|
||||
|
||||
// Add team separation for matches
|
||||
// Built as DOM nodes rather than concatenated HTML. Every value below —
|
||||
// match title, location, description, and above all the participant list,
|
||||
// which is made of user-chosen usernames — comes from a JSON API and has
|
||||
// never been HTML-escaped. Assigning it to innerHTML executed it.
|
||||
var grid = makeEl('div', 'detail-grid');
|
||||
|
||||
var typeLabel = props.match_type === 'team_vs_team' ? 'Team Match'
|
||||
: (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim');
|
||||
var typeBadgeClass = (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player')
|
||||
? 'success' : 'warning';
|
||||
|
||||
grid.appendChild(detailItem('Type', makeEl('span', 'badge badge-' + typeBadgeClass, typeLabel)));
|
||||
grid.appendChild(detailItem('Title', document.createTextNode(title)));
|
||||
grid.appendChild(detailItem('Date', document.createTextNode(date)));
|
||||
grid.appendChild(detailItem('Location', document.createTextNode(props.location || 'TBD')));
|
||||
|
||||
var status = props.status || 'scheduled';
|
||||
grid.appendChild(detailItem('Status', makeEl('span', 'badge badge-' + status, status)));
|
||||
|
||||
if (type === 'match' && props.participants) {
|
||||
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">';
|
||||
if (props.match_type === 'team_vs_team') {
|
||||
var teams = props.participants.split(' vs ');
|
||||
if (teams.length >= 2) {
|
||||
content += '<div class="match-teams">';
|
||||
content += '<div class="match-team"><span class="team-name">' + teams[0] + '</span></div>';
|
||||
content += '<div class="match-vs">vs</div>';
|
||||
content += '<div class="match-team"><span class="team-name">' + teams[1] + '</span></div>';
|
||||
content += '</div>';
|
||||
} else {
|
||||
content += props.participants;
|
||||
}
|
||||
} else if (props.match_type === 'player_vs_player') {
|
||||
var parts = props.participants.split(' vs ');
|
||||
if (parts.length >= 2) {
|
||||
content += '<div class="match-teams">';
|
||||
content += '<div class="match-team"><span class="team-name">Team 1</span><ul class="team-players-list"><li>' + parts[0].split(', ').join('</li><li>') + '</li></ul></div>';
|
||||
content += '<div class="match-vs">vs</div>';
|
||||
content += '<div class="match-team"><span class="team-name">Team 2</span><ul class="team-players-list"><li>' + parts[1].split(', ').join('</li><li>') + '</li></ul></div>';
|
||||
content += '</div>';
|
||||
} else {
|
||||
content += props.participants;
|
||||
}
|
||||
} else {
|
||||
content += props.participants;
|
||||
}
|
||||
content += '</span></div>';
|
||||
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
|
||||
}
|
||||
|
||||
|
||||
if (props.description) {
|
||||
content += '<div class="detail-item full-width"><span class="detail-label">Description</span><span class="detail-value">' + props.description + '</span></div>';
|
||||
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
||||
}
|
||||
content += '</div>';
|
||||
|
||||
document.getElementById('modalTitle').textContent = 'Match Details';
|
||||
document.getElementById('modalContent').innerHTML = content;
|
||||
|
||||
var modalContent = document.getElementById('modalTitle');
|
||||
modalContent.textContent = 'Match Details';
|
||||
var target = document.getElementById('modalContent');
|
||||
target.textContent = '';
|
||||
target.appendChild(grid);
|
||||
|
||||
// Reset buttons
|
||||
document.getElementById('deleteMatchBtn').style.display = 'none';
|
||||
@@ -378,5 +445,18 @@ function toggleCalendarPresence(matchId, participantId, btn) {
|
||||
function hideEventModal() {
|
||||
document.getElementById('eventModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Behaviours declared in the markup, dispatched by the delegated listener
|
||||
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
|
||||
registerActions({
|
||||
'change-view': function (element) {
|
||||
changeView(element.getAttribute('data-view'));
|
||||
},
|
||||
'hide-create-event': hideCreateEventModal,
|
||||
'go-tryout-match': goToTryoutMatch,
|
||||
'go-team-match': goToTeamMatch,
|
||||
'go-create-tryout': goToCreateTryout,
|
||||
'hide-event-modal': hideEventModal,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Manage Availability - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Manage Availability{% endblock %}
|
||||
{% block title %}{{ _('Manage Availability') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Manage Availability') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> Set Your Weekly Availability</h3>
|
||||
<p class="text-muted small">Select time slots when you're available for One on One sessions</p>
|
||||
<h3><i class="fas fa-clock"></i> {{ _('Set Your Weekly Availability') }}</h3>
|
||||
<p class="text-muted small">{{ _('Select time slots when you\'re available for One on One sessions') }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="availability-grid" id="availability-grid">
|
||||
<p class="text-muted">Loading availability grid...</p>
|
||||
<p class="text-muted">{{ _('Loading availability grid...') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-4">
|
||||
<button type="button" class="btn btn-primary" onclick="saveAvailability()">
|
||||
<i class="fas fa-save"></i> Save Availability
|
||||
<button type="button" class="btn btn-primary" data-action="save-availability">
|
||||
<i class="fas fa-save"></i> {{ _('Save Availability') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
||||
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
// Time slots from 8:00 AM to 10:00 PM (30-minute intervals)
|
||||
const TIME_SLOTS = [];
|
||||
for (let h = 8; h <= 22; h++) {
|
||||
@@ -137,7 +137,7 @@ function renderGrid() {
|
||||
TIME_SLOTS.forEach(slot => {
|
||||
const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time);
|
||||
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
|
||||
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" onclick="toggleSlot(' + dayIndex + ', \'' + slot.time + '\', this)">' + slot.display + '</div>';
|
||||
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" data-action="toggle-slot">' + slot.display + '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
@@ -146,7 +146,9 @@ function renderGrid() {
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleSlot(dayOfWeek, timeStr, element) {
|
||||
function toggleSlot(element) {
|
||||
const dayOfWeek = Number(element.getAttribute('data-day'));
|
||||
const timeStr = element.getAttribute('data-time');
|
||||
if (!selectedSlots[dayOfWeek]) {
|
||||
selectedSlots[dayOfWeek] = [];
|
||||
}
|
||||
@@ -214,7 +216,7 @@ function flash(message, type) {
|
||||
const flashContainer = document.querySelector('.flash-messages');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-' + type + ' alert-dismissible';
|
||||
alert.innerHTML = '<span>' + message + '</span><button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>';
|
||||
alert.innerHTML = '<span>' + message + '</span><button type="button" class="alert-close" data-action="remove-element">×</button>';
|
||||
flashContainer.appendChild(alert);
|
||||
}
|
||||
|
||||
@@ -226,5 +228,14 @@ document.addEventListener('click', function(e) {
|
||||
saveTimeout = setTimeout(saveAvailability, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Behaviours are declared in the markup with data-action / data-change and
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'save-availability': saveAvailability,
|
||||
'clear-availability': clearAllAvailability,
|
||||
'toggle-slot': toggleSlot,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Contracts - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Contracts{% endblock %}
|
||||
{% block title %}{{ _('Contracts') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Contracts') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
{% if current_user.role in ['admin', 'manager', 'coach'] %}
|
||||
<a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload Contract
|
||||
<i class="fas fa-upload"></i> {{ _('Upload Contract') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -14,7 +14,7 @@
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-file-contract"></i> Contract Dropbox</h3>
|
||||
<h3><i class="fas fa-file-contract"></i> {{ _('Contract Dropbox') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if contracts %}
|
||||
@@ -22,12 +22,12 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Team</th>
|
||||
<th>Contract</th>
|
||||
<th>Status</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Actions</th>
|
||||
<th>{{ _('Player') }}</th>
|
||||
<th>{{ _('Team') }}</th>
|
||||
<th>{{ _('Contract') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Uploaded') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -46,17 +46,17 @@
|
||||
<td>{{ contract.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('users.download_contract', contract_id=contract.id) }}" class="btn btn-sm btn-primary" title="Download">
|
||||
<i class="fas fa-download"></i> Download
|
||||
<a href="{{ url_for('users.download_contract', contract_id=contract.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
|
||||
<i class="fas fa-download"></i> {{ _('Download') }}
|
||||
</a>
|
||||
{% if contract.signed_file_path %}
|
||||
<a href="{{ url_for('users.download_signed_contract', contract_id=contract.id) }}" class="btn btn-sm btn-success" title="Download Signed">
|
||||
<i class="fas fa-file-signature"></i> Signed
|
||||
<a href="{{ url_for('users.download_signed_contract', contract_id=contract.id) }}" class="btn btn-sm btn-success" title="{{ _('Download Signed') }}">
|
||||
<i class="fas fa-file-signature"></i> {{ _('Signed') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' and contract.status == 'pending' %}
|
||||
<button class="btn btn-sm btn-warning" onclick="showUploadSignedForm({{ contract.id }})" title="Upload Signed Contract">
|
||||
<i class="fas fa-upload"></i> Return Signed
|
||||
<button class="btn btn-sm btn-warning" data-action="show-upload-signed" data-contract-id="{{ contract.id }}" title="{{ _('Upload Signed Contract') }}">
|
||||
<i class="fas fa-upload"></i> {{ _('Return Signed') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -69,11 +69,11 @@
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-contract"></i>
|
||||
<h4>No contracts found</h4>
|
||||
<h4>{{ _('No contracts found') }}</h4>
|
||||
{% if current_user.role == 'player' %}
|
||||
<p>No contracts have been uploaded for you yet. Contact your coach or manager.</p>
|
||||
<p>{{ _('No contracts have been uploaded for you yet. Contact your coach or manager.') }}</p>
|
||||
{% else %}
|
||||
<p>No contracts have been uploaded yet. Upload a contract using the button above.</p>
|
||||
<p>{{ _('No contracts have been uploaded yet. Upload a contract using the button above.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -83,23 +83,23 @@
|
||||
{% if current_user.role == 'player' %}
|
||||
<!-- Upload Signed Contract Modal -->
|
||||
<div id="uploadSignedModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideUploadSignedForm()"></div>
|
||||
<div class="modal-backdrop" data-action="hide-upload-signed"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Upload Signed Contract</h3>
|
||||
<button class="modal-close" onclick="hideUploadSignedForm()">×</button>
|
||||
<h3>{{ _('Upload Signed Contract') }}</h3>
|
||||
<button class="modal-close" data-action="hide-upload-signed">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="uploadSignedForm" method="POST" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="signed_file">Select Signed Contract File</label>
|
||||
<label for="signed_file">{{ _('Select Signed Contract File') }}</label>
|
||||
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
||||
<small class="form-text text-muted">Accepted formats: PDF, DOC, DOCX, JPG, PNG</small>
|
||||
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="hideUploadSignedForm()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload Signed Contract</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ _('Upload Signed Contract') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function showUploadSignedForm(contractId) {
|
||||
document.getElementById('uploadSignedForm').action = '/users/contracts/' + contractId + '/upload_signed';
|
||||
document.getElementById('uploadSignedModal').classList.remove('hidden');
|
||||
@@ -116,5 +116,15 @@ function showUploadSignedForm(contractId) {
|
||||
function hideUploadSignedForm() {
|
||||
document.getElementById('uploadSignedModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Behaviours are declared in the markup with data-action / data-change and
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'show-upload-signed': function (element) {
|
||||
showUploadSignedForm(element.getAttribute('data-contract-id'));
|
||||
},
|
||||
'hide-upload-signed': hideUploadSignedForm,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Create User - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Create User{% endblock %}
|
||||
{% block title %}{{ _('Create User') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Create User') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Create</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -10,25 +10,25 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="full_name">Full Name</label>
|
||||
<input type="text" id="full_name" name="full_name" placeholder="Enter full name" required>
|
||||
<label for="full_name">{{ _('Full Name') }}</label>
|
||||
<input type="text" id="full_name" name="full_name" placeholder="{{ _('Enter full name') }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Choose username" required>
|
||||
<label for="username">{{ _('Username') }}</label>
|
||||
<input type="text" id="username" name="username" placeholder="{{ _('Choose username') }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" placeholder="Enter email" required>
|
||||
<label for="email">{{ _('Email') }}</label>
|
||||
<input type="email" id="email" name="email" placeholder="{{ _('Enter email') }}" required>
|
||||
</div>
|
||||
<div class="form-group col-3">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="Phone number">
|
||||
<label for="phone">{{ _('Phone') }}</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="{{ _('Phone number') }}">
|
||||
</div>
|
||||
<div class="form-group col-3">
|
||||
<label for="role">Role</label>
|
||||
<label for="role">{{ _('Role') }}</label>
|
||||
<select id="role" name="role" required>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ r | capitalize }}</option>
|
||||
@@ -38,15 +38,15 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Set password" required minlength="6">
|
||||
<label for="password">{{ _('Password') }}</label>
|
||||
<input type="password" id="password" name="password" placeholder="{{ _('Set password') }}" required minlength="6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.list_users') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
<button type="submit" class="btn btn-primary">{{ _('Create User') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Dashboard - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block title %}{{ _('Dashboard') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Dashboard') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Dashboard</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_users }}</h3>
|
||||
<p>Total Users</p>
|
||||
<p>{{ _('Total Users') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_players }}</h3>
|
||||
<p>Players</p>
|
||||
<p>{{ _('Players') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_tryouts }}</h3>
|
||||
<p>Total Tryouts</p>
|
||||
<p>{{ _('Total Tryouts') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_evaluations }}</h3>
|
||||
<p>Evaluations</p>
|
||||
<p>{{ _('Evaluations') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.active_tryouts }}</h3>
|
||||
<p>Active Tryouts</p>
|
||||
<p>{{ _('Active Tryouts') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.upcoming_tryouts }}</h3>
|
||||
<p>Upcoming</p>
|
||||
<p>{{ _('Upcoming') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,12 +66,12 @@
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user-plus"></i> Recent Users</h3>
|
||||
<h3><i class="fas fa-user-plus"></i> {{ _('Recent Users') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Role</th><th>Joined</th></tr>
|
||||
<tr><th>{{ _('Name') }}</th><th>{{ _('Role') }}</th><th>{{ _('Joined') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in stats.recent_users %}
|
||||
@@ -87,12 +87,12 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-alt"></i> Recent Tryouts</h3>
|
||||
<h3><i class="fas fa-calendar-alt"></i> {{ _('Recent Tryouts') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Date</th><th>Status</th></tr>
|
||||
<tr><th>{{ _('Title') }}</th><th>{{ _('Date') }}</th><th>{{ _('Status') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in stats.recent_tryouts %}
|
||||
@@ -111,11 +111,11 @@
|
||||
{% if stats.upcoming_matches %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> Upcoming Matches</h3>
|
||||
<h3><i class="fas fa-futbol"></i> {{ _('Upcoming Matches') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Match</th><th>Tryout</th><th>Date & Time</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>{{ _('Match') }}</th><th>{{ _('Tryout') }}</th><th>{{ _('Date & Time') }}</th><th>{{ _('Status') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for match in stats.upcoming_matches %}
|
||||
<tr>
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_tryouts }}</h3>
|
||||
<p>My Tryouts</p>
|
||||
<p>{{ _('My Tryouts') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.active_tryouts }}</h3>
|
||||
<p>Active</p>
|
||||
<p>{{ _('Active') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -160,19 +160,19 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_evaluations }}</h3>
|
||||
<p>My Evaluations</p>
|
||||
<p>{{ _('My Evaluations') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar"></i> My Tryouts</h3>
|
||||
<h3><i class="fas fa-calendar"></i> {{ _('My Tryouts') }}</h3>
|
||||
<a href="{{ url_for('tryouts.create_tryout') }}" class="btn btn-sm btn-primary">+ New Tryout</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Date</th><th>Status</th><th>Players</th><th>Actions</th></tr>
|
||||
<tr><th>{{ _('Title') }}</th><th>{{ _('Date') }}</th><th>{{ _('Status') }}</th><th>{{ _('Players') }}</th><th>{{ _('Actions') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in stats.my_tryouts %}
|
||||
@@ -195,11 +195,11 @@
|
||||
{% if stats.upcoming_matches %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> Upcoming Matches</h3>
|
||||
<h3><i class="fas fa-futbol"></i> {{ _('Upcoming Matches') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Match</th><th>Tryout</th><th>Date & Time</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>{{ _('Match') }}</th><th>{{ _('Tryout') }}</th><th>{{ _('Date & Time') }}</th><th>{{ _('Status') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for match in stats.upcoming_matches %}
|
||||
<tr>
|
||||
@@ -226,7 +226,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.my_evaluations }}</h3>
|
||||
<p>Evaluations Done</p>
|
||||
<p>{{ _('Evaluations Done') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -235,18 +235,18 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.pending_evaluations }}</h3>
|
||||
<p>Pending</p>
|
||||
<p>{{ _('Pending') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-history"></i> Recent Evaluations</h3>
|
||||
<h3><i class="fas fa-history"></i> {{ _('Recent Evaluations') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Player</th><th>Tryout</th><th>Score</th><th>Date</th></tr>
|
||||
<tr><th>{{ _('Player') }}</th><th>{{ _('Tryout') }}</th><th>{{ _('Score') }}</th><th>{{ _('Date') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in stats.my_recent_evaluations %}
|
||||
@@ -270,19 +270,19 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.my_tryouts }}</h3>
|
||||
<p>My Tryouts</p>
|
||||
<p>{{ _('My Tryouts') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> My Next Matches</h3>
|
||||
<h3><i class="fas fa-futbol"></i> {{ _('My Next Matches') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.next_matches %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Tryout</th><th>Match</th><th>Date & Time</th><th>Opponent</th></tr></thead>
|
||||
<thead><tr><th>{{ _('Tryout') }}</th><th>{{ _('Match') }}</th><th>{{ _('Date & Time') }}</th><th>{{ _('Opponent') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in stats.next_matches %}
|
||||
<tr>
|
||||
@@ -308,17 +308,17 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No upcoming matches yet.</p>
|
||||
<p class="text-muted">{{ _('No upcoming matches yet.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> My Registrations</h3>
|
||||
<h3><i class="fas fa-clipboard-list"></i> {{ _('My Registrations') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Tryout</th><th>Date</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>{{ _('Tryout') }}</th><th>{{ _('Date') }}</th><th>{{ _('Status') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in stats.my_registrations %}
|
||||
<tr>
|
||||
@@ -341,7 +341,7 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_players }}</h3>
|
||||
<p>Total Players</p>
|
||||
<p>{{ _('Total Players') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -350,17 +350,17 @@
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_evaluations }}</h3>
|
||||
<p>Total Evaluations</p>
|
||||
<p>{{ _('Total Evaluations') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-trophy"></i> Top Rated Players</h3>
|
||||
<h3><i class="fas fa-trophy"></i> {{ _('Top Rated Players') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>#</th><th>Player</th><th>Avg Score</th></tr></thead>
|
||||
<thead><tr><th>#</th><th>{{ _('Player') }}</th><th>{{ _('Avg Score') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for player, score in stats.top_players %}
|
||||
<tr>
|
||||
@@ -370,7 +370,7 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not stats.top_players %}
|
||||
<tr><td colspan="3" class="text-center">No evaluations yet.</td></tr>
|
||||
<tr><td colspan="3" class="text-center">{{ _('No evaluations yet.') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -378,4 +378,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Edit Profile - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Edit Profile{% endblock %}
|
||||
{% block title %}{{ _('Edit Profile') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Edit Profile') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Edit</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -10,31 +10,31 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="username">Username</label>
|
||||
<label for="username">{{ _('Username') }}</label>
|
||||
<input type="text" id="username" name="username" value="{{ user.username }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="full_name">Full Name</label>
|
||||
<label for="full_name">{{ _('Full Name') }}</label>
|
||||
<input type="text" id="full_name" name="full_name" value="{{ user.full_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="email">Email</label>
|
||||
<label for="email">{{ _('Email') }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="phone">Phone</label>
|
||||
<label for="phone">{{ _('Phone') }}</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ user.phone or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> E-Sports Profile</h4>
|
||||
<p class="text-muted small">Update your competitive gaming profile for tryouts.</p>
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> {{ _('E-Sports Profile') }}</h4>
|
||||
<p class="text-muted small">{{ _('Update your competitive gaming profile for tryouts.') }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label><i class="fas fa-headset"></i> Games You Play</label>
|
||||
<label><i class="fas fa-headset"></i> {{ _('Games You Play') }}</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for game in esport_games %}
|
||||
{% set games_list = user.get_games_list() %}
|
||||
@@ -44,14 +44,14 @@
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small class="form-text text-muted">Select all games you're signing in for.</small>
|
||||
<small class="form-text text-muted">{{ _('Select all games you\'re signing in for.') }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Gamertag inputs - will be shown when game is selected -->
|
||||
<div id="gamertag-section" style="display: none;">
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-chart-line"></i> Gamertags for TRN</h4>
|
||||
<p class="text-muted small">Enter your gamertag for each selected game to link to your Tracker Network profile.</p>
|
||||
<h4 class="section-title"><i class="fas fa-chart-line"></i> {{ _('Gamertags for TRN') }}</h4>
|
||||
<p class="text-muted small">{{ _('Enter your gamertag for each selected game to link to your Tracker Network profile.') }}</p>
|
||||
|
||||
{% for game in esport_games %}
|
||||
{% set gamertag_data = user_gamertags.get(game) %}
|
||||
@@ -59,14 +59,14 @@
|
||||
<h5>{{ game }}</h5>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="gamertag_{{ game }}">Gamertag</label>
|
||||
<label for="gamertag_{{ game }}">{{ _('Gamertag') }}</label>
|
||||
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}" value="{{ gamertag_data.gamertag if gamertag_data else '' }}" placeholder="Your {{ game }} gamertag">
|
||||
</div>
|
||||
{% if game_platforms.get(game) %}
|
||||
<div class="form-group col-6">
|
||||
<label for="platform_{{ game }}">Platform</label>
|
||||
<label for="platform_{{ game }}">{{ _('Platform') }}</label>
|
||||
<select id="platform_{{ game }}" name="platform_{{ game }}">
|
||||
<option value="">Select Platform</option>
|
||||
<option value="">{{ _('Select Platform') }}</option>
|
||||
{% for platform in game_platforms[game] %}
|
||||
<option value="{{ platform }}" {% if gamertag_data and gamertag_data.platform == platform %}selected{% endif %}>{{ platform }}</option>
|
||||
{% endfor %}
|
||||
@@ -79,40 +79,39 @@
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> Discord Username</label>
|
||||
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="e.g. Name#1234">
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>(for DMs)</small></label>
|
||||
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="Numeric ID (e.g. 123456789012345678)">
|
||||
<small class="text-muted">Enable Developer Mode in Discord → Right-click profile → Copy ID</small>
|
||||
<div class="form-group col-12">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> {{ _('Discord Username') }}</label>
|
||||
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="{{ _('e.g. Name#1234') }}">
|
||||
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-secondary mt-2">
|
||||
<i class="fab fa-discord"></i>
|
||||
{% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> League OS Connection</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="League OS profile link or ID">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> {{ _('League OS Connection') }}</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="{{ _('League OS profile link or ID') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-lock"></i> Change Password</h4>
|
||||
<p class="text-muted small">Leave blank to keep your current password.</p>
|
||||
<h4 class="section-title"><i class="fas fa-lock"></i> {{ _('Change Password') }}</h4>
|
||||
<p class="text-muted small">{{ _('Leave blank to keep your current password.') }}</p>
|
||||
<div class="form-group">
|
||||
<label for="password">New Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter new password">
|
||||
<label for="password">{{ _('New Password') }}</label>
|
||||
<input type="password" id="password" name="password" placeholder="{{ _('Enter new password') }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.profile') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<button type="submit" class="btn btn-primary">{{ _('Save Changes') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
var GAME_PLATFORMS = {{ game_platforms|tojson }};
|
||||
|
||||
// Show/hide gamertag inputs when games are checked
|
||||
@@ -148,4 +147,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleGamertagInputs();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Edit {{ user.username }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Edit User{% endblock %}
|
||||
{% block page_title %}{{ _('Edit User') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Edit</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -10,21 +10,21 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="full_name">Full Name</label>
|
||||
<label for="full_name">{{ _('Full Name') }}</label>
|
||||
<input type="text" id="full_name" name="full_name" value="{{ user.username }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="email">Email</label>
|
||||
<label for="email">{{ _('Email') }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="phone">Phone</label>
|
||||
<label for="phone">{{ _('Phone') }}</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ user.phone or '' }}">
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="role">Role</label>
|
||||
<label for="role">{{ _('Role') }}</label>
|
||||
<select id="role" name="role" required>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {% if user.role == r %}selected{% endif %}>{{ r | capitalize }}</option>
|
||||
@@ -40,10 +40,10 @@
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> E-Sports Profile</h4>
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> {{ _('E-Sports Profile') }}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Games</label>
|
||||
<label>{{ _('Games') }}</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for game in esport_games %}
|
||||
{% set games_list = user.get_games_list() %}
|
||||
@@ -58,8 +58,8 @@
|
||||
<!-- Gamertag inputs - will be shown when game is selected -->
|
||||
<div id="gamertag-section" style="display: none;">
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-chart-line"></i> Gamertags for TRN</h4>
|
||||
<p class="text-muted small">Enter gamertag for each selected game to link to Tracker Network.</p>
|
||||
<h4 class="section-title"><i class="fas fa-chart-line"></i> {{ _('Gamertags for TRN') }}</h4>
|
||||
<p class="text-muted small">{{ _('Enter gamertag for each selected game to link to Tracker Network.') }}</p>
|
||||
|
||||
{% for game in esport_games %}
|
||||
{% set gamertag_data = user_gamertags.get(game) %}
|
||||
@@ -67,14 +67,14 @@
|
||||
<h5>{{ game }}</h5>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="gamertag_{{ game }}">Gamertag</label>
|
||||
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}" value="{{ gamertag_data.gamertag if gamertag_data else '' }}" placeholder="Gamertag">
|
||||
<label for="gamertag_{{ game }}">{{ _('Gamertag') }}</label>
|
||||
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}" value="{{ gamertag_data.gamertag if gamertag_data else '' }}" placeholder="{{ _('Gamertag') }}">
|
||||
</div>
|
||||
{% if game_platforms.get(game) %}
|
||||
<div class="form-group col-6">
|
||||
<label for="platform_{{ game }}">Platform</label>
|
||||
<label for="platform_{{ game }}">{{ _('Platform') }}</label>
|
||||
<select id="platform_{{ game }}" name="platform_{{ game }}">
|
||||
<option value="">Select Platform</option>
|
||||
<option value="">{{ _('Select Platform') }}</option>
|
||||
{% for platform in game_platforms[game] %}
|
||||
<option value="{{ platform }}" {% if gamertag_data and gamertag_data.platform == platform %}selected{% endif %}>{{ platform }}</option>
|
||||
{% endfor %}
|
||||
@@ -88,33 +88,33 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> Discord Username</label>
|
||||
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="e.g. Name#1234">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> {{ _('Discord Username') }}</label>
|
||||
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="{{ _('e.g. Name#1234') }}">
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>(for DMs)</small></label>
|
||||
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="Numeric ID (e.g. 123456789012345678)">
|
||||
<small class="text-muted">Enable Developer Mode in Discord → Right-click profile → Copy ID</small>
|
||||
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>{{ _('(for DMs)') }}</small></label>
|
||||
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="{{ _('Numeric ID (e.g. 123456789012345678)') }}">
|
||||
<small class="text-muted">{{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }}</small>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> League OS Connection</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="League OS profile link or ID">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> {{ _('League OS Connection') }}</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="{{ _('League OS profile link or ID') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">New Password <small>(leave blank to keep current)</small></label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter new password">
|
||||
<label for="password">New Password <small>{{ _('(leave blank to keep current)') }}</small></label>
|
||||
<input type="password" id="password" name="password" placeholder="{{ _('Enter new password') }}">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.list_users') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Update User</button>
|
||||
<button type="submit" class="btn btn-primary">{{ _('Update User') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
// Show/hide gamertag inputs when games are checked
|
||||
function toggleGamertagInputs() {
|
||||
var selectedGames = [];
|
||||
@@ -148,4 +148,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleGamertagInputs();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> Player Evaluation</h3>
|
||||
<h3><i class="fas fa-clipboard-list"></i> {{ _('Player Evaluation') }}</h3>
|
||||
<span class="badge badge-info">{{ tryout.title }} - {{ tryout.game }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
{% if existing_eval %}
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> You have already evaluated this player. Your previous scores are shown below.
|
||||
<i class="fas fa-info-circle"></i> {{ _('You have already evaluated this player. Your previous scores are shown below.') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -30,23 +30,23 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="mecanics_score">Mecanics (1-10)</label>
|
||||
<label for="mecanics_score">{{ _('Mecanics (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="mecanics_score" name="mecanics_score" min="1" max="10" value="{{ existing_eval.mecanics_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="mecanics_score" name="mecanics_score" min="1" max="10" value="{{ existing_eval.mecanics_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.mecanics_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="cohesion_score">Cohesion (1-10)</label>
|
||||
<label for="cohesion_score">{{ _('Cohesion (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="cohesion_score" name="cohesion_score" min="1" max="10" value="{{ existing_eval.cohesion_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="cohesion_score" name="cohesion_score" min="1" max="10" value="{{ existing_eval.cohesion_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.cohesion_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="communication_score">Communication (1-10)</label>
|
||||
<label for="communication_score">{{ _('Communication (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="communication_score" name="communication_score" min="1" max="10" value="{{ existing_eval.communication_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="communication_score" name="communication_score" min="1" max="10" value="{{ existing_eval.communication_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.communication_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,23 +54,23 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="gamesense_score">Gamesense (1-10)</label>
|
||||
<label for="gamesense_score">{{ _('Gamesense (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="gamesense_score" name="gamesense_score" min="1" max="10" value="{{ existing_eval.gamesense_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="gamesense_score" name="gamesense_score" min="1" max="10" value="{{ existing_eval.gamesense_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.gamesense_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="versatility_score">Versatility (1-10)</label>
|
||||
<label for="versatility_score">{{ _('Versatility (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="versatility_score" name="versatility_score" min="1" max="10" value="{{ existing_eval.versatility_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="versatility_score" name="versatility_score" min="1" max="10" value="{{ existing_eval.versatility_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.versatility_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="discipline_score">Discipline (1-10)</label>
|
||||
<label for="discipline_score">{{ _('Discipline (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="discipline_score" name="discipline_score" min="1" max="10" value="{{ existing_eval.discipline_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="discipline_score" name="discipline_score" min="1" max="10" value="{{ existing_eval.discipline_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.discipline_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,23 +78,23 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="analysis_score">Analysis (1-10)</label>
|
||||
<label for="analysis_score">{{ _('Analysis (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="analysis_score" name="analysis_score" min="1" max="10" value="{{ existing_eval.analysis_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="analysis_score" name="analysis_score" min="1" max="10" value="{{ existing_eval.analysis_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.analysis_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="sport_ethics_score">Sport Ethics (1-10)</label>
|
||||
<label for="sport_ethics_score">{{ _('Sport Ethics (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="sport_ethics_score" name="sport_ethics_score" min="1" max="10" value="{{ existing_eval.sport_ethics_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="sport_ethics_score" name="sport_ethics_score" min="1" max="10" value="{{ existing_eval.sport_ethics_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.sport_ethics_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="mental_score">Mental (1-10)</label>
|
||||
<label for="mental_score">{{ _('Mental (1-10)') }}</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="mental_score" name="mental_score" min="1" max="10" value="{{ existing_eval.mental_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<input type="range" id="mental_score" name="mental_score" min="1" max="10" value="{{ existing_eval.mental_score or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_eval.mental_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,23 +103,23 @@
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
<div class="form-row">
|
||||
<div class="form-group col-12">
|
||||
<label for="position_recommendation">Recommended Position</label>
|
||||
<label for="position_recommendation">{{ _('Recommended Position') }}</label>
|
||||
{% if positions %}
|
||||
<select id="position_recommendation" name="position_recommendation" class="form-select">
|
||||
<option value="">-- Select Position --</option>
|
||||
<option value="">{{ _('-- Select Position --') }}</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}" {% if existing_eval.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" id="position_recommendation" name="position_recommendation" value="{{ existing_eval.position_recommendation or '' }}" placeholder="Enter position (optional)">
|
||||
<input type="text" id="position_recommendation" name="position_recommendation" value="{{ existing_eval.position_recommendation or '' }}" placeholder="{{ _('Enter position (optional)') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="comments">Comments</label>
|
||||
<textarea id="comments" name="comments" rows="4" placeholder="Enter your evaluation notes...">{{ existing_eval.comments or '' }}</textarea>
|
||||
<label for="comments">{{ _('Comments') }}</label>
|
||||
<textarea id="comments" name="comments" rows="4" placeholder="{{ _('Enter your evaluation notes...') }}">{{ existing_eval.comments or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
@@ -127,8 +127,8 @@
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {% if existing_eval %}Update Evaluation{% else %}Submit Evaluation{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('users.notes_dashboard') }}" class="btn btn-outline" title="Add note for this player">
|
||||
<i class="fas fa-sticky-note"></i> Add Note
|
||||
<a href="{{ url_for('users.notes_dashboard') }}" class="btn btn-outline" title="{{ _('Add note for this player') }}">
|
||||
<i class="fas fa-sticky-note"></i> {{ _('Add Note') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -144,18 +144,18 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Evaluator</th>
|
||||
<th>Mecanics</th>
|
||||
<th>Cohesion</th>
|
||||
<th>Communication</th>
|
||||
<th>Gamesense</th>
|
||||
<th>Versatility</th>
|
||||
<th>Discipline</th>
|
||||
<th>Analysis</th>
|
||||
<th>Sport Ethics</th>
|
||||
<th>Mental</th>
|
||||
<th>Overall</th>
|
||||
<th>Position</th>
|
||||
<th>{{ _('Evaluator') }}</th>
|
||||
<th>{{ _('Mecanics') }}</th>
|
||||
<th>{{ _('Cohesion') }}</th>
|
||||
<th>{{ _('Communication') }}</th>
|
||||
<th>{{ _('Gamesense') }}</th>
|
||||
<th>{{ _('Versatility') }}</th>
|
||||
<th>{{ _('Discipline') }}</th>
|
||||
<th>{{ _('Analysis') }}</th>
|
||||
<th>{{ _('Sport Ethics') }}</th>
|
||||
<th>{{ _('Mental') }}</th>
|
||||
<th>{{ _('Overall') }}</th>
|
||||
<th>{{ _('Position') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user