Author SHA1 Message Date
cedrick2711 f468217745 progression 50% pour evolution des joueurs
CI - Security, Lint & Tests / validate (push) Failing after 1m13s
2026-09-05 11:30:13 -04:00
cedrick2711 cf551867c9 changement d'un traduction pour la légende des disponibilité qui est ambigu
CI - Security, Lint & Tests / validate (push) Failing after 1m12s
2026-09-01 14:55:57 -04:00
cedrick2711 b43c5b1734 meme commit que avant
CI - Security, Lint & Tests / validate (push) Failing after 58s
2026-09-01 14:17:47 -04:00
cedrick2711 85eca9edd4 re-régler problème de disponibilité
Creation d'un fichier pour setup postgres en local pour dev
2026-09-01 14:17:44 -04:00
cedrick2711 c7b321a636 petit changement pour 429 error handler (temp fix)
CI - Security, Lint & Tests / validate (push) Failing after 59s
2026-08-27 09:25:24 -04:00
cedrick2711 90c00ad29b régler problème URL lol
CI - Security, Lint & Tests / validate (push) Failing after 1m2s
2026-08-26 22:46:24 -04:00
cedrick2711 bfb25f4d16 ajout d'un toggle pour permission d'upload des documents dans admin pannel
CI - Security, Lint & Tests / validate (push) Failing after 1m3s
ajout de compteur d'heure disponible
ajout du calendrier de dispo dans les profils publics
2026-08-26 22:33:24 -04:00
cedrick2711 820cccb25e changement vers google drive storage pour les contrats.
CI - Security, Lint & Tests / validate (push) Failing after 1m15s
Tentative d'obtenir un token de connection échoue
2026-08-26 20:52:40 -04:00
cedrick2711 38defc53a5 debut changement vers google drive
CI - Security, Lint & Tests / validate (push) Failing after 1m14s
2026-08-25 19:01:28 -04:00
cedrick2711 d18979a3e9 ajout de tests
CI - Security, Lint & Tests / validate (push) Failing after 56s
2026-08-25 15:53:46 -04:00
cedrick2711 1549fbaef3 fix merge conflict
CI - Security, Lint & Tests / validate (push) Failing after 54s
2026-08-25 14:03:24 -04:00
cedrick2711 c72ed9b0b1 Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-25 14:03:07 -04:00
cedrick2711 6232b77094 ajout d'un paneau admin 2026-08-25 13:29:22 -04:00
cedrick2711 a616c79663 regler probleme avec la creation d'equipe et 1 manager par tryout changer pour plusieurs
CI - Security, Lint & Tests / validate (push) Failing after 58s
2026-08-19 21:16:25 -04:00
cedrick2711 48ca62cdd0 Merge branch 'audit/securite-maintenabilite-standards' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards
CI - Security, Lint & Tests / validate (push) Failing after 54s
2026-08-19 19:11:38 -04:00
cedrick2711 1bef716a12 Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards 2026-08-19 19:09:55 -04:00
cedrick2711 a0e31d1a2f ajout dune page batch evaluation 2026-08-17 18:16:22 -04:00
GGThed e15b3c1293 fix(audit): centraliser l'horloge UTC
CI - Security, Lint & Tests / validate (push) Failing after 16m22s
2026-08-17 15:21:08 -04:00
GGThed d7a8907953 fix(audit): moderniser les accès ORM
CI - Security, Lint & Tests / validate (push) Failing after 19m37s
2026-08-17 15:02:29 -04:00
GGThed 105a72700f fix(audit): fermer les frontieres restantes 2026-08-17 14:34:06 -04:00
GGThed f84cb4e3b6 fix(audit): durcir les validations de securite 2026-08-17 13:51:37 -04:00
GGThed 06508cc7d6 Merge immortal/main into audit branch 2026-08-17 00:28:06 -04:00
GGThed 9647003c3f fix(auth): garder l identite Discord du cote verifie 2026-08-16 23:36:30 -04:00
cedrick2711 2b943c5c22 régler problème avec le bouton pour sauvegarder les dispo 2026-08-14 12:03:39 -04:00
cedrick2711 9fc4ba0b98 Merge branch 'main' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-08 10:18:51 -04:00
cedrick2711 53e390672e changer ou modifier les dispo 2026-08-05 21:37:15 -04:00
131 changed files with 9404 additions and 1708 deletions
+45
View File
@@ -0,0 +1,45 @@
name: CI - Security, Lint & Tests
on:
push:
pull_request:
workflow_dispatch:
# This workflow validates branches only. It has no deployment step and no
# write permission, so an audit-branch push cannot alter main or production.
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
cache: pip
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Audit declared dependencies
run: pip-audit -r requirements.txt
- name: Lint and check formatting
run: |
ruff check .
ruff format --check .
- name: Run tests with coverage gate
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
- name: Run repository security checks
env:
SECRET_KEY: audit-ci-key-not-for-production-1234567890
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http
+2 -1
View File
@@ -2,7 +2,7 @@ name: CI - Security & Lint
on:
push:
branches: [main, master]
branches: [main, master, 'audit/**']
pull_request:
branches: [main, master]
workflow_dispatch: # Allow manual triggers
@@ -94,6 +94,7 @@ jobs:
- name: Run security scan
env:
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http
+3
View File
@@ -98,6 +98,9 @@ coverage.xml
# =============================================================================
# Base SQLite locale (une base de démo a déjà été committée par le passé,
# voir l'historique du commit `4fd27ba`).
# Données PostgreSQL locales — gérées par app/local_postgres.py
instance/pgdata/
instance/pg.log
instance/
*.db
*.sqlite
+5 -2
View File
@@ -101,8 +101,11 @@ Ce qui **n'est pas** fait, pour que personne ne s'y fie :
- **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'identité Discord** transite encore par un champ caché du formulaire
d'inscription : elle n'est pas prouvée par le passage OAuth2.
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
snowflake et l'application refuse les nouvelles collisions. Les doublons
historiques doivent être relevés puis corrigés avant la contrainte
`UNIQUE` (`schema_report.py --check-discord-identities`).
`docs/security-checklist.md` détaille la liste avant mise en production.
+40
View File
@@ -0,0 +1,40 @@
"""Add Drive signed upload test."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
drive_test = (
b"\n def test_signed_upload_with_google_drive(\n"
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
b" ):\n"
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
b" monkeypatch.setattr(\n"
b" 'app.storage.upload_google_drive_file',\n"
b" lambda **_kwargs: 'drive-signed-456',\n"
b" )\n"
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status == 'signed'\n"
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
)
# Add before 'class TestTheHelper:'
marker = b'\nclass TestTheHelper:'
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + drive_test + t[pos:]
p.write_bytes(t)
print('Added Drive signed upload test')
else:
print('Could not find marker')
+48
View File
@@ -0,0 +1,48 @@
"""Add new tests to test_contract_uploads.py."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
marker = b'\n\nclass TestTheHelper:'
test = (
b"\n def test_a_header_only_pdf_is_refused(\n"
b" self, app, client, as_role, make_user, contract_for\n"
b" ):\n"
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status != 'signed'\n"
b" assert contract.signed_file_path is None\n\n"
)
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + test + t[pos:]
# 2. Header-only helper test at end of TestTheHelper
trailing = t.rstrip(b'\n')
idx = t.find(trailing) + len(trailing)
helper = (
b"\n def test_a_header_only_file_is_refused(self, app):\n"
b' """A stream starting with %PDF- but holding non-PDF data is\n'
b" rejected (the header check is not enough).\"\"\"\n"
b" from werkzeug.datastructures import FileStorage\n\n"
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
b" with app.test_request_context('/'):\n"
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
)
t = t[:idx] + helper + t[idx:]
p.write_bytes(t)
print('Added new tests')
+29 -9
View File
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# =============================================================================
# Optional — storage
# Contract storage — Google Drive
# =============================================================================
# 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.
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
# refresh token, and folder ID are secrets/configuration: do not commit them.
# Use `local` only for isolated development and legacy-file maintenance.
DOCUMENT_STORAGE_BACKEND=google_drive
# Target folder in the owner's personal Drive. The application keeps every
# contract directly in this folder and stores only each Drive file ID in the
# database.
GOOGLE_DRIVE_FOLDER_ID=
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
# refresh token here. See docs/deployment.md before enabling this in production.
GOOGLE_DRIVE_CLIENT_ID=
GOOGLE_DRIVE_CLIENT_SECRET=
GOOGLE_DRIVE_REFRESH_TOKEN=
# Local storage is retained only for historical rows and local test runs.
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both
@@ -140,5 +151,14 @@ AUTO_CREATE_TABLES=true
# FORCE_HTTPS=false no redirect to HTTPS
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#
# LOCAL_PG=true start a local PostgreSQL server (requires PostgreSQL
# installed on the system). Uses port 5433,
# data in instance/pgdata/, database team_tryouts.
#
# LOCAL_PG_PORT=5433 override the PostgreSQL port
# LOCAL_PG_USER=team_tryouts_db override the PostgreSQL user
# LOCAL_PG_DATABASE=team_tryouts_db override the database name
#
#
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
# development session cannot accidentally inherit a production binding.
+4
View File
@@ -411,6 +411,8 @@ def create_app(config=None):
from app.routes.teams import teams_bp
from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp
from app.routes.admin import admin_bp
from app.routes.objectives import objectives_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -420,6 +422,8 @@ def create_app(config=None):
app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(objectives_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
+13 -8
View File
@@ -65,6 +65,8 @@ from discord.ext import commands
from dotenv import load_dotenv
from sqlalchemy.exc import SQLAlchemyError
from app.time_utils import utc_now_naive
load_dotenv()
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
@@ -683,9 +685,10 @@ class TeamTryoutsBot(commands.Bot):
reference_id: ID of the MatchParticipant or TryoutRegistration record.
"""
# Look up the DB user to get their Discord user ID
from app.extensions import db
from app.models import User as DBUser
db_user = DBUser.query.get(user_id)
db_user = db.session.get(DBUser, user_id)
if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification")
return None
@@ -754,7 +757,7 @@ class TeamTryoutsBot(commands.Bot):
from app.models import OneOnOneRequest
try:
request = OneOnOneRequest.query.get(request_id)
request = db.session.get(OneOnOneRequest, request_id)
if not request:
# The row is gone; no reaction on this message can ever mean
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
@@ -779,7 +782,7 @@ class TeamTryoutsBot(commands.Bot):
coach_obj = request.coach
request.status = 'approved'
request.responded_at = datetime.utcnow()
request.responded_at = utc_now_naive()
except SQLAlchemyError:
db.session.rollback()
logger.exception('Could not read One on One request %s to approve it', request_id)
@@ -825,7 +828,7 @@ class TeamTryoutsBot(commands.Bot):
from app.models import OneOnOneRequest
try:
request = OneOnOneRequest.query.get(request_id)
request = db.session.get(OneOnOneRequest, request_id)
if not request:
logger.info(
'One on One request %s no longer exists; its pending message was dropped.',
@@ -874,7 +877,7 @@ class TeamTryoutsBot(commands.Bot):
try:
request.status = 'rejected'
request.responded_at = datetime.utcnow()
request.responded_at = utc_now_naive()
if refusal_note:
request.coach_rejection_message = refusal_note
except SQLAlchemyError:
@@ -918,12 +921,13 @@ class TeamTryoutsBot(commands.Bot):
Returns:
tuple: (row, player_id) — either may be None.
"""
from app.extensions import db
from app.models import MatchParticipant, TryoutRegistration
if event_type == 'match':
row = MatchParticipant.query.get(reference_id)
row = db.session.get(MatchParticipant, reference_id)
elif event_type == 'tryout':
row = TryoutRegistration.query.get(reference_id)
row = db.session.get(TryoutRegistration, reference_id)
else:
row = None
return row, getattr(row, 'player_id', None)
@@ -937,11 +941,12 @@ class TeamTryoutsBot(commands.Bot):
attendance handlers did not (OPS-009) — same message shape, same
threat, one of them checked. The asymmetry was the bug.
"""
from app.extensions import db
from app.models import User
if not player_id:
return False
owner = User.query.get(player_id)
owner = db.session.get(User, player_id)
return bool(owner and owner.discord_user_id == str(reacting_user.id))
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
+1 -1
View File
@@ -22,7 +22,7 @@ babel = Babel()
# 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"])
limiter = Limiter(key_func=get_remote_address, default_limits=["1000 per day", "200 per hour"])
def hash_password(password):
+30
View File
@@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass
if not payload.get(name):
payload.pop(name, None)
return payload
def form_gamertags(selected_games):
"""Validate the dynamic gamertag fields for the selected games.
These fields cannot be declared statically on the account schemas: their
names contain the game label. They are still untrusted form data, so
every caller uses this shared boundary before adding or changing rows.
"""
from marshmallow import ValidationError
from app.models import GAME_PLATFORMS
from app.validators import GamertagSchema
validated = {}
for game in selected_games:
raw_gamertag = request.form.get(f'gamertag_{game}', '')
raw_platform = (
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
)
if not raw_gamertag.strip():
continue
try:
validated[game] = GamertagSchema().load(
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
)
except ValidationError as err:
messages = [message for values in err.messages.values() for message in values]
raise ValidationError({f'gamertag_{game}': messages}) from err
return validated
+144
View File
@@ -0,0 +1,144 @@
"""Google Drive storage for contract PDFs.
The application uses an OAuth refresh token for the owner's personal Google
account. Tokens and client secrets come only from environment variables; the
database stores opaque file IDs, never a credential or a shareable Drive URL.
"""
import io
import os
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
class GoogleDriveStorageError(RuntimeError):
"""A configuration or API failure while storing a contract in Drive."""
def _setting(name):
value = os.getenv(name)
if not value:
raise GoogleDriveStorageError(
f'{name} must be configured for Google Drive document storage.'
)
return value
def _drive_service():
"""Build an authorized Drive client from the owner's refresh token."""
try:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
except ImportError as exc:
raise GoogleDriveStorageError(
'Google Drive dependencies are not installed. Install requirements.txt again.'
) from exc
credentials = Credentials(
token=None,
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
token_uri='https://oauth2.googleapis.com/token',
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
scopes=[GOOGLE_DRIVE_SCOPE],
)
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
def list_folder_files():
"""Return (file_id, name) tuples for every file in the configured folder.
Used by the backup script to enumerate Drive-stored contracts. Returns
only files directly in the folder (not nested inside sub-folders).
Returns:
list[tuple[str, str]]: (file_id, name) for each file found.
"""
try:
folder_id = _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
page_token = None
results = []
while True:
response = (
_drive_service()
.files()
.list(
q=f"'{folder_id}' in parents and trashed=false",
fields='nextPageToken, files(id, name)',
pageToken=page_token,
pageSize=1000,
)
.execute()
)
for f in response.get('files', []):
results.append((f['id'], f['name']))
page_token = response.get('nextPageToken')
if not page_token:
break
return results
except GoogleDriveStorageError:
raise
except Exception as exc:
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
def upload_file(*, stream, filename, mimetype):
"""Upload a PDF to the configured owner folder and return its Drive ID."""
try:
from googleapiclient.http import MediaIoBaseUpload
stream.seek(0)
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
response = (
_drive_service()
.files()
.create(
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
media_body=media,
fields='id',
)
.execute()
)
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
file_id = response.get('id')
if not file_id:
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
return file_id
def download_file(file_id):
"""Download a Drive file into memory for Flask's authenticated response."""
try:
from googleapiclient.http import MediaIoBaseDownload
destination = io.BytesIO()
downloader = MediaIoBaseDownload(
destination,
_drive_service().files().get_media(fileId=file_id),
)
complete = False
while not complete:
_status, complete = downloader.next_chunk()
return destination.getvalue()
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
def delete_file(file_id):
"""Permanently delete a Drive document when its contract record is deleted."""
try:
_drive_service().files().delete(fileId=file_id).execute()
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+437
View File
@@ -0,0 +1,437 @@
"""Manage a local PostgreSQL instance for development.
Searches for PostgreSQL binaries on the system, initialises a data directory
under ``instance/pgdata/``, starts the server on port 5433, creates the
``team_tryouts`` database, and registers cleanup on exit.
Usage from run.py::
if os.getenv('LOCAL_PG', '').lower() == 'true':
from app.local_postgres import start as start_local_pg
os.environ['DATABASE_URL'] = start_local_pg()
Environment used (all optional):
LOCAL_PG_PORT Port to bind (default 5433)
LOCAL_PG_USER Database superuser name (default team_tryouts_dev)
LOCAL_PG_DATABASE Database name (default team_tryouts)
"""
from __future__ import annotations
import atexit
import contextlib
import logging
import os
import pathlib
import shutil
import signal
import subprocess
import sys
import tempfile
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DATADIR = pathlib.Path(__file__).resolve().parent.parent / 'instance' / 'pgdata'
PG_LOG = DATADIR.parent / 'pg.log'
PORT = int(os.getenv('LOCAL_PG_PORT', '5433'))
_USER = os.getenv('LOCAL_PG_USER', 'team_tryouts_dev')
_DATABASE = os.getenv('LOCAL_PG_DATABASE', 'team_tryouts')
DATABASE_URL = f'postgresql://{_USER}@127.0.0.1:{PORT}/{_DATABASE}'
_started = False
#: Name of the system Windows PostgreSQL service we temporarily stopped.
_stopped_system_service_name: str | None = None
logger = logging.getLogger('app.local_postgres')
# ---------------------------------------------------------------------------
# Binary discovery
# ---------------------------------------------------------------------------
_WIN_PG_ROOTS = [
pathlib.Path(os.environ.get('PROGRAMFILES', 'C:\\Program Files')) / 'PostgreSQL',
pathlib.Path(os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)')) / 'PostgreSQL',
]
def _find_binary(name: str) -> pathlib.Path | None:
"""Locate a PostgreSQL binary (pg_ctl, initdb, psql)."""
found = shutil.which(name)
if found:
return pathlib.Path(found)
if sys.platform == 'win32':
for root in _WIN_PG_ROOTS:
if not root.is_dir():
continue
versions = sorted(
(p for p in root.iterdir() if p.is_dir()),
key=lambda p: p.name,
reverse=True,
)
for version_dir in versions:
candidate = version_dir / 'bin' / f'{name}.exe'
if candidate.is_file():
return candidate
return None
# ---------------------------------------------------------------------------
# Windows service management
# ---------------------------------------------------------------------------
#: Path to the Windows service controller.
_SC_EXE = pathlib.Path(os.environ.get('SYSTEMROOT', 'C:\\Windows')) / 'System32' / 'sc.exe'
def _detect_system_service() -> str | None:
"""Find a running Windows PostgreSQL service installed by the EDB installer.
Returns the service name (e.g. ``postgresql-x64-18``) or None.
"""
if sys.platform != 'win32' or not _SC_EXE.is_file():
return None
for root in _WIN_PG_ROOTS:
if not root.is_dir():
continue
versions = sorted(
(p for p in root.iterdir() if p.is_dir()),
key=lambda p: p.name,
reverse=True,
)
for version_dir in versions:
srv = f'postgresql-x64-{version_dir.name}'
try:
result = subprocess.run(
[str(_SC_EXE), 'query', srv],
capture_output=True,
text=True,
)
if result.returncode == 0 and 'RUNNING' in result.stdout:
return srv
except (FileNotFoundError, OSError):
continue
return None
def _stop_system_service(name: str):
"""Stop a Windows system service."""
print(f'[INFO] Stopping system PostgreSQL service "{name}" ...')
try:
subprocess.run(
[str(_SC_EXE), 'stop', name],
capture_output=True,
text=True,
check=False,
)
except (FileNotFoundError, OSError) as exc:
print(f'[WARNING] Could not stop service "{name}": {exc}')
def _start_system_service(name: str):
"""Start a Windows system service."""
print(f'[INFO] Restarting system PostgreSQL service "{name}" ...')
try:
subprocess.run(
[str(_SC_EXE), 'start', name],
capture_output=True,
text=True,
check=False,
)
except (FileNotFoundError, OSError) as exc:
print(f'[WARNING] Could not restart service "{name}": {exc}')
# ---------------------------------------------------------------------------
# Data directory management
# ---------------------------------------------------------------------------
_TRUST_HBA = """\
# Local development configuration — trust all local connections.
# Installed by app/local_postgres.py.
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
"""
def _init_cluster():
"""Create the data directory if it does not exist yet."""
if DATADIR.is_dir() and (DATADIR / 'PG_VERSION').is_file():
return
initdb = _find_binary('initdb')
if not initdb:
print(
'[ERROR] PostgreSQL not found. Install it from:',
'https://www.postgresql.org/download/windows/',
file=sys.stderr,
)
sys.exit(1)
print(f'[INFO] Initialising PostgreSQL data directory at {DATADIR} ...')
DATADIR.mkdir(parents=True, exist_ok=True)
# Write a non-empty password to a temporary file. PostgreSQL >= 18
# rejects an empty password (NUL device), and we immediately overwrite
# pg_hba.conf with trust auth anyway, so the value is irrelevant.
pw_file = tempfile.NamedTemporaryFile( # noqa: SIM115 — delete=False needed for initdb --pwfile
mode='w',
delete=False,
suffix='.pw',
prefix='pg_',
)
pw_file.write('dev\n')
pw_file.close()
try:
subprocess.run(
[
str(initdb),
'--locale=C',
'--encoding=UTF8',
'--data-checksums',
'--username',
_USER,
'--pwfile',
pw_file.name,
'-D',
str(DATADIR),
],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as exc:
print(f'[ERROR] initdb failed (exit {exc.returncode}):')
if exc.stderr:
print(exc.stderr)
if exc.stdout:
print(exc.stdout)
sys.exit(1)
finally:
with contextlib.suppress(OSError):
os.unlink(pw_file.name)
_set_postgresql_conf()
hba_path = DATADIR / 'pg_hba.conf'
hba_path.write_text(_TRUST_HBA, encoding='utf-8')
print('[INFO] pg_hba.conf set to trust local connections.')
def _set_postgresql_conf():
"""Write postgresql.conf to match our development settings."""
conf_path = DATADIR / 'postgresql.conf'
if not conf_path.is_file():
return
lines = conf_path.read_text(encoding='utf-8').splitlines()
lines.append('\n# ---- local_postgres.py overrides ----')
lines.append(f'port = {PORT}')
lines.append("listen_addresses = '127.0.0.1'")
lines.append("unix_socket_directories = ''")
lines.append('autovacuum = off') # avoid DLL conflicts with system PostgreSQL service
lines.append('logging_collector = off')
lines.append('log_destination = stderr')
conf_path.write_text('\n'.join(lines), encoding='utf-8')
# ---------------------------------------------------------------------------
# Server start / stop
# ---------------------------------------------------------------------------
def _pg_ctl(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
"""Run ``pg_ctl`` with the data directory pre-filled."""
pg_ctl = _find_binary('pg_ctl')
if not pg_ctl:
raise RuntimeError('pg_ctl binary not found')
return subprocess.run(
[str(pg_ctl), *args, '-D', str(DATADIR), '-w'],
capture_output=True,
text=True,
cwd=cwd or str(DATADIR),
)
def _server_running() -> bool:
"""Return True if the server is already up on our port."""
try:
result = _pg_ctl(['status'])
return result.returncode == 0
except (FileNotFoundError, RuntimeError):
return False
def _create_database():
"""Create the database if it does not exist."""
psql = _find_binary('psql')
if not psql:
raise RuntimeError('psql binary not found')
check = subprocess.run(
[
str(psql),
'-h',
'127.0.0.1',
'-p',
str(PORT),
'-U',
_USER,
'-d',
'postgres',
'-tAc',
f"SELECT 1 FROM pg_database WHERE datname = '{_DATABASE}'",
],
capture_output=True,
text=True,
env={**os.environ, 'PGPASSWORD': ''},
)
if check.stdout.strip() == '1':
print(f'[INFO] Database "{_DATABASE}" already exists.')
return
subprocess.run(
[
str(psql),
'-h',
'127.0.0.1',
'-p',
str(PORT),
'-U',
_USER,
'-d',
'postgres',
'-c',
f'CREATE DATABASE "{_DATABASE}"',
],
check=True,
capture_output=True,
text=True,
env={**os.environ, 'PGPASSWORD': ''},
)
print(f'[INFO] Created database "{_DATABASE}".')
def _stop(*_args):
"""Stop the PostgreSQL server with a fast shutdown.
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
normal exit, and terminal close.
"""
global _started
global _stopped_system_service_name
if not _started:
return
_started = False
try:
result = _pg_ctl(['stop', '-m', 'fast'])
if result.returncode != 0:
logger.info('pg_ctl stop returned %d: %s', result.returncode, result.stderr.strip())
else:
logger.info('Local PostgreSQL stopped.')
except Exception as exc: # noqa: BLE001 — shutdown must never raise
logger.warning('Failed to stop local PostgreSQL: %s', exc)
# Restart the system service if we stopped it earlier.
if _stopped_system_service_name:
srv = _stopped_system_service_name
_stopped_system_service_name = None
_start_system_service(srv)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def start() -> str:
"""Start a local PostgreSQL server for development.
Returns the ``DATABASE_URL`` the application should use.
The caller is expected to set this on ``os.environ['DATABASE_URL']``
before invoking ``create_app()``.
This function is idempotent: if the server is already running and the
database exists, it returns immediately.
"""
global _started
global _stopped_system_service_name
# On Windows, temporarily stop the system PostgreSQL service so its child
# processes (autovacuum, etc.) do not collide with our local instance.
if _stopped_system_service_name is None: # only do this once per process
srv = _detect_system_service()
if srv:
_stop_system_service(srv)
_stopped_system_service_name = srv
# 1. Initialise if needed.
_init_cluster()
# 2. Start if not already running.
if not _server_running():
print(f'[INFO] Starting PostgreSQL on port {PORT} ...')
PG_LOG.parent.mkdir(parents=True, exist_ok=True)
pg_ctl = _find_binary('pg_ctl')
try:
subprocess.run(
[
str(pg_ctl),
'start',
'-D',
str(DATADIR),
'-l',
str(PG_LOG),
'-w', # wait for readiness
],
check=True,
)
except subprocess.CalledProcessError:
print('[ERROR] pg_ctl start failed.')
# Also show the server log — it often has the real error.
try:
log_text = PG_LOG.read_text(encoding='utf-8')
if log_text.strip():
print(f'Server log ({PG_LOG}):')
print(log_text)
except Exception: # noqa: BLE001
pass
sys.exit(1)
print(f'[INFO] PostgreSQL is up (pid in {PG_LOG}).')
else:
print(f'[INFO] PostgreSQL is already running on port {PORT}.')
# 3. Create the application database (idempotent).
_create_database()
# 4. Register cleanup (only in the *parent* process, not the reloader
# worker — the worker registers its own and tears the database down
# behind any sibling process that is still connected).
#
# The Werkzeug reloader sets WERKZEUG_RUN_MAIN=true in the worker
# process and leaves it unset (or false) in the watcher process that
# acts as the parent. We want the cleanup on the *actual process
# lifecycle*, i.e. when the watcher exits, not every time the
# reloader restarts the worker.
if not _started:
_started = True
if os.environ.get('WERKZEUG_RUN_MAIN', '').lower() != 'true':
atexit.register(_stop)
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _stop)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
return DATABASE_URL
+24
View File
@@ -20,6 +20,12 @@ from app.models._constants import (
GAME_PLATFORMS,
PLATFORM_CODES,
TRN_URLS,
EVALUATION_CRITERIA,
OBJECTIVE_TYPES,
OBJECTIVE_STATUSES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_CHECK_FREQUENCIES,
DEFAULT_OBJECTIVE_TEMPLATES,
)
# =========================================================================
@@ -93,3 +99,21 @@ from app.models.contract import Contract
from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
# =========================================================================
# Layer 12: player evolution objectives
# =========================================================================
from app.models.objective import Objective # noqa: F401 — new table for create_all()
# =========================================================================
# Layer 13: player evolution entries (KPI measurements)
# =========================================================================
from app.models.objective_entry import ObjectiveEntry # noqa: F401
# =========================================================================
# Layer 14: objective comments (coach feedback, no existing-schema change)
# =========================================================================
from app.models.objective_comment import ObjectiveComment # noqa: F401
+10
View File
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
tryout_managers = db.Table(
'tryout_managers',
db.Column(
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
),
db.Column(
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
+90 -2
View File
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
# Ordered list of (field_name, human_label) pairs for the player evaluation
# score criteria. Kept in a single place so the evaluation forms, batch
# evaluation page, and any future reporting all stay in sync.
EVALUATION_CRITERIA = [
('mecanics_score', 'Mecanics'),
('cohesion_score', 'Cohesion'),
('communication_score', 'Communication'),
('gamesense_score', 'Gamesense'),
('versatility_score', 'Versatility'),
('discipline_score', 'Discipline'),
('analysis_score', 'Analysis'),
('sport_ethics_score', 'Sport Ethics'),
('mental_score', 'Mental'),
]
ESPORT_GAMES = [
'Valorant',
'League of Legends',
@@ -54,11 +69,84 @@ PLATFORM_CODES = {
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
# Objective-related constants for the Player Evolution planning feature.
# Ordered lists of (value, human_label) pairs for form fields and display.
OBJECTIVE_TYPES = [
('individual', 'Individuel'),
('team', 'Équipe'),
]
OBJECTIVE_STATUSES = [
('active', 'Actif'),
('pending', 'En attente'),
('completed', 'Terminé'),
('overdue', 'En retard'),
]
OBJECTIVE_PRIORITIES = [
('low', 'Faible'),
('medium', 'Moyenne'),
('high', 'Élevée'),
]
OBJECTIVE_CHECK_FREQUENCIES = [
('weekly', 'Hebdomadaire'),
('biweekly', 'Bi-hebdomadaire'),
('monthly', 'Mensuelle'),
]
# Default templates seeded on first run.
# (priority, title, description, kpi_metric, kpi_target, kpi_unit, type, category)
DEFAULT_OBJECTIVE_TEMPLATES = [
# Quantitative
('medium', 'Améliorer le taux de headshot (HS)',
'Augmenter le pourcentage de têtes sur les kills totaux.',
'Taux de headshot', 45.0, '%', 'individual', 'Mécaniques'),
('medium', 'Augmenter le K/D ratio',
'Améliorer le ratio kills/morts sur les matchs compétitifs.',
'K/D Ratio', 1.2, '', 'individual', 'Performance'),
('medium', 'Atteindre un KDA cible',
'Maintenir un ratio kills/deaths/assists au-dessus du seuil.',
'KDA', 3.0, '', 'individual', 'Performance'),
('medium', 'Améliorer la précision',
'Augmenter le pourcentage de tirs touchés par partie.',
'Précision', 40.0, '%', 'individual', 'Mécaniques'),
('medium', 'Augmenter les CS par minute',
'Atteindre une moyenne de last-hits par minute en jeu.',
'CS/min', 8.0, '', 'individual', 'Farm'),
('medium', 'Réduire les morts par match',
'Diminuer le nombre de décès moyens par partie compétitive.',
'Morts/match', 5.0, '', 'individual', 'Survie'),
('medium', 'Augmenter les dégâts par round',
'Améliorer le damage output moyen par round joué.',
'Dégâts/round', 150.0, '', 'individual', 'Performance'),
('medium', 'Améliorer le taux de victoire',
'Augmenter le pourcentage de parties gagnées sur la saison.',
'Winrate', 55.0, '%', 'team', 'Compétitif'),
# Qualitative
('medium', 'Améliorer le positionnement',
'Travailler le placement sur la carte pour réduire les angles exposés.',
None, None, None, 'individual', 'Positionnement'),
('medium', 'Améliorer la communication',
'Être plus vocal et clair dans les callouts en équipe.',
None, None, None, 'individual', 'Communication'),
('medium', 'Développer la conscience de carte',
'Anticiper les rotations adverses et mieux lire la minimap.',
None, None, None, 'individual', 'Game Sense'),
('medium', 'Améliorer la prise de décision',
'Réduire les décisions risquées et améliorer le choix des engagements.',
None, None, None, 'individual', 'Decision'),
('medium', 'Renforcer la cohésion d\'équipe',
'Participer activement aux stratégies collectives et aux drills.',
None, None, None, 'team', 'Équipe'),
]
TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
'Apex Legends': 'https://tracker.gg/apex/profile/{platform_code}/{username}',
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
+56
View File
@@ -0,0 +1,56 @@
"""Global application settings stored as key-value pairs in the database."""
from app.extensions import db
class AppSettings(db.Model):
"""Key-value store for global application settings.
Stores toggles and configuration that admins control through the
admin panel, such as whether tryouts are open, season state, etc.
"""
__tablename__ = 'app_settings'
key = db.Column(db.String(100), primary_key=True)
value = db.Column(db.Text, nullable=True)
# ------------------------------------------------------------------
# Well-known keys (documented here for discoverability)
# ------------------------------------------------------------------
# tryouts_open – "true" / "false" (default: "true")
# document_uploads_enabled – "true" / "false" (default: "true")
# season_active – "true" / "false" (default: "false")
# season_name – e.g. "Fall 2026"
# season_start – ISO date string
# season_end – ISO date string
@staticmethod
def get(key, default=None):
"""Return the value for *key*, or *default* if not set."""
row = db.session.get(AppSettings, key)
return row.value if row is not None else default
@staticmethod
def set(key, value):
"""Upsert a setting."""
row = db.session.get(AppSettings, key)
if row is None:
row = AppSettings(key=key, value=str(value) if value is not None else None)
db.session.add(row)
else:
row.value = str(value) if value is not None else None
db.session.commit()
@staticmethod
def get_bool(key, default=False):
"""Return a boolean setting."""
val = AppSettings.get(key)
if val is None:
return default
return val.lower() in ('true', '1', 'yes', 'on')
@staticmethod
def set_bool(key, value):
"""Store a boolean setting as 'true' / 'false'."""
AppSettings.set(key, 'true' if value else 'false')
+32
View File
@@ -0,0 +1,32 @@
"""Audit log for tracking sensitive administrative actions."""
from app.extensions import db
from app.time_utils import utc_now_naive
class AuditLog(db.Model):
"""Append-only log of critical admin actions for accountability."""
__tablename__ = 'audit_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
action = db.Column(db.String(100), nullable=False)
details = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=utc_now_naive)
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
@staticmethod
def record(user_id, action, details=None, ip_address=None):
"""Create a new audit log entry."""
entry = AuditLog(
user_id=user_id,
action=action,
details=details,
ip_address=ip_address,
)
db.session.add(entry)
db.session.commit()
return entry
+3 -4
View File
@@ -1,8 +1,7 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class BaseAvailability(db.Model):
@@ -13,5 +12,5 @@ class BaseAvailability(db.Model):
day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
+21
View File
@@ -0,0 +1,21 @@
"""Records of database backups created through the admin panel."""
from app.extensions import db
from app.time_utils import utc_now_naive
class BackupRecord(db.Model):
"""Metadata for a database backup stored on disk."""
__tablename__ = 'backup_records'
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
size_bytes = db.Column(db.BigInteger, nullable=True)
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
notes = db.Column(db.Text, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
+3 -4
View File
@@ -1,8 +1,7 @@
"""Contract documents for players to sign."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class Contract(db.Model):
@@ -23,7 +22,7 @@ class Contract(db.Model):
status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
@@ -57,7 +56,7 @@ class Contract(db.Model):
if isinstance(user, Admin):
return True
if isinstance(user, Manager):
player = User.query.get(self.player_id)
player = db.session.get(User, self.player_id)
if player and player.get_org_teams():
return True
if isinstance(user, Coach):
+3 -4
View File
@@ -1,8 +1,7 @@
"""Player evaluation record."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class Evaluation(db.Model):
@@ -25,8 +24,8 @@ class Evaluation(db.Model):
overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match models (Match + TeamMatch)."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class BaseMatch(db.Model):
@@ -18,4 +17,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=utc_now_naive)
+84
View File
@@ -0,0 +1,84 @@
"""Player evolution objective — individual or team KPI-based goal."""
from app.extensions import db
from app.time_utils import utc_now_naive
class Objective(db.Model):
"""Player evolution objective — a KPI-driven plan for individual players
or teams, created and tracked by coaching staff.
Covers the full lifecycle: draft (pending), active, completed, overdue.
Supports arbitrary KPI metrics, optional tags, and verifiable progress.
"""
__tablename__ = 'objectives'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Category
type = db.Column(
db.String(20),
nullable=False,
default='individual',
)
status = db.Column(
db.String(20),
nullable=False,
default='active',
)
priority = db.Column(
db.String(20),
nullable=False,
default='medium',
)
category = db.Column(db.String(50), nullable=True)
is_template = db.Column(db.Boolean, default=False, nullable=False)
# Assignment (individual player / org team)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
# KPI fields
kpi_metric = db.Column(db.String(120), nullable=True)
kpi_target = db.Column(db.Numeric(10, 2), nullable=True)
kpi_unit = db.Column(db.String(20), nullable=True)
kpi_precision = db.Column(db.Integer, nullable=True)
# Timeline
start_date = db.Column(db.Date, nullable=True)
due_date = db.Column(db.Date, nullable=False)
check_frequency = db.Column(db.String(30), nullable=True)
# Progress tracking (0–100)
progress = db.Column(db.Integer, default=0, nullable=False)
# Misc
tags = db.Column(db.String(255), nullable=True)
# Who created it
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
player = db.relationship(
'User',
foreign_keys=[player_id],
backref=db.backref('objectives', lazy='dynamic'),
)
team = db.relationship(
'OrgTeam',
foreign_keys=[team_id],
backref=db.backref('objectives', lazy='dynamic'),
)
creator = db.relationship(
'User',
foreign_keys=[created_by],
)
+35
View File
@@ -0,0 +1,35 @@
"""Coach comments on a specific objective."""
from app.extensions import db
from app.time_utils import utc_now_naive
class ObjectiveComment(db.Model):
"""A coach's comment on a player-evolution objective.
This is a separate table from PersonalNote — no existing schema
is altered. The objective-form page also *reads* PersonalNote and
TeamNote for context, but never writes to those tables.
"""
__tablename__ = 'objective_comments'
id = db.Column(db.Integer, primary_key=True)
objective_id = db.Column(
db.Integer,
db.ForeignKey('objectives.id'),
nullable=False,
)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
objective = db.relationship(
'Objective',
backref=db.backref('comments', lazy='dynamic', order_by='ObjectiveComment.created_at'),
)
coach = db.relationship('User', foreign_keys=[coach_id])
+39
View File
@@ -0,0 +1,39 @@
"""Player evolution — KPI measurement entries for objectives."""
from app.extensions import db
from app.time_utils import utc_now_naive
class ObjectiveEntry(db.Model):
"""A single KPI measurement recorded for an objective.
Each entry records a value at a point in time so the evolution
graph can plot historical data and compute a trend line.
"""
__tablename__ = 'objective_entries'
id = db.Column(db.Integer, primary_key=True)
objective_id = db.Column(
db.Integer,
db.ForeignKey('objectives.id'),
nullable=False,
)
value = db.Column(db.Numeric(10, 2), nullable=True)
recorded_at = db.Column(db.DateTime, nullable=False, default=utc_now_naive)
note = db.Column(db.Text, nullable=True)
# Who recorded it
recorded_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
objective = db.relationship(
'Objective',
backref=db.backref('entries', lazy='dynamic', order_by='ObjectiveEntry.recorded_at'),
)
recorder = db.relationship('User', foreign_keys=[recorded_by])
+2 -3
View File
@@ -1,8 +1,7 @@
"""Request from player to coach for a One on One session."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class OneOnOneRequest(db.Model):
@@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model):
end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True)
+2 -3
View File
@@ -1,9 +1,8 @@
"""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 app.time_utils import utc_now_naive
class OrgTeam(db.Model):
@@ -13,7 +12,7 @@ class OrgTeam(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Many-to-many junction: player to org-team."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TeamPlayer(db.Model):
@@ -14,7 +13,7 @@ class TeamPlayer(db.Model):
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utc_now_naive)
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match participant models."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class BaseParticipant(db.Model):
@@ -11,4 +10,4 @@ class BaseParticipant(db.Model):
__abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utc_now_naive)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Personal notes from coach to individual player."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class PersonalNote(db.Model):
@@ -13,8 +12,8 @@ class PersonalNote(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class Team(db.Model):
@@ -13,7 +12,7 @@ class Team(db.Model):
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Link between a player and a tryout-specific team."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TeamMember(db.Model):
@@ -13,6 +12,6 @@ class TeamMember(db.Model):
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utc_now_naive)
player = db.relationship('User', overlaps="player_ref,team_assignments")
+3 -4
View File
@@ -1,8 +1,7 @@
"""Team improvement notes from coach."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TeamNote(db.Model):
@@ -13,8 +12,8 @@ class TeamNote(db.Model):
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
+20 -6
View File
@@ -1,9 +1,8 @@
"""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 app.models._associations import tryout_coaches, tryout_managers
from app.time_utils import utc_now_naive
class Tryout(db.Model):
@@ -21,16 +20,17 @@ class Tryout(db.Model):
max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
@@ -48,3 +48,17 @@ class Tryout(db.Model):
if self.end_date is not None:
return self.end_date < today
return self.date < today
def get_managers(self):
"""Managers attached to this tryout, both legacy and many-to-many."""
manager_list = list(self.managers)
if not manager_list and self.manager:
return [self.manager]
return manager_list
def get_coaches(self):
"""Coaches attached to this tryout, both legacy and many-to-many."""
coach_list = list(self.coaches)
if not coach_list and self.coach:
return [self.coach]
return coach_list
+2 -3
View File
@@ -1,8 +1,7 @@
"""Registration linking a player to a tryout."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TryoutRegistration(db.Model):
@@ -12,6 +11,6 @@ class TryoutRegistration(db.Model):
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
registered_at = db.Column(db.DateTime, default=utc_now_naive)
status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True)
+12 -2
View File
@@ -21,7 +21,11 @@ class Manager(User):
return True
def can_manage_this_tryout(self, tryout):
return tryout.created_by == self.id or tryout.manager_id == self.id
return (
tryout.created_by == self.id
or tryout.manager_id == self.id
or any(m.id == self.id for m in tryout.managers)
)
def can_manage_this_org_team(self, org_team):
return True
@@ -32,7 +36,13 @@ class Manager(User):
from app.models.tryout.tryout import Tryout
return (
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
Tryout.query.filter(
or_(
Tryout.created_by == self.id,
Tryout.manager_id == self.id,
Tryout.managers.any(id=self.id),
)
)
.order_by(Tryout.date)
.all()
)
+2 -3
View File
@@ -1,10 +1,9 @@
"""Base User model — shared fields and polymorphic configuration."""
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db
from app.time_utils import utc_now_naive
class User(UserMixin, db.Model):
@@ -25,7 +24,7 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
+124
View File
@@ -0,0 +1,124 @@
"""Objective analytics — linear regression for KPI prediction.
Given a set of ObjectiveEntry measurements, computes a simple linear
regression (y = mx + b, where x = days since first entry) and answers:
- **predicted_achievement_date**: the date when the trend line crosses
the KPI target value (or None if the trend is flat or negative).
- **confidence_score**: a heuristic 0.0–1.0 based on sample count and
the R² coefficient of determination.
- **predicted_value_at_date(date)**: the value the trend predicts at a
given future date.
"""
from __future__ import annotations
import datetime
def _to_days(timestamps: list[datetime.datetime]) -> list[float]:
"""Convert datetimes to float days since the first entry."""
if not timestamps:
return []
t0 = timestamps[0]
return [(t - t0).total_seconds() / 86400.0 for t in timestamps]
def _r_squared(xs, ys, slope, intercept):
"""Coefficient of determination (R²)."""
if len(xs) < 2:
return 0.0
mean_y = sum(ys) / len(ys)
ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys, strict=True))
ss_tot = sum((y - mean_y) ** 2 for y in ys)
if ss_tot == 0:
return 1.0
return 1.0 - ss_res / ss_tot
def compute_trend(
timestamps: list[datetime.datetime],
values: list[float],
target: float | None,
) -> dict:
"""Compute a linear regression trend and prediction.
Args:
timestamps: Entry dates.
values: Entry values (must be same length as timestamps).
target: KPI target value, or None for qualitative objectives.
Returns:
dict with keys:
slope, intercept, r_squared, confidence,
predicted_achievement_days, predicted_achievement_date,
predicted_value_at_due, target_value
"""
n = len(timestamps)
if n < 2 or not values:
return {
'slope': 0.0,
'intercept': float(values[0]) if values else 0.0,
'r_squared': 0.0,
'confidence': 0.0,
'predicted_achievement_days': None,
'predicted_achievement_date': None,
'predicted_value_at_due': None,
'target_value': target,
}
xs = _to_days(timestamps)
ys = [float(v) for v in values]
n_f = float(n)
sum_x = sum(xs)
sum_y = sum(ys)
sum_xy = sum(x * y for x, y in zip(xs, ys, strict=True))
sum_xx = sum(x * x for x in xs)
denom = n_f * sum_xx - sum_x * sum_x
if abs(denom) < 1e-12:
return {
'slope': 0.0,
'intercept': sum_y / n_f,
'r_squared': 0.0,
'confidence': 0.0,
'predicted_achievement_days': None,
'predicted_achievement_date': None,
'predicted_value_at_due': None,
'target_value': target,
}
slope = (n_f * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n_f
r2 = _r_squared(xs, ys, slope, intercept)
# Confidence heuristic: combination of R² and sample count
sample_confidence = min(1.0, n / 10.0) # 10+ entries = full confidence
confidence = sample_confidence * r2
# Days from first entry to target (if slope > 0 and target > intercept)
predicted_achievement_days = None
predicted_achievement_date = None
if target is not None and slope > 1e-12:
days_to_target = (target - intercept) / slope
if days_to_target >= 0:
predicted_achievement_days = days_to_target
predicted_achievement_date = timestamps[0] + datetime.timedelta(
days=days_to_target,
)
return {
'slope': slope,
'intercept': intercept,
'r_squared': round(r2, 4),
'confidence': round(confidence, 4),
'predicted_achievement_days': (
round(predicted_achievement_days, 1) if predicted_achievement_days is not None else None
),
'predicted_achievement_date': (
predicted_achievement_date.isoformat() if predicted_achievement_date else None
),
'predicted_value_at_due': None,
'target_value': target,
}
+70
View File
@@ -343,3 +343,73 @@ def coach_manages_tryout(coach, tryout):
if tryout.target_org_team_id:
return tryout.target_org_team_id in coach_org_team_ids(coach)
return False
# ---------------------------------------------------------------------------
# Player Evolution — Objectives
# ---------------------------------------------------------------------------
def can_view_objectives(user):
"""Whether the user may view the objectives list.
Everyone except regular players without any context can browse objectives.
Players see only their own.
"""
return True
def can_manage_objectives(user):
"""Whether the user may create, edit, or delete objectives.
Presidents, managers, and coaches may manage objectives. Players are
read-only.
"""
from app.models import Admin, Coach, Manager
return isinstance(user, (Admin, Manager, Coach))
def can_manage_objective(user, objective):
"""Whether the user may edit or delete a specific objective.
Presidents and managers may manage any objective. A coach may manage
objectives assigned to their players or their teams.
"""
from app.models import Admin, Coach, Manager
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
if objective.player_id and objective.player_id in coach_player_ids(user):
return True
if objective.team_id and objective.team_id in coach_org_team_ids(user):
return True
return False
def can_view_objective(user, objective):
"""Whether the user may view this objective's detail page.
Presidents, managers, and coaches may view any they manage.
A player may view only their own assigned objectives.
Everyone else may view objectives they created.
"""
from app.models import Admin, Coach, Manager, Player
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
return can_manage_objective(user, objective)
if isinstance(user, Player):
return objective.player_id == user.id
return objective.created_by == user.id
def can_add_objective_entry(user, objective):
"""Whether the user may record a KPI measurement on this objective.
The same as can_view_objective — the assigned player may record
their own measurements on their own objectives.
"""
return can_view_objective(user, objective)
+396
View File
@@ -0,0 +1,396 @@
"""Admin panel routes for system-level management.
Provides backup/restore, tryout open/close toggling, season lifecycle
management, team wiping, and audit log viewing. All routes are restricted
to administrators.
"""
import os
from datetime import datetime
from flask import (
Blueprint, render_template, redirect, url_for, flash, request,
send_file,
)
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
)
from app.supporting_scripts.backup import (
BACKUP_DIR, BackupError, backup_database, backup_documents,
create_backup_dir, parse_database_url, verify_backup,
)
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def require_admin():
"""Return True if current user is an Admin, else flash and redirect."""
if isinstance(current_user, Admin):
return True
flash('Only the president can access the admin panel.', 'danger')
return False
def _client_ip():
"""Best-effort client IP for audit logging."""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
return request.remote_addr
def _log(action, details=None):
"""Record an audit log entry for the current user."""
AuditLog.record(
user_id=current_user.id,
action=action,
details=details,
ip_address=_client_ip(),
)
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
@admin_bp.route('')
@login_required
def dashboard():
"""Render the admin panel dashboard."""
if not require_admin():
return redirect(url_for('main.dashboard'))
stats = {
'total_users': User.query.count(),
'total_players': User.query.filter_by(role='player').count(),
'total_org_teams': OrgTeam.query.count(),
'active_tryouts': Tryout.query.filter_by(status='in_progress').count(),
'upcoming_tryouts': Tryout.query.filter_by(status='upcoming').count(),
}
settings = {
'tryouts_open': AppSettings.get_bool('tryouts_open', default=True),
'document_uploads_enabled': AppSettings.get_bool('document_uploads_enabled', default=True),
'season_active': AppSettings.get_bool('season_active', default=False),
'season_name': AppSettings.get('season_name') or 'Not set',
'season_start': AppSettings.get('season_start') or 'Not set',
'season_end': AppSettings.get('season_end') or 'Not set',
}
backups = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(20).all()
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()
return render_template(
'pages/admin.html',
stats=stats,
settings=settings,
backups=backups,
audit_logs=audit_logs,
)
# ---------------------------------------------------------------------------
# Backups
# ---------------------------------------------------------------------------
def _create_backup_record(backup_type='manual', notes=None):
"""Run a database backup and return a BackupRecord, or raise BackupError."""
create_backup_dir()
conn = parse_database_url(os.getenv('DATABASE_URL'))
file_path = backup_database(conn)
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
filename = os.path.basename(file_path)
record = BackupRecord(
filename=filename,
file_path=file_path,
size_bytes=size,
backup_type=backup_type,
notes=notes,
created_by_id=current_user.id,
)
db.session.add(record)
db.session.commit()
return record
@admin_bp.route('/backup/create', methods=['POST'])
@login_required
def create_backup():
"""Create a manual database backup."""
if not require_admin():
return redirect(url_for('main.dashboard'))
notes = request.form.get('notes', '').strip() or None
try:
record = _create_backup_record(backup_type='manual', notes=notes)
except BackupError as e:
flash(f'Backup failed: {e}', 'danger')
_log('backup_failed', f'Error: {e}')
return redirect(url_for('admin.dashboard'))
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
flash(f'Backup created successfully: {record.filename}', 'success')
return redirect(url_for('admin.dashboard'))
@admin_bp.route('/backup/<int:backup_id>/download')
@login_required
def download_backup(backup_id):
"""Download a backup file."""
if not require_admin():
return redirect(url_for('main.dashboard'))
record = BackupRecord.query.get_or_404(backup_id)
if not os.path.exists(record.file_path):
flash('Backup file is missing from disk.', 'danger')
return redirect(url_for('admin.dashboard'))
_log('backup_downloaded', f'File: {record.filename}')
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
@admin_bp.route('/backup/<int:backup_id>/delete', methods=['POST'])
@login_required
def delete_backup(backup_id):
"""Delete a backup file and its record."""
if not require_admin():
return redirect(url_for('main.dashboard'))
record = BackupRecord.query.get_or_404(backup_id)
if os.path.exists(record.file_path):
try:
os.remove(record.file_path)
except OSError as e:
flash(f'Could not remove backup file: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
_log('backup_deleted', f'File: {record.filename}')
db.session.delete(record)
db.session.commit()
flash(f'Backup {record.filename} deleted.', 'success')
return redirect(url_for('admin.dashboard'))
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
@login_required
def restore_backup(backup_id):
"""Restore a selected backup.
A safety backup of the current state is created first, then the
selected dump is restored via pg_restore.
"""
if not require_admin():
return redirect(url_for('main.dashboard'))
record = BackupRecord.query.get_or_404(backup_id)
if not os.path.exists(record.file_path):
flash('Backup file is missing from disk.', 'danger')
return redirect(url_for('admin.dashboard'))
# Create a safety backup of the current state before restoring.
try:
safety = _create_backup_record(
backup_type='pre_restore',
notes='Automatic safety backup before restoring ' + record.filename,
)
except BackupError as e:
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
# Restore using pg_restore
import subprocess
from app.supporting_scripts.backup import (
PG_RESTORE, dump_environment, parse_database_url,
)
conn = parse_database_url(os.getenv('DATABASE_URL'))
cmd = [
PG_RESTORE,
'--host', conn['host'],
'--port', conn['port'],
'--username', conn['user'],
'--dbname', conn['dbname'],
'--clean', '--if-exists', '--no-owner',
record.file_path,
]
try:
result = subprocess.run(
cmd,
env=dump_environment(conn),
capture_output=True,
text=True,
timeout=900,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or 'pg_restore failed')
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
flash(f'Restore failed: {e}', 'danger')
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
return redirect(url_for('admin.dashboard'))
_log('backup_restored', f'File: {record.filename}')
flash(
f'Backup {record.filename} restored successfully. The database has been rolled back.',
'success',
)
return redirect(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Tryouts open/close toggle
# ---------------------------------------------------------------------------
@admin_bp.route('/toggle-tryouts', methods=['POST'])
@login_required
def toggle_tryouts():
"""Toggle the global tryout open/close state."""
if not require_admin():
return redirect(url_for('main.dashboard'))
current = AppSettings.get_bool('tryouts_open', default=True)
new_value = not current
AppSettings.set_bool('tryouts_open', new_value)
state = 'opened' if new_value else 'closed'
_log('tryouts_toggled', f'Tryouts {state}')
flash(f'Tryouts are now {state}.', 'success')
return redirect(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Season lifecycle
# ---------------------------------------------------------------------------
@admin_bp.route('/season/start', methods=['POST'])
@login_required
def start_season():
"""Begin a new regular season."""
if not require_admin():
return redirect(url_for('main.dashboard'))
if AppSettings.get_bool('season_active', default=False):
flash('A season is already active. End it before starting a new one.', 'danger')
return redirect(url_for('admin.dashboard'))
name = request.form.get('season_name', '').strip()
start_date = request.form.get('season_start', '').strip()
if not start_date:
flash('A season start date is required.', 'danger')
return redirect(url_for('admin.dashboard'))
try:
datetime.strptime(start_date, '%Y-%m-%d')
except ValueError:
flash('Invalid season start date format.', 'danger')
return redirect(url_for('admin.dashboard'))
AppSettings.set('season_name', name or 'Untitled Season')
AppSettings.set('season_start', start_date)
AppSettings.set('season_end', None)
AppSettings.set_bool('season_active', True)
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
return redirect(url_for('admin.dashboard'))
@admin_bp.route('/season/end', methods=['POST'])
@login_required
def end_season():
"""End the current regular season."""
if not require_admin():
return redirect(url_for('main.dashboard'))
if not AppSettings.get_bool('season_active', default=False):
flash('No season is currently active.', 'danger')
return redirect(url_for('admin.dashboard'))
end_date = request.form.get('season_end', '').strip()
if not end_date:
flash('A season end date is required.', 'danger')
return redirect(url_for('admin.dashboard'))
try:
datetime.strptime(end_date, '%Y-%m-%d')
except ValueError:
flash('Invalid season end date format.', 'danger')
return redirect(url_for('admin.dashboard'))
name = AppSettings.get('season_name')
AppSettings.set('season_end', end_date)
AppSettings.set_bool('season_active', False)
_log('season_ended', f'Season: {name}, End: {end_date}')
flash(f'Season "{name}" has ended.', 'success')
return redirect(url_for('admin.dashboard'))
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
@login_required
def toggle_document_uploads():
"""Enable or disable document uploads for non-admin users."""
if not require_admin():
return redirect(url_for('main.dashboard'))
current = AppSettings.get_bool('document_uploads_enabled', default=True)
new_value = not current
AppSettings.set_bool('document_uploads_enabled', new_value)
state = 'enabled' if new_value else 'disabled'
_log('document_uploads_toggled', f'Document uploads {state}')
flash(f'Document uploads are now {state}.', 'success')
return redirect(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Wipe teams for new season
# ---------------------------------------------------------------------------
@admin_bp.route('/teams/wipe', methods=['POST'])
@login_required
def wipe_teams():
"""Wipe team rosters and season matches for a new season.
Players are removed from org teams (TeamPlayer records), regular-season
matches and their participants are deleted. OrgTeam structures, coaches,
and managers are preserved.
"""
if not require_admin():
return redirect(url_for('main.dashboard'))
confirm = request.form.get('confirm', '').strip()
if confirm != 'WIPE':
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
return redirect(url_for('admin.dashboard'))
# Safety backup before destructive operation
try:
_create_backup_record(
backup_type='pre_wipe',
notes='Automatic safety backup before wiping teams',
)
except BackupError as e:
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
roster_count = TeamPlayer.query.count()
match_count = TeamMatch.query.count()
# Delete team match participants first (FK order)
TeamMatchParticipant.query.delete()
TeamMatch.query.delete()
TeamPlayer.query.delete()
db.session.commit()
_log('teams_wiped', f'Removed {roster_count} roster entries and {match_count} season matches')
flash(
f'Teams wiped for the new season. Removed {roster_count} roster entries '
f'and {match_count} regular-season matches.',
'success',
)
return redirect(url_for('admin.dashboard'))
+112 -27
View File
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
import os
import secrets
import time
from datetime import datetime, timedelta
from datetime import timedelta
from urllib.parse import urlencode, urlparse
import requests
@@ -18,13 +18,19 @@ from flask_login import current_user, login_required, login_user, logout_user
from marshmallow import ValidationError
from app.extensions import check_password, db, hash_password, limiter
from app.forms import form_gamertags
from app.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User
from app.validators import LoginSchema, RegisterSchema
from app.time_utils import utc_now_naive
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state'
#: Whether the OAuth result should create a registration draft or relink the
#: signed-in account. Kept server-side and covered by the same signed session
#: as the anti-forgery state.
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
# Failed-attempt tracking. The tally is kept for the audit trail and for the
# cool-off marker below; it no longer refuses a correct password (SEC-018).
@@ -289,7 +295,7 @@ def login():
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
log_auth_event(
'account.throttled',
username=username,
@@ -354,6 +360,15 @@ def register():
form_data = dict(request.form)
form_data['games'] = request.form.getlist('games')
# Once Discord has authenticated the identity, neither its display
# name nor its snowflake is input data anymore. Remove any client
# copies before validation as well as before persistence: otherwise a
# forged, malformed hidden value can still make the verified flow fail.
discord_oauth = session.get('discord_oauth') or {}
if discord_oauth.get('id'):
form_data.pop('discord_username', None)
form_data.pop('discord_user_id', None)
refusal = check_registration_challenge(request.form)
if refusal is not None:
# Logged, because this is the only place abuse of the sign-up
@@ -380,8 +395,25 @@ def register():
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
try:
submitted_gamertags = form_gamertags(selected_games)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return _rerender_registration(form_data)
# The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005).
# A manual registration may still provide a display name, but never a
# Discord snowflake: that identifier is an authentication factor for
# bot reactions and must come from Discord itself.
discord_user_id = discord_oauth.get('id')
if discord_user_id:
discord_user_id = str(discord_user_id)
discord_username = (
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
)
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
@@ -392,6 +424,13 @@ def register():
flash(_('Email already registered.'), 'danger')
return _rerender_registration(form_data)
# The database constraint belongs to DB-002, after production has
# been backed up and deduplicated. Refuse new duplicates now instead
# of leaving the critical impersonation path open until then.
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender_registration(form_data)
hashed_password = hash_password(password)
user = Player(
username=username,
@@ -415,16 +454,14 @@ def register():
# Create UserGamertag records for each selected game
from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
if gamertag_value:
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
)
db.session.add(gamertag)
for game, gamertag_data in submitted_gamertags.items():
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
)
db.session.add(gamertag)
db.session.commit()
# Clear Discord OAuth data from session after successful registration
@@ -451,11 +488,15 @@ def discord_login():
Returns:
Response: Redirect to Discord authorization URL.
"""
purpose = 'profile' if current_user.is_authenticated else 'registration'
session[DISCORD_PURPOSE_KEY] = purpose
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
# raise inside the query builder rather than report a configuration error.
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
flash(_('Discord OAuth2 is not configured.'), 'danger')
return redirect(url_for('auth.register'))
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
@@ -479,16 +520,24 @@ 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', '')
@@ -500,12 +549,12 @@ def discord_callback():
),
'danger',
)
return redirect(url_for('auth.register'))
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'))
return redirect(url_for(return_endpoint))
# Exchange the authorization code for an access token
token_data = {
@@ -529,11 +578,11 @@ def discord_callback():
access_token = token_json.get('access_token')
except requests.RequestException:
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
if not access_token:
flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
auth_headers = {'Authorization': f'Bearer {access_token}'}
@@ -548,7 +597,43 @@ def discord_callback():
user_data = user_response.json()
except requests.RequestException:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
discord_user_id = user_data.get('id')
try:
if not discord_user_id:
raise ValidationError('missing Discord user id')
discord_user_id = str(discord_user_id)
validate_discord_user_id(discord_user_id)
except ValidationError:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
if purpose == 'profile':
# If the session expired while Discord was open, do not turn a profile
# relink into registration state for an anonymous browser.
if not current_user.is_authenticated:
flash(_('Please log in to connect your Discord account.'), 'danger')
return redirect(url_for('auth.login'))
clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != current_user.id,
).first()
if clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return redirect(url_for('users.edit_profile'))
current_user.discord_user_id = discord_user_id
current_user.discord_username = user_data.get('username') or None
db.session.commit()
log_auth_event(
'account.discord_linked',
username=current_user.username,
user_id=current_user.id,
)
flash(_('Discord account connected!'), 'success')
return redirect(url_for('users.edit_profile'))
# Fetch the user's connected gaming accounts
connections = []
@@ -587,7 +672,7 @@ 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,
+131 -15
View File
@@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_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, EVALUATION_CRITERIA,
)
from sqlalchemy import func
from sqlalchemy.orm import aliased
@@ -27,6 +35,14 @@ from app.validators import EvaluationSchema
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def _users_by_id(user_ids):
"""Load a set of users once for aggregate/list views."""
wanted = {user_id for user_id in user_ids if user_id}
if not wanted:
return {}
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
@evaluations_bp.route('')
@login_required
def list_evaluations():
@@ -85,9 +101,10 @@ def list_evaluations():
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in avg_scores)
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
p = players_by_id.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p,
@@ -126,7 +143,7 @@ def evaluate_player(tryout_id, player_id):
flash(_('You do not have permission to evaluate players.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -142,7 +159,7 @@ def evaluate_player(tryout_id, player_id):
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)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -160,8 +177,10 @@ def evaluate_player(tryout_id, player_id):
tryout_id=tryout_id,
player_id=player_id,
).all()
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
evaluators = [
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
{'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e}
for e in all_evaluations
]
return render_template(
@@ -210,21 +229,118 @@ def players_to_evaluate(tryout_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=p.id,
evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
players_by_id = _users_by_id(reg.player_id for reg in registrations)
evaluated_player_ids = {
player_id
for (player_id,) in db.session.query(Evaluation.player_id)
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
.all()
}
players = [
{
'player': player,
'evaluated': player.id in evaluated_player_ids,
'registration': registration,
}
for registration in registrations
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
]
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
@login_required
def batch_evaluate(tryout_id):
"""Evaluate multiple players at once in a tryout.
GET renders a single form listing every selected player with their
evaluation criteria. POST saves (creates or updates) all of them.
"""
if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Resolve selected player ids (query string on GET, hidden fields on POST).
player_ids = []
for raw in request.values.getlist('player_ids'):
try:
pid = int(raw)
except (ValueError, TypeError):
continue
if pid not in player_ids:
player_ids.append(pid)
if not player_ids:
flash('Please select at least one player to evaluate.', 'warning')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
players = []
for pid in player_ids:
player = User.query.get(pid)
if not player or not isinstance(player, Player):
continue
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=pid,
).first() is not None
if not is_registered:
continue
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
).first()
existing_scores = {
field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA
}
players.append({
'player': player,
'existing': existing,
'existing_scores': existing_scores,
})
if not players:
flash('No valid players selected for evaluation.', 'danger')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
if request.method == 'POST':
saved = 0
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/batch_evaluate.html',
tryout=tryout, players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS)
+7 -9
View File
@@ -223,20 +223,18 @@ def dashboard():
elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = (
top_rows = (
db.session.query(
Evaluation.player_id,
User,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.order_by(func.avg(Evaluation.overall_score).desc())
.join(Evaluation, Evaluation.player_id == User.id)
.filter(User.role == 'player')
.group_by(User.id)
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
.limit(5)
.all()
)
stats['top_players'] = []
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)))
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
return render_template('pages/dashboard.html', user=user, stats=stats)
+27 -14
View File
@@ -46,6 +46,25 @@ def match_form_payload():
return form_payload(list_fields=('player_ids',), optional_blank=())
def registered_players(tryout_id):
"""Players registered for one tryout, loaded in a single query.
The create form previously called ``User.query.get`` twice per
registration (once in the filter and once in the result expression),
and the edit form called it once per row. Besides scaling linearly, both
paths could return duplicates while DB-006 is still pending. The join is
bounded and ``distinct`` preserves the form's intended one-option-per-
player contract until the database constraint lands.
"""
return (
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
.filter(TryoutRegistration.tryout_id == tryout_id)
.order_by(User.username)
.distinct()
.all()
)
#: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30
@@ -270,7 +289,7 @@ def api_events():
@login_required
def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False
@@ -359,7 +378,7 @@ def api_events_for_tryout(tryout_id):
@login_required
def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -369,11 +388,7 @@ def create_match(tryout_id):
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 = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout_id)
prefill_date = request.args.get('date', '')
def rerender():
@@ -437,7 +452,7 @@ def create_match(tryout_id):
@login_required
def edit_match(match_id):
"""Edit an existing match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
@@ -449,9 +464,7 @@ def edit_match(match_id):
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 r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout.id)
current_player_ids = [p.player_id for p in match.participants.all()]
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
@@ -569,7 +582,7 @@ def api_manageable_tryouts():
@login_required
def delete_match(match_id):
"""Delete a match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger')
@@ -656,10 +669,10 @@ def api_available_players(date, time):
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
participant = MatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(MatchParticipant, participant_id)
if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
+661
View File
@@ -0,0 +1,661 @@
"""Player Evolution — Objective management routes.
Allows coaches and staff to create, update, delete, and list
KPI-driven objectives for individual players or teams.
"""
import datetime
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 app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
DEFAULT_OBJECTIVE_TEMPLATES,
OBJECTIVE_CHECK_FREQUENCIES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_STATUSES,
OBJECTIVE_TYPES,
Admin,
Coach,
Manager,
Objective,
ObjectiveComment,
ObjectiveEntry,
OrgTeam,
PersonalNote,
TeamNote,
User,
)
from app.pagination import paginate
from app.permissions import (
can_manage_objective,
can_manage_objectives,
coach_org_team_ids,
coach_player_ids,
)
from app.validators import ObjectiveSchema
objectives_bp = Blueprint('objectives', __name__, url_prefix='/objectives')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_objective(obj_id):
"""Load an objective or 404."""
return db.get_or_404(Objective, obj_id)
def _can_edit(user, objective):
"""Short-hand permission check with flash fallback."""
if can_manage_objective(user, objective):
return True
flash(_('You do not have permission to manage this objective.'), 'danger')
return False
# ---------------------------------------------------------------------------
# List / Dashboard
# ---------------------------------------------------------------------------
@objectives_bp.route('')
@login_required
def list_objectives():
"""List all objectives the current user may see."""
user = current_user
can_edit = can_manage_objectives(user)
# ── Query base ──────────────────────────────────────────────────────
base = Objective.query
# Scope: players only see their own, coaches/admins see theirs, etc.
if isinstance(user, Manager):
base = base.filter(Objective.created_by == user.id)
elif isinstance(user, Coach):
team_ids = coach_org_team_ids(user)
player_ids = coach_player_ids(user)
conditions = [Objective.created_by == user.id]
if team_ids:
conditions.append(Objective.team_id.in_(team_ids))
if player_ids:
conditions.append(Objective.player_id.in_(player_ids))
base = base.filter(db.or_(*conditions))
elif isinstance(user, Admin):
pass # all
elif hasattr(user, 'role') and user.role == 'player':
base = base.filter(Objective.player_id == user.id)
else:
base = base.filter(Objective.created_by == user.id)
# ── Tab / filter ────────────────────────────────────────────────────
tab = request.args.get('tab', 'all')
if tab == 'kpi':
base = base.filter(Objective.kpi_metric.isnot(None))
elif tab in ('modeles', 'templates'):
base = base.filter(Objective.is_template.is_(True))
search = request.args.get('search', '').strip()
if search:
base = base.filter(Objective.title.ilike(f'%{search}%'))
f_status = request.args.get('status', '').strip()
f_priority = request.args.get('priority', '').strip()
f_type = request.args.get('type', '').strip()
f_team = request.args.get('team', type=int)
if f_status:
base = base.filter(Objective.status == f_status)
if f_priority:
base = base.filter(Objective.priority == f_priority)
if f_type:
base = base.filter(Objective.type == f_type)
if f_team:
base = base.filter(Objective.team_id == f_team)
sort_column = request.args.get('sort', 'due_date')
sort_order = request.args.get('order', 'asc')
if sort_order not in ('asc', 'desc'):
sort_order = 'asc'
sort_map = {
'title': Objective.title,
'priority': Objective.priority,
'status': Objective.status,
'progress': Objective.progress,
'due_date': Objective.due_date,
'created_at': Objective.created_at,
}
sort_expr = sort_map.get(sort_column, Objective.due_date)
if sort_order == 'desc':
sort_expr = sort_expr.desc()
else:
sort_expr = sort_expr.asc()
objectives_page = paginate(base.order_by(sort_expr, Objective.id))
all_q = Objective.query
count_total = all_q.count()
count_kpi = all_q.filter(Objective.kpi_metric.isnot(None)).count()
count_templates = all_q.filter(Objective.is_template.is_(True)).count()
success_rate = 0.0
total_with_status = all_q.count()
if total_with_status > 0:
completed = all_q.filter(Objective.status == 'completed').count()
success_rate = round((completed / total_with_status) * 100, 1)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
players = User.query.filter(User.role == 'player').order_by(User.username).all()
templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all()
return render_template(
'pages/objectives.html',
objectives=objectives_page,
pagination=objectives_page,
can_edit=can_edit,
tab=tab,
search=search,
f_status=f_status,
f_priority=f_priority,
f_type=f_type,
f_team=f_team,
sort_column=sort_column,
sort_order=sort_order,
count_total=count_total,
count_kpi=count_kpi,
count_templates=count_templates,
success_rate=success_rate,
objective_types=OBJECTIVE_TYPES,
objective_statuses=OBJECTIVE_STATUSES,
objective_priorities=OBJECTIVE_PRIORITIES,
objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES,
teams=teams,
players=players,
templates=templates,
)
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
# New / Edit form page
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# View (player-facing detail page)
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>')
@login_required
def view_objective(objective_id):
"""Player-facing detail page for a specific objective."""
from app.permissions import can_view_objective as _can_view
objective = _resolve_objective(objective_id)
if not _can_view(current_user, objective):
flash(_('You do not have permission to view this objective.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
can_edit = can_manage_objectives(current_user)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
comments = (
ObjectiveComment.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveComment.created_at.desc())
.all()
)
personal_notes = []
if objective.player_id:
personal_notes = (
PersonalNote.query.filter_by(player_id=objective.player_id)
.order_by(PersonalNote.created_at.desc())
.limit(10)
.all()
)
team_notes = []
if objective.team_id:
team_notes = (
TeamNote.query.filter_by(org_team_id=objective.team_id)
.order_by(TeamNote.created_at.desc())
.limit(10)
.all()
)
return render_template(
'pages/objective_view.html',
objective=objective,
entries=entries,
comments=comments,
personal_notes=personal_notes,
team_notes=team_notes,
can_edit=can_edit,
objective_types=OBJECTIVE_TYPES,
objective_statuses=OBJECTIVE_STATUSES,
objective_priorities=OBJECTIVE_PRIORITIES,
objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES,
)
def _form_context():
"""Shared context for the create/edit form page."""
teams = OrgTeam.query.order_by(OrgTeam.name).all()
players = User.query.filter(User.role == 'player').order_by(User.username).all()
templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all()
return {
'objective_types': OBJECTIVE_TYPES,
'objective_statuses': OBJECTIVE_STATUSES,
'objective_priorities': OBJECTIVE_PRIORITIES,
'objective_frequencies': OBJECTIVE_CHECK_FREQUENCIES,
'teams': teams,
'players': players,
'templates': templates,
}
@objectives_bp.route('/new')
@login_required
def new_objective():
"""Render the create-objective form page with preview."""
if not can_manage_objectives(current_user):
flash(_('You do not have permission to create objectives.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
ctx = _form_context()
ctx['objective'] = None
ctx['edit_mode'] = False
ctx['entries'] = []
ctx['comments'] = []
ctx['personal_notes'] = []
ctx['team_notes'] = []
return render_template('pages/objective_form.html', **ctx)
@objectives_bp.route('/<int:objective_id>/edit')
@login_required
def edit_objective(objective_id):
"""Render the edit-objective form page with preview."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
ctx = _form_context()
ctx['objective'] = objective
ctx['edit_mode'] = True
ctx['entries'] = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
ctx['comments'] = (
ObjectiveComment.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveComment.created_at.desc())
.all()
)
ctx['personal_notes'] = []
if objective.player_id:
ctx['personal_notes'] = (
PersonalNote.query.filter_by(player_id=objective.player_id)
.order_by(PersonalNote.created_at.desc())
.limit(10)
.all()
)
ctx['team_notes'] = []
if objective.team_id:
ctx['team_notes'] = (
TeamNote.query.filter_by(org_team_id=objective.team_id)
.order_by(TeamNote.created_at.desc())
.limit(10)
.all()
)
return render_template('pages/objective_form.html', **ctx)
# ---------------------------------------------------------------------------
@objectives_bp.route('/create', methods=['POST'])
@login_required
def create_objective():
"""Create a new objective."""
if not can_manage_objectives(current_user):
flash(_('You do not have permission to create objectives.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
try:
data = ObjectiveSchema().load(
form_payload(list_fields=(), optional_blank=('password', 'check_frequency'))
)
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.list_objectives'))
objective = Objective(
title=data['title'],
description=data.get('description'),
type=data['type'],
status=data['status'],
priority=data['priority'],
category=data.get('category'),
is_template=data.get('is_template', False),
player_id=data.get('player_id'),
team_id=data.get('team_id'),
kpi_metric=data.get('kpi_metric'),
kpi_target=data.get('kpi_target'),
kpi_unit=data.get('kpi_unit'),
kpi_precision=data.get('kpi_precision'),
start_date=data.get('start_date'),
due_date=data['due_date'],
check_frequency=data.get('check_frequency') or None,
progress=data.get('progress', 0),
tags=data.get('tags'),
created_by=current_user.id,
)
db.session.add(objective)
db.session.commit()
flash(_('Objective created successfully.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/update', methods=['POST'])
@login_required
def update_objective(objective_id):
"""Update an existing objective."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
try:
data = ObjectiveSchema().load(
form_payload(list_fields=(), optional_blank=('password', 'check_frequency')),
partial=('due_date',),
)
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.list_objectives'))
for field in (
'title',
'description',
'type',
'status',
'priority',
'category',
'is_template',
'player_id',
'team_id',
'kpi_metric',
'kpi_unit',
'kpi_precision',
'check_frequency',
'progress',
'tags',
):
val = data.get(field)
setattr(objective, field, val)
if 'kpi_target' in data:
objective.kpi_target = data['kpi_target']
if 'start_date' in data:
objective.start_date = data['start_date']
if 'due_date' in data:
objective.due_date = data['due_date']
db.session.commit()
flash(_('Objective updated successfully.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/delete', methods=['POST'])
@login_required
def delete_objective(objective_id):
"""Delete an objective."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
db.session.delete(objective)
db.session.commit()
flash(_('Objective deleted.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# JSON data endpoint for the edit modal
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/data')
@json_endpoint
@login_required
def objective_data(objective_id):
"""Return objective details as JSON for the edit modal."""
from flask import jsonify
objective = _resolve_objective(objective_id)
return jsonify(
{
'id': objective.id,
'title': objective.title,
'description': objective.description,
'type': objective.type,
'status': objective.status,
'priority': objective.priority,
'category': objective.category,
'is_template': objective.is_template,
'player_id': objective.player_id,
'team_id': objective.team_id,
'kpi_metric': objective.kpi_metric,
'kpi_target': str(objective.kpi_target) if objective.kpi_target is not None else None,
'kpi_unit': objective.kpi_unit,
'kpi_precision': objective.kpi_precision,
'start_date': objective.start_date.isoformat() if objective.start_date else None,
'due_date': objective.due_date.isoformat() if objective.due_date else None,
'check_frequency': objective.check_frequency,
'progress': objective.progress,
'tags': objective.tags,
}
)
# ---------------------------------------------------------------------------
# Seed default templates (idempotent)
# ---------------------------------------------------------------------------
def seed_default_templates():
"""Create the default Objective templates if they don't exist yet.
Idempotent — safe to call on every startup.
Skips if no admin user exists yet (first run seeding may not have run).
"""
admin = User.query.filter(User.role == 'admin').first()
if not admin:
return
existing = {t.title for t in Objective.query.filter_by(is_template=True).all()}
for (
priority,
title,
description,
kpi_metric,
kpi_target,
kpi_unit,
typ,
category,
) in DEFAULT_OBJECTIVE_TEMPLATES:
if title in existing:
continue
obj = Objective(
title=title,
description=description,
type=typ,
status='active',
priority=priority,
category=category,
is_template=True,
kpi_metric=kpi_metric,
kpi_target=kpi_target,
kpi_unit=kpi_unit,
progress=0,
due_date=datetime.date(2099, 12, 31),
created_by=admin.id,
)
db.session.add(obj)
db.session.commit()
# ---------------------------------------------------------------------------
# KPI Entries — measurements for the quantitative graph
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/entries', methods=['GET'])
@login_required
@json_endpoint
def list_entries(objective_id):
"""Return KPI entries for an objective as JSON."""
from flask import jsonify
objective = _resolve_objective(objective_id)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
return jsonify(
[
{
'id': e.id,
'value': str(e.value),
'recorded_at': e.recorded_at.isoformat(),
'note': e.note,
}
for e in entries
]
)
@objectives_bp.route('/<int:objective_id>/entries', methods=['POST'])
@login_required
def add_entry(objective_id):
"""Record a KPI measurement for an objective.
Accepts an optional numeric value (for quantitative KPI tracking)
and an optional note. If neither is provided the entry is rejected.
The assigned player may record entries on their own objectives.
"""
from datetime import datetime
from app.permissions import can_add_objective_entry
objective = _resolve_objective(objective_id)
if not can_add_objective_entry(current_user, objective):
flash(_('You do not have permission to record entries for this objective.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
raw_value = request.form.get('value', '').strip()
note = request.form.get('note', '').strip()
value = None
if raw_value:
try:
value = float(raw_value)
except (ValueError, TypeError):
flash(_('Invalid measurement value.'), 'danger')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
if value is None and not note:
flash(_('Provide a value, a note, or both.'), 'warning')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
entry = ObjectiveEntry(
objective_id=objective.id,
value=value,
recorded_at=datetime.now(),
note=note or None,
recorded_by=current_user.id,
)
db.session.add(entry)
db.session.commit()
flash(_('Measurement recorded.'), 'success')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
flash(_('Measurement recorded.'), 'success')
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
# ---------------------------------------------------------------------------
# Comments — coach feedback on an objective
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/comments', methods=['POST'])
@login_required
def add_comment(objective_id):
"""Add a coach comment on an objective."""
objective = _resolve_objective(objective_id)
if not can_manage_objectives(current_user):
flash(_('You do not have permission to comment.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
from app.validators import NoteContentSchema
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
comment = ObjectiveComment(
objective_id=objective.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(comment)
db.session.commit()
flash(_('Comment added.'), 'success')
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
# ---------------------------------------------------------------------------
# Trend / prediction data for the chart
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/trend')
@login_required
@json_endpoint
def trend_data(objective_id):
"""Return the trend + prediction data for an objective's chart."""
from flask import jsonify
from app.objectives_analytics import compute_trend
objective = _resolve_objective(objective_id)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
timestamps = [e.recorded_at for e in entries]
values = [float(e.value) for e in entries]
target = float(objective.kpi_target) if objective.kpi_target is not None else None
trend = compute_trend(timestamps, values, target)
return jsonify(trend)
+28 -8
View File
@@ -3,8 +3,6 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
@@ -22,11 +20,13 @@ from app.models import (
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings,
)
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.time_utils import utc_now_naive
from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
@@ -41,6 +41,17 @@ def can_manage_team_match(team):
return can_manage_org_team(current_user, team)
def season_locked():
"""Return True when the regular season is inactive for non-admins.
Admins bypass the lock. Coaches and managers may only schedule regular
season matches while the season is active.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('season_active', default=False)
@team_matches_bp.route('')
@login_required
def list_matches():
@@ -100,7 +111,7 @@ def list_matches():
teams=teams,
match_data=match_data,
pagination=matches_page,
now=datetime.utcnow(),
now=utc_now_naive(),
)
@@ -108,7 +119,11 @@ def list_matches():
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if season_locked():
flash('The regular season is not active. Begin a season before scheduling matches.', 'warning')
return redirect(url_for('team_matches.list_matches'))
if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
@@ -213,7 +228,7 @@ def create_match(team_id):
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if not can_manage_team_match(team):
@@ -259,11 +274,16 @@ def edit_match(match_id):
@login_required
def delete_match(match_id):
"""Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if season_locked():
flash('The regular season is not active. Begin a season before deleting matches.', 'warning')
return redirect(url_for('team_matches.list_matches'))
if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match)
db.session.commit()
flash(_('Match deleted successfully.'), 'success')
@@ -293,10 +313,10 @@ def api_manageable_teams():
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(TeamMatchParticipant, participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
+37 -30
View File
@@ -3,9 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
@@ -29,7 +27,8 @@ from app.models import (
User,
)
from app.permissions import visible_org_teams
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -82,7 +81,7 @@ def my_teams():
from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams()
now = datetime.utcnow()
now = utc_now_naive()
team_data = []
for org_team in player_teams:
@@ -223,7 +222,7 @@ def create_team():
@login_required
def edit_team(team_id):
"""Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -294,7 +293,7 @@ def delete_team(team_id):
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)
team = db.get_or_404(OrgTeam, team_id)
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger')
@@ -336,7 +335,7 @@ def delete_team(team_id):
@login_required
def add_coach(team_id):
"""Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -379,7 +378,7 @@ def add_coach(team_id):
@login_required
def add_manager(team_id):
"""Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -422,7 +421,7 @@ def add_manager(team_id):
@login_required
def remove_coach(team_id):
"""Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -450,7 +449,7 @@ def remove_coach(team_id):
@login_required
def remove_manager(team_id):
"""Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -478,7 +477,7 @@ def remove_manager(team_id):
@login_required
def add_player(team_id):
"""Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -515,12 +514,12 @@ def add_player(team_id):
@login_required
def remove_player(team_id, player_id):
"""Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(
@@ -543,7 +542,7 @@ def remove_player(team_id, player_id):
@login_required
def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403
@@ -567,17 +566,21 @@ def toggle_player_status(team_id, player_id):
@login_required
def add_team_note(team_id):
"""Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
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')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams'))
@@ -585,12 +588,12 @@ def add_team_note(team_id):
@login_required
def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -603,10 +606,14 @@ def add_player_note(team_id, player_id):
)
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams'))
+143 -43
View File
@@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from sqlalchemy import select
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
@@ -31,8 +30,17 @@ from app.models import (
Tryout,
TryoutRegistration,
User,
AppSettings,
)
from app.time_utils import utc_now_naive
from app.validators import (
PlayerSelectionSchema,
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
from app.validators import PlayerSelectionSchema, TryoutSchema
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -42,9 +50,20 @@ def can_manage():
return isinstance(current_user, (Admin, Manager))
def tryouts_locked():
"""Return True when tryouts are globally closed to coaches/managers.
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('tryouts_open', default=True)
def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids',), optional_blank=())
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
def coaches_from_ids(coach_ids):
@@ -60,6 +79,13 @@ def coaches_from_ids(coach_ids):
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def managers_from_ids(manager_ids):
"""The manager accounts behind these ids, filtered by role."""
if not manager_ids:
return []
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
def _users_by_id(user_ids):
"""Load these users in one query, keyed by id.
@@ -79,6 +105,25 @@ def _users_by_id(user_ids):
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
def registration_lock_statement(tryout_id):
"""The PostgreSQL row lock used by both registration entry points."""
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
def locked_tryout_or_404(tryout_id):
"""Load and row-lock a tryout while a registration slot is decided.
PostgreSQL serializes concurrent registration attempts on this row. The
duplicate check, capacity count and insert that follow therefore form
one decision instead of three independently racing statements. SQLite
ignores ``FOR UPDATE`` in tests, but production does not.
"""
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
if tryout is None:
abort(404)
return tryout
@tryouts_bp.route('')
@login_required
def list_tryouts():
@@ -87,13 +132,17 @@ def list_tryouts():
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
"""
tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive())
@tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if not can_manage():
flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -134,12 +183,12 @@ def create_tryout():
created_by=current_user.id,
status='upcoming',
target_org_team_id=data['target_org_team_id'],
manager_id=data['manager_id'],
)
db.session.add(tryout)
db.session.flush()
tryout.coaches = coaches_from_ids(data['coach_ids'])
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit()
flash(_('Tryout created successfully!'), 'success')
@@ -152,7 +201,11 @@ def create_tryout():
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger')
@@ -195,8 +248,14 @@ def edit_tryout(tryout_id):
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'])
# Only update staff lists when the form explicitly sends them.
# An absent checkbox group (all unchecked or JS failed) means
# "don't change", not "remove everyone".
if 'coach_ids' in request.form:
tryout.coaches = coaches_from_ids(data['coach_ids'])
if 'manager_ids' in request.form:
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit()
flash(_('Tryout updated successfully!'), 'success')
@@ -209,13 +268,13 @@ def edit_tryout(tryout_id):
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = False
if isinstance(current_user, Admin):
can_view = True
elif isinstance(current_user, Manager):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
@@ -398,7 +457,7 @@ def view_tryout(tryout_id):
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow(),
now=utc_now_naive(),
)
@@ -406,10 +465,10 @@ def view_tryout(tryout_id):
@login_required
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')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger')
@@ -439,15 +498,23 @@ def register_for_tryout(tryout_id):
@login_required
def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
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(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
try:
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout.status = data['status']
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -455,7 +522,11 @@ def update_status(tryout_id):
@login_required
def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -463,11 +534,15 @@ def update_registration_status(tryout_id, player_id):
registration = TryoutRegistration.query.filter_by(
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')
try:
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration.status = data['status']
db.session.commit()
flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -475,7 +550,11 @@ def update_registration_status(tryout_id, player_id):
@login_required
def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = locked_tryout_or_404(tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -522,12 +601,16 @@ def register_player(tryout_id):
@login_required
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)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
@@ -558,17 +641,25 @@ def remove_player(tryout_id, player_id):
@login_required
def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name')
if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -576,8 +667,12 @@ def create_team(tryout_id):
@login_required
def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team."""
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -588,10 +683,12 @@ def add_to_team(tryout_id, team_id):
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')
try:
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_id']
# Only players registered for this tryout may be placed on its teams.
is_registered = (
@@ -602,12 +699,11 @@ def add_to_team(tryout_id, team_id):
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_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')
else:
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
db.session.add(member)
db.session.commit()
flash(_('Player added to team!'), 'success')
@@ -618,7 +714,11 @@ def add_to_team(tryout_id, team_id):
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
+35 -11
View File
@@ -4,7 +4,6 @@ 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
@@ -13,7 +12,7 @@ from app.extensions import db
# 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
from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
@@ -40,6 +39,10 @@ def pdf_upload_error(file, allowed_extensions):
could put an arbitrary file on the server under a name the application
later hands back for download (SEC-021).
The check now goes beyond the header: it parses the upload with pypdf
and requires at least one parseable page, so a file with ``%PDF-``
prepended or a renamed non-PDF is rejected.
Args:
file: The uploaded FileStorage, or None.
allowed_extensions: Extensions to accept, lowercase and without dot.
@@ -54,29 +57,50 @@ def pdf_upload_error(file, allowed_extensions):
if not (stem and dot) or extension.lower() not in allowed_extensions:
return _('Only PDF files are allowed for contracts.')
# Fast-fail: the PDF signature must appear at the start of the stream.
head = file.stream.read(len(PDF_SIGNATURE))
file.stream.seek(0)
if head != PDF_SIGNATURE:
return _('That file is not a PDF, whatever its name says.')
# Full-content validation: parse with pypdf and require at least one
# page. A file with a forged header but no real PDF structure (e.g. a
# renamed executable with ``%PDF-`` prepended) will fail here.
try:
from pypdf import PdfReader
from pypdf.errors import PyPdfError
reader = PdfReader(file.stream, strict=False)
if len(reader.pages) == 0:
return _('That file is not a PDF, whatever its name says.')
except (PyPdfError, ValueError, TypeError):
return _('That file is not a PDF, whatever its name says.')
finally:
file.stream.seek(0)
return None
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
"""Update gamertags for a user from validated dynamic form fields."""
from app.forms import form_gamertags
submitted = form_gamertags(selected_games)
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
gamertag = request.form.get(f'gamertag_{game}', '').strip()
platform = (
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
)
payload = submitted.get(game)
existing = existing_gamertags.get(game)
if gamertag:
if payload:
if existing:
existing.gamertag = gamertag
existing.platform = platform
existing.gamertag = payload['gamertag']
existing.platform = payload['platform']
else:
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
gt = UserGamertag(
user_id=user.id,
game=game,
gamertag=payload['gamertag'],
platform=payload['platform'],
)
db.session.add(gt)
elif existing:
db.session.delete(existing)
+69 -7
View File
@@ -44,6 +44,7 @@ from app.routes.users._shared import (
)
from app.routes.users.blueprint import users_bp
from app.storage import discard_documents
from app.timeslots import total_hours
from app.validators import CreateUserSchema, EditUserSchema
@@ -59,7 +60,39 @@ def list_users():
# 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)
# Build a per-user hours map for the current page with two bounded
# queries (one per availability table), avoiding an N+1 per row.
user_ids = [u.id for u in page.items]
hours_by_user: dict[int, float] = {}
for slots, fk in [
(
PlayerDisponibility.query.filter(
PlayerDisponibility.player_id.in_(user_ids),
).all(),
'player_id',
),
(
CoachAvailability.query.filter(
CoachAvailability.coach_id.in_(user_ids),
).all(),
'coach_id',
),
]:
buckets: dict[int, list] = {}
for slot in slots:
buckets.setdefault(getattr(slot, fk), []).append(slot)
for uid, group in buckets.items():
hours_by_user[uid] = total_hours(group)
return render_template(
'pages/users.html',
users=page.items,
pagination=page,
roles=USER_TYPES,
hours=hours_by_user,
)
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
@@ -70,7 +103,7 @@ def edit_user(user_id):
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
if request.method == 'POST':
actor_name, actor_id = current_user.username, current_user.id
@@ -111,6 +144,22 @@ def edit_user(user_id):
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
discord_clash = None
if discord_user_id:
discord_clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != user.id,
).first()
if discord_clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender()
try:
update_user_gamertags(user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
role_changed = user.role != role
previous_role = user.role
@@ -173,8 +222,6 @@ def edit_user(user_id):
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')
@@ -239,7 +286,7 @@ def delete_user(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)
user = db.get_or_404(User, user_id)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
@@ -365,5 +412,20 @@ def create_user():
@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)
user = db.get_or_404(User, user_id)
# Load availability for players and coaches so the public profile
# can render a read-only weekly grid.
availability = None
if user.role == 'player':
availability = user.disponibilities
elif user.role == 'coach':
availability = user.coach_availabilities
availability_hours = total_hours(availability) if availability else None
return render_template(
'pages/view_user.html',
profile_user=user,
availability=availability,
availability_hours=availability_hours,
)
+60 -71
View File
@@ -5,8 +5,7 @@ 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 import jsonify, request
from flask_login import current_user, login_required
from marshmallow import ValidationError
@@ -141,41 +140,41 @@ def add_disponibility():
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Add multiple disponibility blocks at once."""
"""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']
existing = PlayerDisponibility.query.filter_by(
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
).first()
if not existing:
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'),
}
)
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()
# `rejected` is reported rather than swallowed. What was accepted is
# still saved — dropping a whole batch because one cell was malformed
# would be its own kind of surprise — but the client can now tell the
# difference between "nine slots saved" and "ten sent, nine saved".
return jsonify({'success': True, 'created': created, 'rejected': rejected})
return jsonify({'success': True, 'created': created, 'rejected': []})
@users_bp.route('/disponibilities/clear', methods=['POST'])
@@ -193,7 +192,7 @@ def clear_disponibilities():
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
disponibility = db.get_or_404(PlayerDisponibility, disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
@@ -201,60 +200,50 @@ def delete_disponibility(disponibility_id):
return jsonify({'success': True})
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
@users_bp.route('/coach-availability', methods=['POST'])
@json_endpoint
@login_required
def manage_coach_availability():
"""Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage availability.'), 'danger')
return redirect(url_for('main.dashboard'))
return jsonify({'error': 'Unauthorized'}), 403
if request.method == 'POST':
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
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
# 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()
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),
)
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
)
db.session.commit()
return jsonify({'success': True, 'saved': len(accepted)})
@users_bp.route('/coach-availability/clear', methods=['POST'])
+67 -24
View File
@@ -2,7 +2,6 @@
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 _
@@ -12,6 +11,7 @@ from werkzeug.utils import secure_filename
from app.extensions import db
from app.models import Admin, Coach, Contract, Manager, Player, User
from app.models.admin_settings import AppSettings
from app.permissions import can_manage_player_contract, coach_player_ids
from app.routes.users._shared import (
ALLOWED_CONTRACT_EXTENSIONS,
@@ -19,7 +19,14 @@ from app.routes.users._shared import (
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
from app.storage import CONTRACTS_DIR, document_path
from app.storage import (
CONTRACTS_DIR,
GoogleDriveStorageError,
is_google_drive_path,
open_document,
store_uploaded_document,
)
from app.time_utils import utc_now_naive
from app.validators import UploadContractSchema
@@ -43,6 +50,17 @@ def manageable_players():
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
def document_uploads_locked():
"""Return True when document uploads are globally disabled.
Admins are always allowed to bypass the lock. Managers, coaches, and
players are blocked when the toggle is off.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('document_uploads_enabled', default=True)
@users_bp.route('/contracts')
@login_required
def list_contracts():
@@ -86,6 +104,10 @@ def upload_contract():
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts'))
players = manageable_players()
if request.method == 'POST':
@@ -111,7 +133,7 @@ def upload_contract():
flash(error, 'danger')
return redirect(url_for('users.upload_contract'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None
@@ -126,9 +148,13 @@ def upload_contract():
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)
try:
stored_path = store_uploaded_document(file, relative_path)
except GoogleDriveStorageError:
flash(
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
)
return redirect(url_for('users.upload_contract'))
contract = Contract(
player_id=player_id,
@@ -136,7 +162,7 @@ def upload_contract():
uploaded_by_id=current_user.id,
original_filename=original_filename,
stored_filename=stored_filename,
file_path=relative_path,
file_path=stored_path,
notes=notes if notes else None,
)
db.session.add(contract)
@@ -154,11 +180,15 @@ def upload_contract():
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
@@ -166,13 +196,24 @@ def upload_signed_contract(contract_id):
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))
# Legacy local records keep their signed copy beside the original. Drive
# identifiers are opaque rather than filenames, so new Drive records use
# a fresh logical path and receive a second Drive ID.
signed_path = (
os.path.join(CONTRACTS_DIR, signed_filename)
if is_google_drive_path(contract.file_path)
else contract.file_path.replace(contract.stored_filename, signed_filename)
)
try:
stored_signed_path = store_uploaded_document(file, signed_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
contract.signed_filename = signed_filename
contract.signed_file_path = signed_path
contract.signed_file_path = stored_signed_path
contract.status = 'signed'
contract.signed_at = datetime.utcnow()
contract.signed_at = utc_now_naive()
db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
@@ -182,30 +223,32 @@ def upload_signed_contract(contract_id):
@login_required
def download_contract(contract_id):
"""Download a contract file."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.file_path),
as_attachment=True,
download_name=contract.original_filename,
)
try:
document = open_document(contract.file_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.original_filename)
@users_bp.route('/contracts/<int:contract_id>/download_signed')
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.signed_file_path),
as_attachment=True,
download_name=contract.signed_filename,
)
try:
document = open_document(contract.signed_file_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
+116 -55
View File
@@ -7,23 +7,32 @@ it reads exactly what the coach routes write.
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
Team,
TeamMember,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
from app.permissions import (
coach_can_access_player,
coach_org_teams,
coach_player_ids,
coach_tryouts,
)
from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@users_bp.route('/my-notes')
@@ -116,23 +125,25 @@ def notes_dashboard():
)
# For context selectors in the form
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
# previous selector mixed the two namespaces and could either attach the
# note to an unrelated team with the same integer id or fail its FK.
# Every context list now comes from the tryouts this coach may manage.
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
tryout_ids = [tryout.id for tryout in tryouts]
matches = (
Match.query.filter(
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
)
Match.query.filter(Match.tryout_id.in_(tryout_ids))
.order_by(Match.date.desc())
.limit(20)
.all()
if tryout_ids
else []
)
tryouts = (
Tryout.query.filter_by(
created_by=current_user.id,
)
.order_by(Tryout.date.desc())
.limit(20)
.all()
teams = (
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
if tryout_ids
else []
)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
return render_template(
'pages/notes.html',
@@ -168,16 +179,20 @@ def manage_team_notes():
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')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -195,14 +210,14 @@ def manage_personal_notes():
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')
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
@@ -214,7 +229,7 @@ def manage_personal_notes():
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
)
db.session.add(note)
db.session.commit()
@@ -235,17 +250,14 @@ def add_personal_note():
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')
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
@@ -254,13 +266,40 @@ def add_personal_note():
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['match_id']:
match = db.get_or_404(Match, data['match_id'])
if not current_user.can_manage_this_tryout(match.tryout):
flash(_('You cannot use that match as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
flash(_('That player did not participate in the selected match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['tryout_id']:
tryout = db.get_or_404(Tryout, data['tryout_id'])
if not current_user.can_manage_this_tryout(tryout):
flash(_('You cannot use that tryout as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
flash(_('That player is not registered for the selected tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['team_id']:
team = db.get_or_404(Team, data['team_id'])
if not current_user.can_manage_this_tryout(team.tryout):
flash(_('You cannot use that team as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
flash(_('That player is not on the selected team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=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,
content=data['content'],
match_id=data['match_id'],
tryout_id=data['tryout_id'],
team_id=data['team_id'],
)
db.session.add(note)
db.session.commit()
@@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list
@@ -289,21 +331,29 @@ def add_note_from_tryout(tryout_id):
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()
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
player_id = data['player_id']
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
if data['tryout_id'] not in (None, tryout_id):
flash(_('Invalid tryout context.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
tryout_id=tryout_id,
)
db.session.add(note)
@@ -334,7 +384,10 @@ def add_note_from_match(match_id):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
match_obj = Match.query.get_or_404(match_id)
match_obj = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
# Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
@@ -343,21 +396,29 @@ def add_note_from_match(match_id):
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()
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_match', match_id=match_id))
player_id = data['player_id']
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
if data['match_id'] not in (None, match_id):
flash(_('Invalid match context.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
flash(_('That player did not participate in this match.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
match_id=match_id,
)
db.session.add(note)
+12 -8
View File
@@ -1,7 +1,5 @@
"""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
@@ -12,7 +10,8 @@ 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
from app.time_utils import utc_now_naive
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@@ -166,7 +165,7 @@ def accept_one_on_one(request_id):
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)
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
@@ -178,7 +177,7 @@ def accept_one_on_one(request_id):
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow()
request_obj.responded_at = utc_now_naive()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
@@ -216,7 +215,7 @@ def reject_one_on_one(request_id):
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)
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
@@ -226,11 +225,16 @@ def reject_one_on_one(request_id):
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
rejection_reason = request.form.get('rejection_reason', '').strip()
try:
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason']
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow()
request_obj.responded_at = utc_now_naive()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
+12 -4
View File
@@ -76,7 +76,6 @@ def edit_profile():
phone = validated.get('phone')
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')
if username != current_user.username and User.query.filter_by(username=username).first():
@@ -99,17 +98,26 @@ def edit_profile():
user_gamertags=current_user.get_gamertags(),
)
try:
update_user_gamertags(current_user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
current_user.username = username
current_user.full_name = full_name
current_user.email = email
current_user.phone = phone
current_user.games = ','.join(selected_games) if selected_games else None
current_user.discord_username = discord_username or None
current_user.discord_user_id = discord_user_id 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')
+375
View File
@@ -1256,6 +1256,64 @@ a:hover { color: var(--primary-dark); }
color: white !important;
border-color: #4f46e5 !important;
}
/* Read-only weekly availability grid (public profile) */
.readonly-availability-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-top: 8px;
}
.ra-day-column {
background: var(--bg-secondary);
border-radius: 8px;
padding: 8px;
min-height: 120px;
}
.ra-day-header {
text-align: center;
font-weight: 600;
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 6px;
color: var(--primary);
font-size: 0.85rem;
}
.ra-slot {
padding: 4px 6px;
margin: 3px 0;
border-radius: 4px;
font-size: 0.75rem;
text-align: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.ra-slot.selected {
background: var(--primary);
color: white;
border-color: var(--primary-dark);
}
.ra-slot.ra-empty {
color: var(--gray-400);
border: 1px dashed var(--gray-300);
background: transparent;
}
@media (max-width: 768px) {
.readonly-availability-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.readonly-availability-grid {
grid-template-columns: 1fr;
}
}
.merged-disponibility-time-block.high-availability {
background: var(--success);
@@ -1730,6 +1788,296 @@ a:hover { color: var(--primary-dark); }
box-shadow: var(--shadow-md);
}
/* ------------------------------------------------------------------- */
/* Player Evolution — Objective Management */
/* ------------------------------------------------------------------- */
/* Wider variant for the create/edit modal with five sections */
.modal-lg .modal-content {
max-width: 720px;
}
/* Section grouping inside the form */
.form-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--gray-200);
}
.form-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.form-section h4 {
font-size: 0.95rem;
color: var(--primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
/* Toolbar (tabs + search/filter bar) */
.objectives-toolbar {
display: flex;
flex-direction: column;
gap: 12px;
}
.objectives-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--gray-200);
padding-bottom: 4px;
}
.tab-link {
padding: 6px 16px;
font-size: 0.9rem;
color: var(--gray-500);
text-decoration: none;
border-radius: var(--radius) var(--radius) 0 0;
transition: all 0.2s;
border: none;
background: transparent;
cursor: pointer;
}
.tab-link:hover {
color: var(--primary);
background: var(--gray-100);
}
.tab-link.active {
color: var(--primary);
font-weight: 600;
border-bottom: 2px solid var(--primary);
margin-bottom: -5px;
}
.objectives-search-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.objectives-search-bar .form-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
align-items: flex-end;
}
/* Tag badges inside objective cells */
.objective-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.objective-tags .badge {
font-size: 0.7rem;
padding: 2px 6px;
}
/* Progress bar in the table */
.progress-bar-container {
width: 80px;
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--danger), var(--warning), var(--success));
border-radius: 3px;
transition: width 0.3s ease;
}
/* Score-input range mirror (reused from evaluation forms) */
.score-input {
display: flex;
align-items: center;
gap: 8px;
}
.score-input input[type="range"] {
flex: 1;
}
.range-value {
font-weight: 600;
font-size: 1rem;
/* ------------------------------------------------------------------- */
/* Objective Form — two-column editor layout */
/* ------------------------------------------------------------------- */
.objective-editor {
display: grid;
grid-template-columns: 1fr 360px;
gap: 20px;
align-items: start;
}
.objective-editor-form .card-body {
max-height: 80vh;
overflow-y: auto;
}
.objective-editor-preview {
position: sticky;
top: 20px;
}
/* Preview card stats */
.preview-stats {
margin: 12px 0;
}
.preview-stat {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid var(--gray-100);
font-size: 0.85rem;
}
.preview-label {
color: var(--gray-500);
}
.preview-value {
font-weight: 600;
color: var(--gray-800);
}
.progress-section {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.progress-section .progress-bar-container {
flex: 1;
}
/* Template list items */
.template-item {
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: border-color 0.2s;
}
.template-item:hover {
border-color: var(--primary);
}
.template-item h5 {
margin: 0 0 4px 0;
font-size: 0.9rem;
}
.template-item p {
margin: 0 0 8px 0;
font-size: 0.8rem;
}
/* ------------------------------------------------------------------- */
/* Objective form — comments, entries, prediction info */
/* ------------------------------------------------------------------- */
.comments-list {
max-height: 300px;
overflow-y: auto;
}
.comment-item {
padding: 8px 0;
border-bottom: 1px solid var(--gray-100);
}
.comment-item:last-child {
border-bottom: none;
}
.comment-item p {
margin: 4px 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
}
#predictionInfo {
font-size: 0.85rem;
padding: 8px;
background: var(--gray-50);
border-radius: var(--radius);
}
#predictionInfo .confidence-high { color: var(--success); }
#predictionInfo .confidence-medium { color: var(--warning); }
#predictionInfo .confidence-low { color: var(--danger); }
/* Form-inline for measurement entry */
.form-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
}
.form-inline .form-group {
margin-bottom: 0;
}
/* Hide template/team fields initially */
#playerField, #teamField {
transition: opacity 0.2s;
}
/* Chart sizing */
#progressChart {
background: transparent;
}
/* Responsive: stack on small screens */
@media (max-width: 900px) {
.objective-editor {
grid-template-columns: 1fr;
}
.objective-editor-preview {
position: static;
}
}
min-width: 24px;
text-align: center;
color: var(--primary);
}
/* Actions cell in table */
.actions-cell {
white-space: nowrap;
display: flex;
gap: 4px;
}
/* Required field indicator */
.required {
color: var(--danger);
}
/* Reused stat card col-4 / col-6 helpers */
.col-4 { flex: 0 0 calc(33.333% - 12px); }
.col-6 { flex: 0 0 calc(50% - 12px); }
.col-12 { flex: 0 0 100%; }
.card-header .drag-handle {
margin-left: auto;
margin-right: 0;
@@ -2037,3 +2385,30 @@ a:hover { color: var(--primary-dark); }
.honeypot {
display: none;
}
/* Batch Evaluation - 2 cards wide layout */
.batch-eval-form {
max-width: none;
}
.batch-eval-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.batch-eval-grid .card {
margin-bottom: 0;
}
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
.batch-eval-grid .form-row {
flex-wrap: wrap;
}
@media (max-width: 1100px) {
.batch-eval-grid {
grid-template-columns: 1fr;
}
}
+19 -1
View File
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
* Confirmation before a destructive submit.
*
* <form data-confirm="Delete this match?">
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
* data-confirm-input-message="Type DELETE to continue."
* data-confirm="Delete this record?">
*
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated.
*/
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
if (!form) {
return;
}
const inputName = form.getAttribute('data-confirm-input');
if (inputName) {
const input = form.elements.namedItem(inputName);
const expectedValue = form.getAttribute('data-confirm-value') || '';
if (!input || input.value.trim() !== expectedValue) {
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
event.preventDefault();
return;
}
}
if (!window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
+82 -7
View File
@@ -28,8 +28,23 @@ absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002).
"""
import io
import logging
import os
from app.google_drive import (
GoogleDriveStorageError,
)
from app.google_drive import (
delete_file as delete_google_drive_file,
)
from app.google_drive import (
download_file as download_google_drive_file,
)
from app.google_drive import (
upload_file as upload_google_drive_file,
)
#: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current.
@@ -45,6 +60,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés'
#: Database marker for documents stored remotely. Existing rows continue to
#: hold relative or absolute filesystem paths, so switching storage does not
#: invalidate contracts already uploaded before the Google Drive move.
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
#: Storage backends deliberately stay explicit. Google Drive is the
#: production default; local disk exists only for legacy rows and isolated
#: test/development environments.
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
def project_root():
"""Absolute path of the project, derived from this file's location.
@@ -91,6 +116,56 @@ def _rooted(env_name, default_name):
return os.path.join(project_root(), default_name)
def document_storage_backend():
"""Return the configured backend for new document uploads."""
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'local').strip().lower()
if backend not in {'google_drive', 'local'}:
raise ValueError(
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
)
return backend
def is_google_drive_path(stored_path):
"""Whether a database path references a Google Drive file."""
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
def _google_drive_id(stored_path):
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
if not file_id:
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
return file_id
def store_uploaded_document(file_storage, relative_path):
"""Store an uploaded document and return its durable database reference.
Local storage retains the relative-path format used by existing rows. A
Google Drive upload returns an opaque Drive file identifier prefixed with
``gdrive://`` so it cannot be mistaken for a filesystem path.
"""
if document_storage_backend() == 'local':
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file_storage.save(absolute_path)
return relative_path
file_id = upload_google_drive_file(
stream=file_storage.stream,
filename=os.path.basename(relative_path),
mimetype=file_storage.mimetype or 'application/pdf',
)
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
def open_document(stored_path):
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
if is_google_drive_path(stored_path):
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
return document_path(stored_path)
def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012).
@@ -107,27 +182,27 @@ def discard_documents(stored_paths):
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)
if is_google_drive_path(stored_path):
delete_google_drive_file(_google_drive_id(stored_path))
else:
os.remove(document_path(stored_path))
removed += 1
except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target)
except OSError as exc:
logger.info('Document already absent: %s', stored_path)
except (GoogleDriveStorageError, OSError) as exc:
logger.error(
'Could not remove %s (%s). It is now an orphan: no database row '
'refers to it, so nothing in the application will ever offer to '
'delete it again.',
target,
stored_path,
exc,
)
return removed
+103 -21
View File
@@ -42,7 +42,11 @@ from urllib.parse import unquote, urlparse
# 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
from app.storage import ( # noqa: E402 — needs the path above
backups_root,
document_storage_backend,
documents_root,
)
# Configuration
BACKUP_DIR = backups_root()
@@ -55,8 +59,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
# 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.
# application had never written to. A missing or unreadable document store
# is now a failed full-backup run rather than a database-only green result.
#
# So the more correctly an operator followed the deployment documentation,
# the more certainly their contract backups were empty (OBS-006).
@@ -262,38 +266,105 @@ def backup_documents():
module happened to be imported with.
Returns:
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.
str: Path to the created archive.
Raises:
BackupError: If the configured store is absent or cannot be archived.
Signed contracts live only on disk, so a database-only run must
never be reported as a complete backup.
"""
documents_dir = documents_root()
if not os.path.exists(documents_dir):
# 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
raise BackupError(f'Documents directory does not exist: {documents_dir}')
if not os.path.isdir(documents_dir):
raise BackupError(f'Documents path is not a directory: {documents_dir}')
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
try:
shutil.make_archive(archive_basename, 'zip', documents_dir)
except Exception as exc: # noqa: BLE001 — 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
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
raise BackupError(f'Document backup failed: {exc}') from exc
zip_path = f'{archive_basename}.zip'
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Document archiver reported success but produced an empty file.')
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
def backup_drive_documents():
"""Download every file from the configured Google Drive folder and archive it.
Called **in addition to** ``backup_documents()`` when the storage backend is
``google_drive``. When the backend is ``local`` this function silently
skips — local backups already handled by ``backup_documents()``.
Returns:
str | None: Path to the created archive, or None when skipped.
Raises:
BackupError: If Drive is configured but listing or downloading fails.
"""
if document_storage_backend() != 'google_drive':
print('[SKIP] Google Drive is not configured; local documents only.')
return None
try:
from app.google_drive import (
GoogleDriveStorageError,
list_folder_files,
)
from app.google_drive import (
download_file as download_drive_file,
)
except ImportError:
raise BackupError(
'Google Drive dependencies are not installed. Install requirements.txt again.'
) from None
print('[INFO] Listing Google Drive contract folder...')
try:
files = list_folder_files()
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
raise BackupError(f'Google Drive listing failed: {exc}') from exc
if not files:
print('[INFO] Google Drive folder is empty; no Drive documents to back up.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_drive_{timestamp}')
download = {}
for file_id, name in files:
print(f' Downloading {name} ({file_id})...')
try:
download[name] = download_drive_file(file_id)
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
raise BackupError(f'Google Drive download failed for {name}: {exc}') from exc
try:
import zipfile
zip_path = f'{archive_basename}.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, data in download.items():
zf.writestr(name, data)
except Exception as exc: # noqa: BLE001 — boundary
raise BackupError(f'Drive document archive failed: {exc}') from exc
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Drive document archiver reported success but produced an empty file.')
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Drive documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR):
@@ -361,15 +432,26 @@ def main(argv=None):
return 1
verified = verify_backup(backup_path)
backup_documents()
documents_ok = True
try:
backup_documents()
except BackupError as exc:
documents_ok = False
print(f'[ERROR] {exc}')
drive_ok = True
try:
backup_drive_documents()
except BackupError as exc:
drive_ok = False
print(f'[ERROR] {exc}')
cleanup_old_backups()
print()
if verified:
if verified and documents_ok and drive_ok:
print('=== Backup completed successfully ===')
return 0
print('=== Backup FAILED verification — do not rely on this archive ===')
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
return 1
+121
View File
@@ -0,0 +1,121 @@
"""Obtain a Google Drive refresh token for the application.
Run this once from a development machine that has a browser. It opens a local
OAuth redirect server, asks you to log in, and prints the refresh token.
Usage:
python app/supporting_scripts/get_refresh_token.py
Requires:
pip install google-auth-oauthlib
Before running:
1. Create a Google Cloud project and enable the Drive API.
2. Create a **Desktop** OAuth 2.0 client ID.
3. Add your email as a test user in the OAuth consent screen.
4. Run this script and paste the values when prompted.
"""
import os
import sys
def _prompt(label, env_name, default=""):
"""Ask the user for a value, accepting an env-var fallback or a default."""
candidate = os.getenv(env_name) or default
hint = f" [{candidate}]" if candidate else ""
raw = input(f"{label}{hint}: ").strip()
if raw:
return raw
if candidate:
return candidate
return default
def _validate_client_id(client_id):
"""Warn if the client ID doesn't look like a real OAuth client ID."""
if client_id == "YOUR_CLIENT_ID.apps.googleusercontent.com":
print("[ERROR] You must provide a real client ID from the Google Cloud console.")
return False
if not client_id.endswith(".apps.googleusercontent.com"):
print(
f"[WARNING] Client ID should end with '.apps.googleusercontent.com'.\n"
f" Got: {client_id!r}\n"
f" If this is a placeholder, the OAuth request will fail.\n"
)
return True
def _validate_client_secret(secret):
if secret == "YOUR_CLIENT_SECRET":
print("[ERROR] You must provide a real client secret from the Google Cloud console.")
return False
# The GOCSPX- prefix is now used by every client type, so it no longer
# tells us anything about whether the client was created as a Desktop app.
return True
def main():
print("=" * 60)
print("Google Drive Refresh Token — Setup")
print("=" * 60)
print()
# When both env vars are set, skip prompts entirely for headless runs.
env_id = os.getenv("GOOGLE_DRIVE_CLIENT_ID")
env_secret = os.getenv("GOOGLE_DRIVE_CLIENT_SECRET")
if env_id and env_secret:
client_id = env_id
client_secret = env_secret
print(f"Using Client ID from environment: {client_id[:30]}...")
else:
print("Enter your Google Cloud OAuth credentials.")
print("You can also set GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET")
print("environment variables and re-run to skip the prompts.")
print()
client_id = _prompt("Client ID", "GOOGLE_DRIVE_CLIENT_ID")
client_secret = _prompt("Client Secret", "GOOGLE_DRIVE_CLIENT_SECRET")
if not _validate_client_id(client_id) or not _validate_client_secret(client_secret):
sys.exit(1)
print()
print("Opening browser to authenticate with Google...")
print("(If it doesn't open, copy the URL below into your browser.)")
print()
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPE = "https://www.googleapis.com/auth/drive.file"
client_config = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
flow = InstalledAppFlow.from_client_config(client_config, scopes=[SCOPE])
credentials = flow.run_local_server(host="localhost", port=8080, open_browser=True)
print()
print("=" * 60)
print("SUCCESS — copy these values into your .env file:")
print("=" * 60)
print()
print("DOCUMENT_STORAGE_BACKEND=google_drive")
print(f"GOOGLE_DRIVE_CLIENT_ID={credentials.client_id}")
print(f"GOOGLE_DRIVE_CLIENT_SECRET={client_secret}")
print(f"GOOGLE_DRIVE_REFRESH_TOKEN={credentials.refresh_token}")
print("GOOGLE_DRIVE_FOLDER_ID=<get this from the Drive folder URL>")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+61 -2
View File
@@ -29,10 +29,13 @@ Usage
# 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 found — the report says what
1 drift or requested data risk found — the report says what
2 could not connect or read the catalogue
Reading the output
@@ -315,6 +318,42 @@ def find_seed_accounts(engine):
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(
@@ -327,6 +366,11 @@ def main(argv=None):
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:
@@ -375,9 +419,24 @@ def main(argv=None):
)
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 else 0
return 1 if findings or duplicate_discord_identities else 0
if __name__ == '__main__': # pragma: no cover
+33 -17
View File
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
- Debug mode status
- HTTPS configuration
- Dependency vulnerabilities
- Database connectivity
- Required database configuration
Usage:
python security_scan.py [--url http://localhost:5000]
@@ -19,6 +19,10 @@ import subprocess
import sys
import urllib.request
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
def check_environment():
@@ -31,8 +35,8 @@ def check_environment():
print('1. ENVIRONMENT VARIABLES CHECK')
print('=' * 60)
critical_vars = ['SECRET_KEY']
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
recommended_vars = ['CORS_ALLOWED_ORIGINS']
all_ok = True
for var in critical_vars:
@@ -58,7 +62,8 @@ def check_environment():
# Check FLASK_DEBUG
debug = os.getenv('FLASK_DEBUG', 'false').lower()
if debug == 'true':
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
all_ok = False
else:
print('[OK] FLASK_DEBUG is disabled')
@@ -91,10 +96,11 @@ def check_https_headers(url):
all_ok = True
try:
# Create a context that doesn't verify SSL (for local testing)
# Keep the default certificate and hostname verification. A scanner
# that accepts an invalid certificate can validate headers while the
# transport itself is impersonated. Local runs without TLS should use
# http:// explicitly or opt out with --skip-http.
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method='HEAD')
@@ -148,9 +154,9 @@ def check_https_headers(url):
all_ok = False
except urllib.error.URLError as e:
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
print('[SKIP] Run with --url <application_url> to check headers')
return True # Not a failure, just can't check
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
return False
return all_ok
@@ -167,7 +173,15 @@ def check_dependencies():
try:
result = subprocess.run(
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
[
sys.executable,
'-m',
'pip_audit',
'--requirement',
str(REQUIREMENTS_FILE),
'--format',
'json',
],
capture_output=True,
text=True,
timeout=60,
@@ -195,14 +209,16 @@ def check_dependencies():
if result.stdout:
print(f'[INFO] {result.stdout.strip()}')
if result.stderr:
print(f'[WARN] {result.stderr.strip()}')
return True
print(f'[FAIL] {result.stderr.strip()}')
else:
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
return False
except FileNotFoundError:
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
return True
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
return False
except subprocess.TimeoutExpired:
print('[WARN] pip-audit timed out')
return True
print('[FAIL] pip-audit timed out')
return False
def check_file_permissions():
+12
View File
@@ -68,6 +68,12 @@
</a>
</li>
{% endif %}
<li>
<a href="{{ url_for('objectives.list_objectives') }}" class="{% if request.endpoint and 'objectives' in request.endpoint %}active{% endif %}">
<i class="fas fa-bullseye"></i>
<span>{{ _('Objectives') }}</span>
</a>
</li>
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
@@ -90,6 +96,12 @@
<span>{{ _('Manage Users') }}</span>
</a>
</li>
<li>
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
<i class="fas fa-cogs"></i>
<span>Admin Panel</span>
</a>
</li>
{% endif %}
{% if current_user.role == 'player' %}
<li>
+294
View File
@@ -0,0 +1,294 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Panel{% endblock %}
{% block page_title %}Admin Panel{% endblock %}
{% block content %}
<!-- Stats Bar -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-primary">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_users }}</h3>
<p>Total Users</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-success">
<i class="fas fa-user"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_players }}</h3>
<p>Players</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-warning">
<i class="fas fa-shield-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_org_teams }}</h3>
<p>Org Teams</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-info">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.active_tryouts }}</h3>
<p>Active Tryouts</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-secondary">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3>{{ stats.upcoming_tryouts }}</h3>
<p>Upcoming</p>
</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Left column -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-toggle-{% if settings.tryouts_open %}on{% else %}off{% endif %}"></i> Tryouts Access</h3>
</div>
<div class="card-body">
<p>
Status:
<span class="badge badge-{% if settings.tryouts_open %}success{% else %}danger{% endif %}">
{% if settings.tryouts_open %}OPEN{% else %}CLOSED{% endif %}
</span>
</p>
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
{% if settings.tryouts_open %}
Coaches and managers can create and modify tryouts.
{% else %}
Only admins can create or modify tryouts. Coaches and managers are locked out.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-toggle-{% if settings.document_uploads_enabled %}on{% else %}off{% endif %}"></i> Document Uploads</h3>
</div>
<div class="card-body">
<p>
Status:
<span class="badge badge-{% if settings.document_uploads_enabled %}success{% else %}danger{% endif %}">
{% if settings.document_uploads_enabled %}ENABLED{% else %}DISABLED{% endif %}
</span>
</p>
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
{% if settings.document_uploads_enabled %}
Managers and coaches can upload contracts. Players can upload signed contracts.
{% else %}
Only admins can upload or sign contracts. Other roles are blocked.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_document_uploads') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.document_uploads_enabled %}warning{% else %}success{% endif %}">
{% if settings.document_uploads_enabled %}Disable Uploads{% else %}Enable Uploads{% endif %}
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> Season Management</h3>
</div>
<div class="card-body">
<p>
Season:
<span class="badge badge-{% if settings.season_active %}success{% else %}info{% endif %}">
{% if settings.season_active %}ACTIVE{% else %}INACTIVE{% endif %}
</span>
</p>
<p><strong>Name:</strong> {{ settings.season_name }}</p>
<p><strong>Start:</strong> {{ settings.season_start }}</p>
<p><strong>End:</strong> {{ settings.season_end }}</p>
{% if not settings.season_active %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.start_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Season Name</label>
<input type="text" name="season_name" class="form-input" placeholder="e.g. Fall 2026" required>
</div>
<div class="form-group">
<label>Start Date</label>
<input type="date" name="season_start" class="form-input" required>
</div>
<button type="submit" class="btn btn-success">Begin Season</button>
</form>
{% else %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.end_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>End Date</label>
<input type="date" name="season_end" class="form-input" required>
</div>
<button type="submit" class="btn btn-warning">End Season</button>
</form>
{% endif %}
</div>
</div>
<!-- Wipe Teams -->
<div class="card" style="border-left: 4px solid var(--danger);">
<div class="card-header">
<h3><i class="fas fa-trash-alt" style="color: var(--danger);"></i> Wipe Teams for New Season</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
This removes all players from organization teams and deletes all regular-season matches.
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
</p>
<form method="POST" action="{{ url_for('admin.wipe_teams') }}"
data-confirm-input="confirm" data-confirm-value="WIPE"
data-confirm-input-message="You must type WIPE to confirm."
data-confirm="This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Type <strong>WIPE</strong> to confirm:</label>
<input type="text" name="confirm" class="form-input" placeholder="WIPE" autocomplete="off" required>
</div>
<button type="submit" class="btn btn-danger">Wipe Team Rosters</button>
</form>
</div>
</div>
<!-- Manual Backup -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-database"></i> Manual Backup</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
Creates a full PostgreSQL database dump (.sql file). Stored on the server.
</p>
<form method="POST" action="{{ url_for('admin.create_backup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Notes (optional)</label>
<input type="text" name="notes" class="form-input" placeholder="What's this backup for?">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Backup Now
</button>
</form>
</div>
</div>
<!-- Backup History -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-history"></i> Backup History & Restore</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Filename</th>
<th>Size</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in backups %}
<tr>
<td>{{ b.created_at.strftime('%Y-%m-%d %H:%M') if b.created_at else '—' }}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ b.filename }}">{{ b.filename }}</td>
<td>{{ (b.size_bytes / 1024)|round(1) }} KB</td>
<td><span class="badge badge-info">{{ b.backup_type }}</span></td>
<td style="white-space: nowrap;">
<a href="{{ url_for('admin.download_backup', backup_id=b.id) }}" class="btn btn-sm btn-primary" title="Download">
<i class="fas fa-download"></i>
</a>
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
data-confirm="Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
<i class="fas fa-undo"></i>
</button>
</form>
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
data-confirm="Delete backup {{ b.filename }}?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Audit Log -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Action</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{% for entry in audit_logs %}
<tr>
<td>{{ entry.created_at.strftime('%Y-%m-%d %H:%M') if entry.created_at else '—' }}</td>
<td>{{ entry.user.username if entry.user else 'System' }}</td>
<td><span class="badge badge-info">{{ entry.action }}</span></td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
<td>{{ entry.ip_address or '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "layouts/base.html" %}
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
{% block page_title %}Evaluate Players{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% for entry in players %}
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
{% endfor %}
{% set positions = game_positions.get(tryout.game, []) %}
<div class="batch-eval-grid">
{% for entry in players %}
<div class="card">
<div class="card-header">
<h3>
<i class="fas fa-user"></i> {{ entry.player.username }}
{% if entry.existing %}
<span class="badge badge-success">Already Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</h3>
</div>
<div class="card-body">
<div class="eval-player-info mb-4">
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
<div>
<h3>{{ entry.player.username }}</h3>
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
</div>
</div>
{% set pid = entry.player.id %}
{% set existing = entry.existing %}
{% set existing_scores = entry.existing_scores %}
<div class="form-row">
{% for field_name, label in evaluation_criteria %}
<div class="form-group col-4">
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
<div class="score-input">
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" data-mirror>
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
</div>
</div>
{% endfor %}
</div>
<div class="form-row">
<div class="form-group col-12">
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
{% if positions %}
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
<option value="">-- Select Position --</option>
{% for pos in positions %}
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
{% endif %}
</div>
</div>
<div class="form-group">
<label for="comments_{{ pid }}">Comments</label>
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="form-actions">
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save All Evaluations
</button>
</div>
</form>
{% endblock %}
-241
View File
@@ -1,241 +0,0 @@
{% extends "layouts/base.html" %}
{% 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>
</div>
<div class="card-body">
<div class="availability-grid" id="availability-grid">
<p class="text-muted">{{ _('Loading availability grid...') }}</p>
</div>
<div class="form-actions mt-4">
<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" data-action="clear-availability">
<i class="fas fa-trash"></i> {{ _('Clear All') }}
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
.availability-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
margin-top: 10px;
}
.day-column {
background: var(--bg-secondary);
border-radius: 8px;
padding: 10px;
min-height: 300px;
}
.day-header {
text-align: center;
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
color: var(--primary);
}
.time-slot {
padding: 6px 8px;
margin: 4px 0;
border-radius: 4px;
font-size: 0.8rem;
text-align: center;
cursor: pointer;
transition: var(--transition);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.time-slot:hover {
background: var(--primary-light);
border-color: var(--primary);
}
.time-slot.selected {
background: var(--primary);
color: white;
border-color: var(--primary-dark);
}
.time-slot.selected:hover {
background: var(--danger);
}
@media (max-width: 768px) {
.availability-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.availability-grid {
grid-template-columns: 1fr;
}
}
</style>
<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++) {
for (let m = 0; m < 60; m += 30) {
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
const displayHour = h > 12 ? h - 12 : h;
const displayAmpm = h >= 12 ? 'PM' : 'AM';
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
TIME_SLOTS.push({ time: timeStr, display: displayTime });
}
}
// Day names
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// Track selected slots: {day_of_week: [time_strings]}
let selectedSlots = {};
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadExistingAvailability();
renderGrid();
});
function loadExistingAvailability() {
// Load from existing data
{% for av in existing_availability %}
if (!selectedSlots[{{ av.day_of_week }}]) {
selectedSlots[{{ av.day_of_week }}] = [];
}
selectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
{% endfor %}
}
function renderGrid() {
const grid = document.getElementById('availability-grid');
let html = '';
DAYS.forEach((day, dayIndex) => {
html += '<div class="day-column">';
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
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 + '" data-action="toggle-slot">' + slot.display + '</div>';
});
html += '</div>';
});
grid.innerHTML = html;
}
function toggleSlot(element) {
const dayOfWeek = Number(element.getAttribute('data-day'));
const timeStr = element.getAttribute('data-time');
if (!selectedSlots[dayOfWeek]) {
selectedSlots[dayOfWeek] = [];
}
const index = selectedSlots[dayOfWeek].indexOf(timeStr);
if (index === -1) {
selectedSlots[dayOfWeek].push(timeStr);
element.classList.add('selected');
} else {
selectedSlots[dayOfWeek].splice(index, 1);
element.classList.remove('selected');
}
}
function saveAvailability() {
const slots = [];
for (let day in selectedSlots) {
selectedSlots[day].forEach(time => {
slots.push({ day_of_week: parseInt(day), start_time: time });
});
}
fetch('{{ url_for("users.manage_coach_availability") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ slots: slots })
})
.then(response => response.json())
.then(data => {
if (data.success) {
flash('Availability saved!', 'success');
} else {
flash(data.error || 'Error saving availability.', 'danger');
}
})
.catch(function(error) {
console.error('Save error:', error);
flash('Error saving availability.', 'danger');
});
}
function clearAllAvailability() {
if (!confirm('Are you sure you want to clear all your availability slots?')) {
return;
}
fetch('{{ url_for("users.clear_coach_availability") }}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
})
.then(response => response.json())
.then(data => {
if (data.success) {
selectedSlots = {};
renderGrid();
flash('Availability cleared!', 'success');
}
});
}
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" data-action="remove-element">&times;</button>';
flashContainer.appendChild(alert);
}
// Auto-save on change (debounced)
let saveTimeout;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('time-slot')) {
clearTimeout(saveTimeout);
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 %}
+2 -2
View File
@@ -94,8 +94,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<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>
<input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
+5 -6
View File
@@ -79,14 +79,13 @@
</div>
<div class="form-row">
<div class="form-group col-6">
<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') }}">
</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>
<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">
+20 -8
View File
@@ -450,11 +450,18 @@
var playerDataById = {
player_data: {
{%- for p in all_players %}
{{ p.id }}: "{{ p.username | escape }}",
{{ p.id }}: {{ p.username | tojson }},
{%- endfor %}
}
};
var HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (character) {
return HTML_ESCAPES[character];
});
}
// All registered player IDs
var allRegisteredPlayers = [
{%- for p in all_players %}
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
}
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
'data-action="toggle-time-slot">' +
slotData.display +
'<span class="merged-disponibility-count">' + count + '</span>' +
'</div>';
@@ -920,7 +927,7 @@ function updatePlayerPool() {
var availabilityClass = isAvailable ? 'available' : 'unavailable';
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
html += '<span class="player-name">' + playerName + '</span>';
html += '<span class="player-name">' + escapeHtml(playerName) + '</span>';
html += '<div class="player-actions">';
html += '<button type="button" class="btn btn-sm btn-primary" data-action="assign-team" data-team-side="1">{{ _('T1') }}</button>';
html += '<button type="button" class="btn btn-sm btn-secondary" data-action="assign-team" data-team-side="2">{{ _('T2') }}</button>';
@@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) {
if (!playerName) return;
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) {
registerActions({
'toggle-match-type': toggleMatchType,
'clear-time-selection': clearTimeSelection,
'toggle-time-slot': function (element) {
toggleTimeSlot(parseInt(element.getAttribute('data-day'), 10),
element.getAttribute('data-time'),
element);
},
'update-randomize-preview': updateRandomizePreview,
'randomize-teams': randomizeTeams,
'return-to-pool': returnToPool,
+348
View File
@@ -0,0 +1,348 @@
{% extends "layouts/base.html" %}
{% block title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %} - UdeS team manager{% endblock %}
{% block page_title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {% if edit_mode %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %}</span>{% endblock %}
{% block content %}
<div class="objective-editor">
<div class="objective-editor-form">
<div class="card">
<div class="card-header"><h3><i class="fas fa-edit"></i> {% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('Create Objective') }}{% endif %}</h3></div>
<div class="card-body">
<form method="POST" action="{% if edit_mode %}{{ url_for('objectives.update_objective', objective_id=objective.id) }}{% else %}{{ url_for('objectives.create_objective') }}{% endif %}" class="form" id="objForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-section">
<h4><i class="fas fa-info-circle"></i> {{ _('1. General Information') }}</h4>
<div class="form-row">
<div class="form-group col-6">
<label for="obj_type">{{ _('Type') }}</label>
<select name="type" id="obj_type" class="form-select" onchange="updatePreview();toggleAssignment()">
{% for val, label in objective_types %}<option value="{{ val }}"{% if objective and objective.type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="obj_status">{{ _('Status') }}</label>
<select name="status" id="obj_status" class="form-select" onchange="updatePreview()">
{% for val, label in objective_statuses %}<option value="{{ val }}"{% if objective and objective.status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="obj_title">{{ _('Title') }} <span class="required">*</span></label>
<input type="text" name="title" id="obj_title" class="form-input" required value="{{ objective.title if objective else '' }}" placeholder="{{ _('e.g. Improve headshot accuracy') }}" oninput="updatePreview()">
</div>
<div class="form-group">
<label for="obj_description">{{ _('Description') }}</label>
<textarea name="description" id="obj_description" rows="3" class="form-textarea" placeholder="{{ _('Describe the objective...') }}" oninput="updatePreview()">{{ objective.description if objective else '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group col-6" id="playerField">
<label for="obj_player_id">{{ _('Assigned to (Player)') }}</label>
<select name="player_id" id="obj_player_id" class="form-select" onchange="updatePreview()">
<option value="">{{ _('-- Select a player --') }}</option>
{% for p in players %}<option value="{{ p.id }}"{% if objective and objective.player_id == p.id %} selected{% endif %}>{{ p.username }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6" id="teamField">
<label for="obj_team_id">{{ _('Team') }}</label>
<select name="team_id" id="obj_team_id" class="form-select" onchange="updatePreview()">
<option value="">{{ _('-- Select a team --') }}</option>
{% for t in teams %}<option value="{{ t.id }}"{% if objective and objective.team_id == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" name="is_template" id="obj_is_template" value="1"{% if objective and objective.is_template %} checked{% endif %} onchange="updatePreview();toggleAssignment()"> {{ _('This is a template (reusable pattern)') }}</label>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-chart-line"></i> {{ _('2. KPI & Target') }}</h4>
<div class="form-row">
<div class="form-group col-4">
<label for="obj_kpi_metric">{{ _('KPI metric') }}</label>
<input type="text" name="kpi_metric" id="obj_kpi_metric" class="form-input" value="{{ objective.kpi_metric if objective else '' }}" placeholder="{{ _('e.g. HS rate, KDA, CS/min') }}" oninput="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_kpi_target">{{ _('Target value') }}</label>
<input type="number" step="0.01" name="kpi_target" id="obj_kpi_target" class="form-input" value="{{ objective.kpi_target if objective else '' }}" placeholder="{{ _('e.g. 40') }}" oninput="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_kpi_unit">{{ _('Unit') }}</label>
<input type="text" name="kpi_unit" id="obj_kpi_unit" class="form-input" value="{{ objective.kpi_unit if objective else '' }}" placeholder="{{ _('e.g. points, percentage') }}" oninput="updatePreview()">
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-calendar-alt"></i> {{ _('3. Deadline & Priority') }}</h4>
<div class="form-row">
<div class="form-group col-4">
<label for="obj_start_date">{{ _('Start date') }}</label>
<input type="date" name="start_date" id="obj_start_date" class="form-input" value="{{ objective.start_date.strftime('%Y-%m-%d') if objective and objective.start_date else '' }}" onchange="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_due_date">{{ _('Deadline') }} <span class="required">*</span></label>
<input type="date" name="due_date" id="obj_due_date" class="form-input" required value="{{ objective.due_date.strftime('%Y-%m-%d') if objective else '' }}" onchange="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_priority">{{ _('Priority') }}</label>
<select name="priority" id="obj_priority" class="form-select" onchange="updatePreview()">
{% for val, label in objective_priorities %}<option value="{{ val }}"{% if objective and objective.priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-bell"></i> {{ _('4. Tracking & Alerts') }}</h4>
<div class="form-row">
<div class="form-group col-6">
<label for="obj_check_frequency">{{ _('Check frequency') }}</label>
<select name="check_frequency" id="obj_check_frequency" class="form-select">
<option value="">{{ _('-- None --') }}</option>
{% for val, label in objective_frequencies %}<option value="{{ val }}"{% if objective and objective.check_frequency == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="obj_progress">{{ _('Progress') }} (<span id="progressVal">{{ objective.progress if objective else 0 }}</span>%)</label>
<input type="range" id="obj_progress" name="progress" min="0" max="100" value="{{ objective.progress if objective else 0 }}" class="form-range" oninput="document.getElementById('progressVal').textContent=this.value;updatePreview()">
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-cog"></i> {{ _('5. Advanced Settings') }}</h4>
<div class="form-group">
<label for="obj_category">{{ _('Category') }}</label>
<input type="text" name="category" id="obj_category" class="form-input" value="{{ objective.category if objective else '' }}" placeholder="{{ _('e.g. Mechanics, Teamplay, Communication') }}">
</div>
<div class="form-group">
<label for="obj_tags">{{ _('Tags') }}</label>
<input type="text" name="tags" id="obj_tags" class="form-input" value="{{ objective.tags if objective else '' }}" placeholder="{{ _('Separate with commas') }}">
</div>
</div>
<div class="form-actions">
<a href="{{ url_for('objectives.list_objectives') }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> {% if edit_mode %}{{ _('Update') }}{% else %}{{ _('Create') }}{% endif %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="objective-editor-preview">
<div class="card mb-4" id="previewCard">
<div class="card-header"><h3><i class="fas fa-eye"></i> {{ _('Player Preview') }}</h3></div>
<div class="card-body">
<div id="previewContent">
<h4 id="previewTitle">{{ objective.title if objective else _('Objective title') }}</h4>
<p id="previewDesc" class="text-muted small">{{ objective.description if objective else '' }}</p>
<div class="objective-tags" id="previewTags">{% if objective and objective.tags %}{% for tag in objective.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}{% endif %}</div>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span id="previewAssignee" class="preview-value">{% if objective and objective.player %}{{ objective.player.username }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('KPI') }}</span><span id="previewKpi" class="preview-value">{% if objective and objective.kpi_metric %}{{ objective.kpi_metric }}: {{ objective.kpi_target }}{{ objective.kpi_unit }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span id="previewDeadline" class="preview-value">{% if objective and objective.due_date %}{{ objective.due_date.strftime('%Y-%m-%d') }}{% endif %}</span></div>
</div>
<div class="progress-section">
<span class="preview-label">{{ _('Progress') }}</span>
<div class="progress-bar-container" style="width:100%"><div class="progress-bar" id="previewBar" style="width:{{ objective.progress if objective else 0 }}%"></div></div>
<strong><span id="previewProgress">{{ objective.progress if objective else 0 }}</span>%</strong>
</div>
</div>
<div class="mt-3">
<span id="previewStatusBadge" class="badge">{% if objective %}{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}{% else %}{{ _('Active') }}{% endif %}</span>
<span id="previewPriorityBadge" class="badge">{% if objective %}{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(objective.priority,objective.priority) }}{% else %}{{ _('Medium') }}{% endif %}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if edit_mode and objective.kpi_target %}
<div class="mt-2 text-center" id="predictionInfo"></div>
{% endif %}
</div>
</div>
{% if edit_mode %}
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;">
<input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value') }}" required>
</div>
<div class="form-group" style="flex:2;">
<input type="text" name="note" class="form-input" placeholder="{{ _('Note (optional)') }}">
</div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}
<table class="table mt-2">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead>
<tbody>
{% for e in entries %}
<tr><td>{{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}</td><td><strong>{{ e.value }}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_comment', objective_id=objective.id) }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<textarea name="content" rows="2" class="form-textarea" placeholder="{{ _('Add a comment...') }}" required></textarea>
</div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-paper-plane"></i> {{ _('Comment') }}</button>
</form>
{% if comments %}
<div class="comments-list mt-2">
{% for c in comments %}
<div class="comment-item">
<strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<p>{{ c.content }}</p>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div>
<div class="card-body">
{% if personal_notes %}
<h5>{{ _('Player Notes') }}</h5>
{% for n in personal_notes %}
<div class="comment-item">
<strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span>
<p>{{ n.content[:200] }}</p>
</div>
{% endfor %}
{% endif %}
{% if team_notes %}
<h5 class="mt-2">{{ _('Team Notes') }}</h5>
{% for n in team_notes %}
<div class="comment-item">
<strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span>
<p>{{ n.content[:200] }}</p>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if templates %}
<div class="card">
<div class="card-header"><h3><i class="fas fa-copy"></i> {{ _('Templates') }}</h3></div>
<div class="card-body" id="templatesList">
{% for t in templates %}
<div class="template-item">
<h5>{{ t.title }}</h5>
<p class="text-muted small">{{ t.description[:120] if t.description else '' }}</p>
<button type="button" class="btn btn-sm btn-outline-primary use-template-btn" onclick="applyTemplate({{ t.id }})"><i class="fas fa-arrow-right"></i> {{ _('Use this template') }}</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var templateDataUrl = '{{ url_for("objectives.objective_data", objective_id=0) }}';
var templates = {};
{% for t in templates %}
(function(id) {
fetch(templateDataUrl.replace('/0/', '/' + id + '/')).then(function(r) { return r.json(); }).then(function(d) { templates[id] = d; });
})({{ t.id }});
{% endfor %}
function toggleAssignment() {
var t = document.getElementById('obj_type').value;
var isTmpl = document.getElementById('obj_is_template').checked;
var pf = document.getElementById('playerField');
var tf = document.getElementById('teamField');
if (isTmpl) { pf.style.display = 'none'; tf.style.display = 'none'; return; }
pf.style.display = t === 'team' ? 'none' : '';
tf.style.display = t === 'individual' ? 'none' : '';
}
function applyTemplate(id) {
var t = templates[id]; if (!t) return;
document.getElementById('obj_title').value = t.title || '';
document.getElementById('obj_description').value = t.description || '';
document.getElementById('obj_type').value = t.type || 'individual';
document.getElementById('obj_status').value = t.status || 'active';
document.getElementById('obj_priority').value = t.priority || 'medium';
document.getElementById('obj_kpi_metric').value = t.kpi_metric || '';
document.getElementById('obj_kpi_target').value = t.kpi_target || '';
document.getElementById('obj_kpi_unit').value = t.kpi_unit || '';
document.getElementById('obj_start_date').value = t.start_date || '';
document.getElementById('obj_due_date').value = '';
document.getElementById('obj_check_frequency').value = t.check_frequency || '';
document.getElementById('obj_category').value = t.category || '';
document.getElementById('obj_tags').value = t.tags || '';
document.getElementById('obj_progress').value = 0;
document.getElementById('progressVal').textContent = '0';
toggleAssignment(); updatePreview();
}
function updatePreview() {
var title = document.getElementById('obj_title').value || '';
var desc = document.getElementById('obj_description').value || '';
var status = document.getElementById('obj_status').value;
var priority = document.getElementById('obj_priority').value;
var kpiMetric = document.getElementById('obj_kpi_metric').value;
var kpiTarget = document.getElementById('obj_kpi_target').value;
var kpiUnit = document.getElementById('obj_kpi_unit').value;
var deadline = document.getElementById('obj_due_date').value;
var progress = document.getElementById('obj_progress').value;
var sel = document.getElementById('obj_player_id');
var playerName = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '-';
document.getElementById('previewTitle').textContent = title || '{{ _('Objective title') | e }}';
document.getElementById('previewDesc').textContent = desc;
document.getElementById('previewAssignee').textContent = playerName;
document.getElementById('previewKpi').textContent = kpiMetric ? kpiMetric + ': ' + kpiTarget + (kpiUnit || '') : '-';
document.getElementById('previewDeadline').textContent = deadline;
document.getElementById('previewBar').style.width = progress + '%';
document.getElementById('previewProgress').textContent = progress;
var sl = {}, sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'};
var pl = {}, pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'};
{% for val, label in objective_statuses %}sl['{{ val }}'] = '{{ label | e }}';{% endfor %}
{% for val, label in objective_priorities %}pl['{{ val }}'] = '{{ label | e }}';{% endfor %}
document.getElementById('previewStatusBadge').textContent = sl[status] || status;
document.getElementById('previewStatusBadge').className = 'badge ' + (sc[status] || 'badge-secondary');
document.getElementById('previewPriorityBadge').textContent = pl[priority] || priority;
document.getElementById('previewPriorityBadge').className = 'badge ' + (pc[priority] || 'badge-secondary');
drawChart(progress, kpiTarget);
}
function drawChart(progress, target) {
var svg = document.getElementById('progressChart');
var w = 400, h = 200, pt = 20, pr = 20, pb = 30, pl = 40;
var cw = w - pl - pr, ch = h - pt - pb;
var prog = Math.min(parseInt(progress) || 0, 100);
var py = pt + ch * (1 - prog / 100);
var tgt = parseFloat(target) || 100;
var tgtScale = Math.max(tgt, prog);
var tgtNorm = tgt / (tgtScale);
var ty = pt + ch * (1 - tgtNorm);
var html = '<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="transparent"/>';
html += '<line x1="' + pl + '" y1="' + (pt + ch) + '" x2="' + (pl + cw) + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<line x1="' + pl + '" y1="' + pt + '" x2="' + pl + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + 5) + '" fill="#999" font-size="10">' + tgtScale + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch / 2 + 4) + '" fill="#999" font-size="10">' + (tgtScale / 2).toFixed(1) + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch + 4) + '" fill="#999" font-size="10">0</text>';
html += '<text x="' + pl + '" y="' + (pt + ch + 18) + '" fill="#999" font-size="10">{{ _('Now') | e }}</text>';
html += '<text x="' + (pl + cw - 50) + '" y="' + (pt + ch + 18) + '" fill="#999" font-size="10">{{ _('Deadline') | e }}</text>';
html += '<line x1="' + pl + '" y1="' + ty + '" x2="' + (pl + cw) + '" y2="' + ty + '" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="4,3"/>';
html += '<text x="' + (pl + cw + 4) + '" y="' + (ty + 4) + '" fill="#4CAF50" font-size="9">{{ _('Target') | e }}</text>';
html += '<polygon points="' + pl + ',' + (pt + ch) + ' ' + pl + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + (pt + ch) + '" fill="rgba(59,130,246,0.15)"/>';
html += '<line x1="' + pl + '" y1="' + py + '" x2="' + (pl + (prog / 100) * cw) + '" y2="' + py + '" stroke="#3B82F6" stroke-width="2"/>';
html += '<circle cx="' + (pl + (prog / 100) * cw) + '" cy="' + py + '" r="4" fill="#3B82F6"/>';
html += '<text x="' + (pl + (prog / 100) * cw - 12) + '" y="' + (py - 8) + '" fill="#3B82F6" font-size="10" font-weight="bold">' + prog + '%</text>';
svg.innerHTML = html;
}
document.addEventListener('DOMContentLoaded', function() { toggleAssignment(); updatePreview(); });
</script>
{% endblock %}
+125
View File
@@ -0,0 +1,125 @@
{% extends "layouts/base.html" %}
{% block title %}{{ objective.title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ objective.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {{ objective.title }}</span>{% endblock %}
{% block content %}
<div class="row">
<div class="col-8">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ objective.title }}</h3></div>
<div class="card-body">
<p>{{ objective.description or '' }}</p>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Type') }}</span><span class="preview-value">{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span class="preview-value">{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Category') }}</span><span class="preview-value">{{ objective.category or '-' }}</span></div>
</div>
<div class="mt-3">
<span id="statusBadge" class="badge">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span>
<span id="priorityBadge" class="badge">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if objective.kpi_target %}<div class="mt-2 text-center" id="predictionInfo"></div>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;"><input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value (optional)') }}"></div>
<div class="form-group" style="flex:2;"><input type="text" name="note" class="form-input" placeholder="{{ _('Note or comment') }}"></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}<table class="table mt-2"><thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead><tbody>
{% for e in entries %}<tr><td>{{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}</td><td><strong>{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>{% endfor %}</tbody></table>{% else %}<p class="text-muted mt-2">{{ _('No measurements recorded yet.') }}</p>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">{% if comments %}<div class="comments-list">{% for c in comments %}<div class="comment-item"><strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span><p>{{ c.content }}</p></div>{% endfor %}</div>{% else %}<p class="text-muted">{{ _('No comments yet.') }}</p>{% endif %}</div>
</div>
</div>
<div class="col-4">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-info-circle"></i> {{ _('Details') }}</h3></div>
<div class="card-body">
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Priority') }}</span><span class="preview-value">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Status') }}</span><span class="preview-value">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Start date') }}</span><span class="preview-value">{{ objective.start_date.strftime('%Y-%m-%d') if objective.start_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span class="preview-value">{{ objective.due_date.strftime('%Y-%m-%d') if objective.due_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Progress') }}</span><span class="preview-value">{{ objective.progress }}%</span></div>
</div>
{% if objective.kpi_target %}<div class="progress-section mt-2"><span class="preview-label">{{ _('KPI Target') }}</span><strong>{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}</strong></div>{% endif %}
{% if can_edit %}<a href="{{ url_for('objectives.edit_objective', objective_id=objective.id) }}" class="btn btn-sm btn-primary mt-3"><i class="fas fa-edit"></i> {{ _('Edit') }}</a>{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}<div class="card mb-4"><div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div><div class="card-body">{% if personal_notes %}<h5>{{ _('Personal Notes') }}</h5>{% for n in personal_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}{% if team_notes %}<h5 class="mt-2">{{ _('Team Notes') }}</h5>{% for n in team_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}</div></div>{% endif %}
</div>
</div>{% endblock %}
{% block scripts %}
<script>
function drawChart(progress, target) {
var svg = document.getElementById('progressChart');
if (!svg) return;
var w = 400, h = 200, pt = 20, pr = 20, pb = 30, pl = 40;
var cw = w - pl - pr, ch = h - pt - pb;
var prog = Math.min(parseInt(progress) || 0, 100);
var py = pt + ch * (1 - prog / 100);
var tgt = parseFloat(target) || 100;
var tgtScale = Math.max(tgt, prog);
var ty = pt + ch * (1 - tgt / tgtScale);
var html = '<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="transparent"/>';
html += '<line x1="' + pl + '" y1="' + (pt + ch) + '" x2="' + (pl + cw) + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<line x1="' + pl + '" y1="' + pt + '" x2="' + pl + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + 5) + '" fill="#999" font-size="10">' + tgtScale + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch / 2 + 4) + '" fill="#999" font-size="10">' + (tgtScale / 2).toFixed(1) + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch + 4) + '" fill="#999" font-size="10">0</text>';
html += '<line x1="' + pl + '" y1="' + ty + '" x2="' + (pl + cw) + '" y2="' + ty + '" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="4,3"/>';
html += '<text x="' + (pl + cw + 4) + '" y="' + (ty + 4) + '" fill="#4CAF50" font-size="9">{{ _('Target') | e }}</text>';
html += '<polygon points="' + pl + ',' + (pt + ch) + ' ' + pl + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + (pt + ch) + '" fill="rgba(59,130,246,0.15)"/>';
html += '<line x1="' + pl + '" y1="' + py + '" x2="' + (pl + (prog / 100) * cw) + '" y2="' + py + '" stroke="#3B82F6" stroke-width="2"/>';
html += '<circle cx="' + (pl + (prog / 100) * cw) + '" cy="' + py + '" r="4" fill="#3B82F6"/>';
svg.innerHTML = html;
fetchTrendAndEntries();
}
function fetchTrendAndEntries() {
var trendUrl = '{{ url_for("objectives.trend_data", objective_id=objective.id) }}';
var entriesUrl = '{{ url_for("objectives.list_entries", objective_id=objective.id) }}';
Promise.all([fetch(trendUrl).then(function(r){return r.json()}), fetch(entriesUrl).then(function(r){return r.json()})]).then(function(results) {
var trend = results[0], entries = results[1];
var infoEl = document.getElementById('predictionInfo');
if (trend.predicted_achievement_date) {
infoEl.innerHTML = '<strong>{{ _("Predicted achievement") }}:</strong> ' + new Date(trend.predicted_achievement_date).toLocaleDateString() + ' - <span class=\"' + (trend.confidence > 0.6 ? 'confidence-high' : trend.confidence > 0.3 ? 'confidence-medium' : 'confidence-low') + '\">{{ _("Confidence") }}: ' + (trend.confidence * 100).toFixed(0) + '%</span>';
}
if (entries && entries.length > 1) {
var svg = document.getElementById('progressChart');
var html = svg.innerHTML;
var w = 400, pl = 40, pt = 20, cw = 320, ch = 150;
var entryValues = entries.map(function(e){return parseFloat(e.value)||0;});
var maxVal = Math.max.apply(null, entryValues);
var range = maxVal || 1;
var stepX = cw / (entries.length - 1);
entryValues.forEach(function(v, i) {
var ex = pl + i * stepX;
var ey = pt + ch * (1 - v / range);
html += '<circle cx="' + ex + '" cy="' + ey + '" r="3" fill="#8B5CF6"/>';
if (i > 0) {
var pex = pl + (i - 1) * stepX;
var pey = pt + ch * (1 - entryValues[i-1] / range);
html += '<line x1="' + pex + '" y1="' + pey + '" x2="' + ex + '" y2="' + ey + '" stroke="#8B5CF6" stroke-width="1.5"/>';
}
});
svg.innerHTML = html;
}
}).catch(function(){});
}
document.addEventListener('DOMContentLoaded', function() { drawChart({{ objective.progress }}, '{{ objective.kpi_target if objective.kpi_target else '' }}'); });
</script>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "layouts/base.html" %}
{% import 'layouts/_pagination.html' as pager %}
{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Objective Management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / {{ _('Objectives') }}</span>{% endblock %}
{% block content %}
<div class="stats-grid mb-4">
<div class="stat-card"><div class="stat-icon bg-primary"><i class="fas fa-flag-checkered"></i></div><div class="stat-info"><h3>{{ count_total }}</h3><p>{{ _('Total') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-info"><i class="fas fa-chart-line"></i></div><div class="stat-info"><h3>{{ count_kpi }}</h3><p>{{ _('Custom KPIs') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-secondary"><i class="fas fa-copy"></i></div><div class="stat-info"><h3>{{ count_templates }}</h3><p>{{ _('Templates') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-success"><i class="fas fa-percentage"></i></div><div class="stat-info"><h3>{{ success_rate }}%</h3><p>{{ _('Success rate') }}</p></div></div>
</div>
<div class="card mb-4"><div class="card-body"><div class="objectives-toolbar">
<nav class="objectives-tabs" role="tablist">
<a href="{{ url_for('objectives.list_objectives', tab='all') }}" class="tab-link {% if tab == 'all' %}active{% endif %}">{{ _('All') }} ({{ count_total }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='kpi') }}" class="tab-link {% if tab == 'kpi' %}active{% endif %}">{{ _('Custom KPIs') }} ({{ count_kpi }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='modeles') }}" class="tab-link {% if tab in ('modeles','templates') %}active{% endif %}">{{ _('Templates') }} ({{ count_templates }})</a>
</nav>
<form method="GET" action="{{ url_for('objectives.list_objectives') }}" class="objectives-search-bar">
<input type="hidden" name="tab" value="{{ tab }}">
<div class="form-inline">
<div class="form-group" style="flex:1;min-width:200px;"><input type="text" name="search" class="form-input" placeholder="{{ _('Search an objective...') }}"></div>
<div class="form-group"><select name="status" class="form-select"><option value="">{{ _('Status') }}</option>{% for val, label in objective_statuses %}<option value="{{ val }}"{% if f_status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="priority" class="form-select"><option value="">{{ _('Priority') }}</option>{% for val, label in objective_priorities %}<option value="{{ val }}"{% if f_priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="type" class="form-select"><option value="">{{ _('Type') }}</option>{% for val, label in objective_types %}<option value="{{ val }}"{% if f_type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="team" class="form-select"><option value="">{{ _('Team') }}</option>{% for t in teams %}<option value="{{ t.id }}"{% if f_team == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-filter"></i> {{ _('Filters') }}</button>
</div></form>
</div></div></div>
<div class="card"><div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ _('Objectives') }}</h3>{% if can_edit %}<a href="{{ url_for('objectives.new_objective') }}" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('New Objective') }}</a>{% endif %}</div>
<div class="card-body"><div class="table-container"><table class="table objectives-table"><thead><tr>
<th>{{ _('Objective') }}</th><th>{{ _('Assigned to') }}</th><th>{{ _('Team') }}</th><th>{{ _('Priority') }}</th><th>{{ _('Status') }}</th><th>{{ _('Target KPI') }}</th><th>{{ _('Progress') }}</th><th>{{ _('Deadline') }}</th>{% if can_edit %}<th>{{ _('Actions') }}</th>{% endif %}
</tr></thead><tbody>
{% for obj in objectives.items %}<tr data-objective-id="{{ obj.id }}">
<td><strong>{{ obj.title }}</strong>{% if obj.tags %}<div class="objective-tags">{% for tag in obj.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}</div>{% endif %}{% if obj.kpi_metric %}<small class="text-muted d-block">{{ obj.kpi_metric }}</small>{% endif %}</td>
<td>{% if obj.player %}<span class="user-avatar avatar-sm">{{ obj.player.username[:2]|upper }}</span> {{ obj.player.username }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% if obj.team %}{{ obj.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}<span class="badge {{ pc.get(obj.priority,'badge-secondary') }}">{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}</span></td>
<td>{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}<span class="badge {{ sc.get(obj.status,'badge-secondary') }}">{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}</span></td>
<td>{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td><div class="progress-bar-container"><div class="progress-bar" style="width:{{ obj.progress }}%"></div></div><small>{{ obj.progress }}%</small></td>
<td>{% if obj.is_template %}-{% elif obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %}</td>
{% if can_edit %}<td class="actions-cell"><a href="{{ url_for('objectives.edit_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-primary" title="{{ _('Edit') }}"><i class="fas fa-edit"></i></a> <a href="{{ url_for('objectives.view_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-info" title="{{ _('View') }}"><i class="fas fa-eye"></i></a> <button class="btn btn-sm btn-outline-danger delete-obj-btn" data-obj-id="{{ obj.id }}" data-obj-title="{{ obj.title }}" title="{{ _('Delete') }}"><i class="fas fa-trash"></i></button></td>{% elif current_user.role == 'player' %}<td><a href="{{ url_for('objectives.view_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-info"><i class="fas fa-eye"></i> {{ _('View') }}</a></td>{% endif %}
</tr>{% else %}
<tr><td colspan="{{ 9 if can_edit else 8 }}" class="text-center"><div class="empty-state"><i class="fas fa-bullseye"></i><h3>{{ _('No objectives yet') }}</h3><p>{{ _('Create your first objective to start tracking player evolution.') }}</p></div></td></tr>{% endfor %}
</tbody></table></div>{{ pager.controls(pagination) }}</div></div>
<div id="deleteModal" class="modal hidden"><div class="modal-backdrop" onclick="hideDeleteModal();"></div><div class="modal-content"><div class="modal-header"><h3><i class="fas fa-trash-alt"></i> {{ _('Delete Objective') }}</h3><button class="modal-close" onclick="hideDeleteModal();">&times;</button></div><div class="modal-body"><p>{{ _('Are you sure you want to delete this objective?') }}</p><p><strong id="deleteObjTitle"></strong></p><form id="deleteForm" method="POST" action=""><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="form-actions"><button type="button" class="btn btn-secondary" onclick="hideDeleteModal();">{{ _('Cancel') }}</button><button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> {{ _('Delete') }}</button></div></form></div></div></div>
{% endblock %}
{% block scripts %}<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.delete-obj-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.getAttribute('data-obj-id');
var title = this.getAttribute('data-obj-title');
document.getElementById('deleteObjTitle').textContent = title;
document.getElementById('deleteForm').action = '{{ url_for("objectives.delete_objective", objective_id=0) }}'.replace('/0/', '/' + id + '/');
document.getElementById('deleteModal').classList.remove('hidden');
});
});
document.getElementById('deleteModal').querySelector('.modal-backdrop').addEventListener('click', hideDeleteModal);
});
function hideDeleteModal(){document.getElementById('deleteModal').classList.add('hidden');}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){hideDeleteModal();}});
</script>{% endblock %}
+74 -44
View File
@@ -6,54 +6,84 @@
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
<h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Player') }}</th>
<th>{{ _('Contact') }}</th>
<th>{{ _('Attendance') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for entry in players %}
<tr>
<td>
<div class="user-mini">
<p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
<form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
<th>Player</th>
<th>Contact</th>
<th>Attendance</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in players %}
<tr>
<td>
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
</td>
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<span>{{ entry.player.username }}</span>
</div>
</td>
<td>{{ entry.player.email }}</td>
<td>
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
</td>
<td>
{% if entry.evaluated %}
<span class="badge badge-success">Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</td>
<td>{{ entry.player.email }}</td>
<td>
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
</td>
<td>
{% if entry.evaluated %}
<span class="badge badge-success">Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">No players registered for this tryout.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-clipboard-check"></i> Evaluate Selected
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}">
function toggleAll() {
var boxes = document.querySelectorAll('.player-check');
var master = document.getElementById('select-all');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = master.checked;
}
}
registerActions({
'toggle-all': toggleAll,
});
</script>
{% endblock %}
+9 -7
View File
@@ -193,9 +193,6 @@
<p class="text-muted">{{ _('Loading...') }}</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
<i class="fas fa-save"></i> {{ _('Save Disponibilities') }}
</button>
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
<i class="fas fa-trash"></i> {{ _('Clear All') }}
</button>
@@ -369,7 +366,7 @@ function saveCoachAvailability() {
const msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Availability saved!';
msg.innerHTML = '<span><i class="fas fa-check"></i> Availability saved!</span>';
document.getElementById('availability-grid').appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
@@ -396,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
loadCoachAvailability();
renderCoachGrid();
});
// 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({
'clear-availability': clearAllAvailability,
});
</script>
{% endif %}
@@ -571,7 +575,7 @@ function saveDisponibilities() {
var msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Disponibilities saved successfully!';
msg.innerHTML = '<span><i class="fas fa-check"></i> Disponibilities saved successfully!</span>';
document.getElementById('disponibilities-grid').appendChild(msg);
setTimeout(function() { msg.remove(); }, 3000);
}
@@ -610,9 +614,7 @@ document.addEventListener('DOMContentLoaded', function() {
// dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise.
registerActions({
'save-disponibilities': saveDisponibilities,
'clear-disponibilities': clearDisponibilities,
'clear-availability': clearAllAvailability,
});
</script>
{% endif %}
-2
View File
@@ -57,8 +57,6 @@
<i class="fas fa-sync-alt"></i> {{ _('Reconnect') }}
</a>
</div>
<input type="hidden" name="discord_username" value="{{ discord_data.username }}">
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">
<small class="form-text text-success">
<i class="fas fa-check-circle"></i> {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }}
</small>
+3
View File
@@ -300,6 +300,9 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}">
function showCreateForm() {
document.getElementById('createTeamForm').classList.remove('hidden');
+14 -7
View File
@@ -67,15 +67,22 @@
</select>
</div>
<div class="form-group col-6">
<label for="manager_id">{{ _('Assigned Manager') }}</label>
<select id="manager_id" name="manager_id" class="form-select">
<option value="">{{ _('-- No manager assigned --') }}</option>
<label>{{ _('Assigned Managers') }}</label>
<div class="checkbox-grid">
{% for manager in managers %}
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
{{ manager.username }}
</option>
{% set is_checked = false %}
{% if tryout %}
{% for m in tryout.get_managers() %}
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
{% endfor %}
{% endif %}
<label class="checkbox-label">
<input type="checkbox" name="manager_ids" value="{{ manager.id }}" {% if is_checked %}checked{% endif %}>
<span>{{ manager.username }}</span>
</label>
{% endfor %}
</select>
</div>
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
</div>
</div>
<div class="form-row">
+2 -2
View File
@@ -24,8 +24,8 @@
<div class="form-group">
<label for="contract_file">{{ _('Contract File') }}</label>
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
<input type="file" id="contract_file" name="contract_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div>
<div class="form-group">
+8
View File
@@ -21,6 +21,7 @@
<th>{{ _('Username') }}</th>
<th>{{ _('Email') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Hours') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Joined') }}</th>
<th>{{ _('Actions') }}</th>
@@ -38,6 +39,13 @@
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></td>
<td>
{% if u.role in ('player', 'coach') %}
{{ '%.1f'|format(hours.get(u.id, 0.0)) }}
{% else %}
—
{% endif %}
</td>
<td>
{% if u.is_active_account %}
<span class="badge badge-success">Active</span>
+15 -6
View File
@@ -70,16 +70,22 @@
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Manager</span>
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
<span class="detail-label">Manager(s)</span>
<span class="detail-value">
{% set mgrs = tryout.get_managers() %}
{% if mgrs %}
{{ mgrs | map(attribute='username') | join(', ') }}
{% else %}
Not assigned
{% endif %}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Coaches</span>
<span class="detail-value">
{% if tryout.coaches %}
{{ tryout.coaches | map(attribute='username') | join(', ') }}
{% elif tryout.coach %}
{{ tryout.coach.username }}
{% set cos = tryout.get_coaches() %}
{% if cos %}
{{ cos | map(attribute='username') | join(', ') }}
{% else %}
Not assigned
{% endif %}
@@ -534,6 +540,9 @@
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<style>
.presence-toggle-btn {
padding: 2px 7px;
+25
View File
@@ -81,6 +81,31 @@
</table>
</div>
{% endif %}
{% if profile_user.role in ('player', 'coach') and availability %}
<hr class="my-4">
<h4 class="mb-3"><i class="fas fa-clock"></i> {{ _('Availability') }}
<small class="text-muted">({{ '%.1f'|format(availability_hours or 0.0) }} h)</small>
</h4>
<div class="readonly-availability-grid">
{% set day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] %}
{% for day_idx in range(7) %}
<div class="ra-day-column">
<div class="ra-day-header">{{ day_names[day_idx][:3] }}</div>
{% set day_slots = availability | selectattr('day_of_week', 'equalto', day_idx) | list %}
{% if day_slots %}
{% for slot in day_slots | sort(attribute='start_time') %}
<div class="ra-slot selected">
{{ slot.start_time.strftime('%H:%M') }}–{{ slot.end_time.strftime('%H:%M') }}
</div>
{% endfor %}
{% else %}
<div class="ra-slot ra-empty">—</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
"""Time helpers with explicit storage semantics.
The deployed schema currently stores timestamps in ``DateTime`` columns
without timezone information. Until the real PostgreSQL schema is restored
and migrated, application timestamps must therefore remain naive values.
They are nevertheless generated from an aware UTC clock so the convention is
explicit and does not rely on the deprecated :meth:`datetime.utcnow` API.
"""
from datetime import UTC, datetime
def utc_now_naive() -> datetime:
"""Return the current UTC instant without ``tzinfo`` for legacy columns.
Replace this compatibility boundary with aware UTC values when the
corresponding columns are migrated to timezone-aware types.
"""
return datetime.now(UTC).replace(tzinfo=None)
+36
View File
@@ -58,3 +58,39 @@ def day_name(day_of_week):
if 0 <= day_of_week < len(DAY_NAMES):
return DAY_NAMES[day_of_week]
return f'Day {day_of_week}'
def slot_duration_minutes(start_time, end_time):
"""Minutes a slot spans, wrapping past midnight like slot_end does.
Works on SQLite where ``Time`` columns are stored as text — the
arithmetic stays in pure Python so it is portable across databases.
Args:
start_time: A ``datetime.time``.
end_time: A ``datetime.time`` (may be earlier if the slot wraps
past midnight).
Returns:
int: Duration in whole minutes.
"""
anchor = date_cls(2000, 1, 1)
start_dt = datetime.combine(anchor, start_time)
end_dt = datetime.combine(anchor, end_time)
if end_dt <= start_dt:
end_dt += timedelta(days=1)
return int((end_dt - start_dt).total_seconds() // 60)
def total_hours(slots):
"""Aggregate slot durations into a single float, rounded to one decimal.
Args:
slots: Iterable of objects with ``start_time`` and ``end_time``
attributes (``PlayerDisponibility`` or ``CoachAvailability``).
Returns:
float: Total hours across all slots, e.g. ``8.5``.
"""
total_minutes = sum(slot_duration_minutes(s.start_time, s.end_time) for s in slots)
return round(total_minutes / 60, 1)
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+283 -12
View File
@@ -23,7 +23,15 @@ from marshmallow import (
validates_schema,
)
from app.models import ESPORT_GAMES, USER_TYPES
from app.models import (
ESPORT_GAMES,
GAME_PLATFORMS,
OBJECTIVE_CHECK_FREQUENCIES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_STATUSES,
OBJECTIVE_TYPES,
USER_TYPES,
)
# =============================================================================
# Custom Validators
@@ -256,11 +264,6 @@ class RegisterSchema(StripMixin):
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
@@ -281,6 +284,36 @@ class RegisterSchema(StripMixin):
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password')
class GamertagSchema(StripMixin):
"""One dynamic per-game identity submitted beside an account form."""
game = fields.String(
required=True,
validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')),
)
gamertag = fields.String(
required=True,
validate=validate.Length(
min=1,
max=120,
error=_l('Gamertag must be between 1 and 120 characters.'),
),
)
platform = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=30, error=_l('Platform must be 30 characters or less.')),
)
@validates_schema
def validate_platform_for_game(self, data, **kwargs):
"""A forged platform must belong to the selected game's list."""
platform = data.get('platform')
allowed = GAME_PLATFORMS.get(data.get('game'), ())
if platform and platform not in allowed:
raise ValidationError(_l('Unknown platform for this game.'), field_name='platform')
class CreateUserSchema(StripMixin):
"""Validate president-created user form input.
@@ -392,7 +425,6 @@ class EditProfileSchema(StripMixin):
phone: Optional.
password: Optional (only if changing).
discord_username: Optional.
discord_user_id: Optional.
league_os_profile: Optional.
games: Optional list.
"""
@@ -428,11 +460,6 @@ class EditProfileSchema(StripMixin):
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
@@ -528,6 +555,115 @@ class TeamPlayerSchema(PlayerSelectionSchema):
)
TRYOUT_STATUSES = ('upcoming', 'in_progress', 'completed')
TRYOUT_REGISTRATION_STATUSES = ('registered', 'attended', 'no_show')
class TryoutStatusSchema(StripMixin):
"""A state transition requested from the tryout detail page."""
status = fields.String(
required=True,
validate=validate.OneOf(TRYOUT_STATUSES, error=_l('Unknown tryout status.')),
)
class TryoutRegistrationStatusSchema(StripMixin):
"""Attendance state for one tryout registration."""
status = fields.String(
required=True,
validate=validate.OneOf(
TRYOUT_REGISTRATION_STATUSES,
error=_l('Unknown registration status.'),
),
)
class TryoutTeamSchema(StripMixin):
"""A tryout-local team created from its compact inline form."""
team_name = fields.String(
required=True,
validate=validate.Length(
min=1,
max=100,
error=_l('Team name must be between 1 and 100 characters.'),
),
)
class TryoutTeamMemberSchema(StripMixin):
"""A registered player and their optional position on a tryout team."""
player_id = fields.Integer(
required=True,
validate=validate.Range(min=1),
error_messages={
'invalid': _l('Invalid player selection.'),
'required': _l('Player must be selected.'),
},
)
position = fields.String(
load_default='',
validate=validate.Length(
max=50,
error=_l('Position must be 50 characters or less.'),
),
)
class NoteContentSchema(StripMixin):
"""Bounded text stored as a team or personal coaching note."""
content = fields.String(
required=True,
validate=validate.Length(
min=1,
max=5000,
error=_l('Note content must be between 1 and 5000 characters.'),
),
)
class PersonalNoteSchema(NoteContentSchema):
"""A personal note with at most one optional, typed context."""
player_id = fields.Integer(
required=True,
validate=validate.Range(min=1),
error_messages={
'invalid': _l('Invalid player selection.'),
'required': _l('Player must be selected.'),
},
)
match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
@validates_schema
def validate_one_context(self, data, **kwargs):
"""A note cannot claim several unrelated contexts at once."""
contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')]
if sum(value is not None for value in contexts) > 1:
raise ValidationError(
_l('Select at most one note context.'),
field_name='context',
)
class OneOnOneRejectionSchema(StripMixin):
"""Optional explanation sent to a player when a request is rejected."""
rejection_reason = fields.String(
load_default='',
validate=validate.Length(
max=2000,
error=_l('Rejection reason must be 2000 characters or less.'),
),
)
class OneOnOneRequestSchema(StripMixin):
"""A player asking their coach for a session (MNT-12).
@@ -821,6 +957,7 @@ class TryoutSchema(StripMixin):
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
manager_id = fields.Integer(allow_none=True, load_default=None)
coach_ids = fields.List(fields.Integer(), load_default=list)
manager_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema
def validate_span(self, data, **kwargs):
@@ -920,3 +1057,137 @@ class EvaluationSchema(StripMixin):
allow_none=True,
load_default=None,
)
# =============================================================================
# Player Evolution — Objective / Plan Schema
# =============================================================================
class ObjectiveSchema(StripMixin):
"""A player-evolution objective — individual or team KPI goal.
Validates the form payload from the create / edit modal on the
objectives management page.
"""
title = fields.String(
required=True,
validate=validate.Length(min=1, max=200, error=_l('Le titre est obligatoire.')),
error_messages={'required': _l('Le titre est obligatoire.')},
)
description = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=5000),
)
type = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_TYPES],
error=_l("Type d'objectif invalide."),
),
load_default='individual',
)
status = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_STATUSES],
error=_l('Statut invalide.'),
),
load_default='active',
)
priority = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_PRIORITIES],
error=_l('Priorité invalide.'),
),
load_default='medium',
)
category = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=50),
)
is_template = fields.Boolean(load_default=False)
player_id = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1),
error_messages={'invalid': _l('Joueur invalide.')},
)
team_id = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1),
error_messages={'invalid': _l('Équipe invalide.')},
)
kpi_metric = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=120),
)
kpi_target = fields.Decimal(
allow_none=True,
load_default=None,
places=2,
error_messages={'invalid': _l('Cible KPI invalide.')},
)
kpi_unit = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=20),
)
kpi_precision = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=0, max=10),
)
start_date = fields.Date(
allow_none=True,
load_default=None,
format='%Y-%m-%d',
error_messages={'invalid': _l('Date de début invalide (AAAA-MM-JJ).')},
)
due_date = fields.Date(
required=True,
format='%Y-%m-%d',
error_messages={
'required': _l('La date d\'échéance est obligatoire.'),
'invalid': _l('Date d\'échéance invalide (AAAA-MM-JJ).'),
},
)
check_frequency = fields.String(
allow_none=True,
load_default=None,
validate=validate.OneOf(
[''] + [v for v, _ in OBJECTIVE_CHECK_FREQUENCIES],
error=_l('Fréquence de vérification invalide.'),
),
)
progress = fields.Integer(
load_default=0,
validate=validate.Range(
min=0, max=100, error=_l('La progression doit être entre 0 et 100.')
),
)
tags = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=255),
)
@validates_schema
def validate_assignment(self, data, **kwargs):
"""Type-aware assignment: coerce to a clean state.
- Templates → no player or team.
- Team objectives → clear player_id.
- Individual objectives → clear team_id.
"""
if data.get('is_template'):
data['player_id'] = None
data['team_id'] = None
return
if data.get('type') == 'team':
data['player_id'] = None
else:
data['team_id'] = None
+1
View File
@@ -15,6 +15,7 @@ incident.
|---|---|---|
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record |
| `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths |
| `documents_backup_drive_<timestamp>.zip` | Google Drive contract folder | Drive-stored contracts downloaded and archived when `DOCUMENT_STORAGE_BACKEND=google_drive` |
Losing either one alone loses data. A database restore without the document
archive leaves contract rows pointing at files that no longer exist.
+14 -5
View File
@@ -1,8 +1,9 @@
# Le schéma réel, et comment sortir de `create_all()`
> **État au 2026-08-11** : l'outil de relevé existe et est testé. Le relevé
> lui-même n'a pas été exécuté — il demande un accès à la base de production,
> qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution.
> **État au 2026-08-16** : l'outil de relevé existe et est testé, y compris
> pour les collisions d'identité Discord. Le relevé lui-même n'a pas été
> exécuté — il demande un accès à la base de production, qui ne peut pas venir
> du dépôt. Tout changement de schéma ci-dessous attend cette exécution.
## Pourquoi c'est le nœud
@@ -60,6 +61,14 @@ existe-t-il encore ? (`SEC-003`) :
python app/supporting_scripts/schema_report.py --check-seed-accounts
```
L'identité Discord est désormais conservée côté serveur et toute nouvelle
collision est refusée par l'application. Les doublons historiques restent à
identifier avant d'ajouter la contrainte `UNIQUE` de `SEC-012` :
```bash
python app/supporting_scripts/schema_report.py --check-discord-identities
```
## Étape 3 — Alembic, décrivant le schéma **réel** (`DB-002`)
Le piège de cette étape tient en une phrase : **la migration initiale doit
@@ -106,12 +115,12 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` :
|---|---|---|
| `DB-004` | Retirer `create_all()` de `create_app()` | Tant qu'il est là, deux mécanismes décrivent le schéma |
| `DB-005` | Cascades de suppression au niveau base | Les cascades ORM sont en place ; PostgreSQL ne les connaît pas |
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Le plafond d'inscriptions est aujourd'hui un `count()` suivi d'un `add()` : deux requêtes simultanées passent toutes les deux |
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Les deux routes verrouillent désormais la ligne `Tryout` avant le contrôle de doublon, le `count()` et l'`add()` : PostgreSQL sérialise donc leurs décisions de capacité. La contrainte reste nécessaire pour les scripts, imports et futurs chemins d'écriture qui ne passent pas par ces routes |
| `DB-007` | Index, `CheckConstraint` sur les statuts, `server_default` | — |
| `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement |
| `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
| `ARCH-001` | Fusionner coach/équipe sur la relation m2m | Migration de données ; `app/permissions.py` rend la duplication inoffensive **en lecture** seulement, l'écriture crée toujours les deux |
| `SEC-012` | Identité Discord côté serveur, `unique=True` | La colonne doit être unique, donc dédoublonnée d'abord |
| `SEC-012` | Ajouter `unique=True` sur l'identité Discord | La valeur OAuth reste côté serveur et les nouvelles collisions sont refusées ; les lignes historiques doivent être dédoublonnées d'abord |
## Ce qu'on ne fait pas
+27 -5
View File
@@ -67,6 +67,28 @@ documents every variable. Copying it verbatim gives a configuration that
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
than one that starts and is wide open (OPS-003).
### Google Drive contract storage
New contracts are stored in the owner's personal Google Drive when
`DOCUMENT_STORAGE_BACKEND=google_drive`. Enable the Google Drive API in a
Google Cloud project, create an OAuth client for the owner, and authorize it
once with the `drive.file` scope. Configure the resulting values outside the
repository:
```text
DOCUMENT_STORAGE_BACKEND=google_drive
GOOGLE_DRIVE_FOLDER_ID=<owner folder id>
GOOGLE_DRIVE_CLIENT_ID=<OAuth client id>
GOOGLE_DRIVE_CLIENT_SECRET=<OAuth client secret>
GOOGLE_DRIVE_REFRESH_TOKEN=<owner refresh token>
```
The refresh token can create, download, and delete contracts created by this
application. Keep all four values out of source control. The database stores
only `gdrive://<file-id>` references, so existing local-file rows continue to
work after the change. Use `DOCUMENT_STORAGE_BACKEND=local` only for tests,
local development, or while migrating legacy files.
### Binding and proxy trust — read this before going live (OPS-002)
Two variables decide whether the rate limiter, the account lockout and the
@@ -282,11 +304,11 @@ have sent new contracts to a new tree and made the existing ones unreadable
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
backup script kept its own copy of the document path — so it archived
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
was therefore enough, on its own, to make every contract backup empty; the
script prints `No documents directory…` and still exits 0, so a scheduled
task watching the exit code would have seen green indefinitely. All three
roots now come from `app/storage.py`, and the backup run prints the document
source it used.
was therefore enough, on its own, to make every contract backup empty. All
three roots now come from `app/storage.py`, and the backup run prints the
document source it used. A missing or unarchivable document store makes the
run exit non-zero even when the database dump itself is valid, so a scheduler
cannot report a database-only recovery point as a complete backup.
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
and check the `Document source:` line and the size of the resulting
+2 -1
View File
@@ -82,8 +82,9 @@ Run through this checklist before deploying to production.
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
- [ ] File extensions restricted to PDF only
- [ ] File content validated by parsing with pypdf (`PdfReader`) — header (`%PDF-`) + at least one parseable page
- [ ] Files stored with UUID filenames (not original names)
- [ ] Upload directory outside web root
- [ ] Upload directory outside web root (or Google Drive in production)
## Phase 13 — Logging & Monitoring
+2 -2
View File
@@ -170,8 +170,8 @@ was marked by the automated pass. Jinja escaped it to `&amp;times;`, so
the button would have shown the literal text `&times;` instead of ×.
Anything that is markup rather than prose must stay out of `_()`.
**Strings inside `<script>` blocks.** Eight remain, in `match_form.html`,
`one_on_one.html` and `coach_availability.html`. They work, but they are
**Strings inside `<script>` blocks.** Seven remain, in `match_form.html` and
`one_on_one.html`. They work, but they are
fragile: Jinja escapes `& < > " '` inside a script block, and those
entities are *not* decoded there — a translation containing an apostrophe
would land in the JavaScript string as `&#39;`. The French wording uses
Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+7 -4
View File
@@ -21,14 +21,17 @@ filterwarnings = [
"default",
# discord.py imports audioop, removed from the stdlib in 3.13.
"ignore:'audioop' is deprecated:DeprecationWarning",
# Every model uses datetime.utcnow as a column default. Tracked as
# DB-009; the warning would otherwise drown the run.
"ignore:datetime.datetime.utcnow:DeprecationWarning",
]
[tool.coverage.report]
# The exhaustive audit established a 71% baseline. Keep one point of margin
# for platform-specific branches while making any material regression fail CI.
fail_under = 70
show_missing = true
[tool.ruff]
line-length = 100
target-version = "py312"
target-version = "py313"
exclude = [".venv", "venv", "migrations", "docs"]
[tool.ruff.lint]
+3
View File
@@ -24,6 +24,9 @@ Flask-SQLAlchemy==3.1.1
Flask-WTF==1.3.0
frozenlist==1.8.0
greenlet==3.5.4
google-api-python-client==2.198.0
google-auth==2.56.3
pypdf==6.16.2
idna==3.18
itsdangerous==2.2.0
Jinja2==3.1.6

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