Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f468217745 | ||
|
|
cf551867c9 | ||
|
|
b43c5b1734 | ||
|
|
85eca9edd4 | ||
|
|
c7b321a636 | ||
|
|
90c00ad29b | ||
|
|
bfb25f4d16 | ||
|
|
820cccb25e | ||
|
|
38defc53a5 | ||
|
|
d18979a3e9 | ||
|
|
1549fbaef3 | ||
|
|
c72ed9b0b1 | ||
|
|
6232b77094 | ||
|
|
a616c79663 | ||
|
|
48ca62cdd0 | ||
|
|
1bef716a12 | ||
|
|
a0e31d1a2f | ||
|
|
e15b3c1293 | ||
|
|
d7a8907953 | ||
|
|
105a72700f | ||
|
|
f84cb4e3b6 | ||
|
|
06508cc7d6 | ||
|
|
9647003c3f | ||
|
|
2b943c5c22 | ||
|
|
9fc4ba0b98 | ||
|
|
53e390672e |
@@ -0,0 +1,45 @@
|
|||||||
|
name: CI - Security, Lint & Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# This workflow validates branches only. It has no deployment step and no
|
||||||
|
# write permission, so an audit-branch push cannot alter main or production.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Audit declared dependencies
|
||||||
|
run: pip-audit -r requirements.txt
|
||||||
|
|
||||||
|
- name: Lint and check formatting
|
||||||
|
run: |
|
||||||
|
ruff check .
|
||||||
|
ruff format --check .
|
||||||
|
|
||||||
|
- name: Run tests with coverage gate
|
||||||
|
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||||
|
|
||||||
|
- name: Run repository security checks
|
||||||
|
env:
|
||||||
|
SECRET_KEY: audit-ci-key-not-for-production-1234567890
|
||||||
|
DATABASE_URL: 'sqlite:///:memory:'
|
||||||
|
FLASK_DEBUG: 'false'
|
||||||
|
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||||
@@ -2,7 +2,7 @@ name: CI - Security & Lint
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [main, master, 'audit/**']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
workflow_dispatch: # Allow manual triggers
|
workflow_dispatch: # Allow manual triggers
|
||||||
@@ -94,6 +94,7 @@ jobs:
|
|||||||
- name: Run security scan
|
- name: Run security scan
|
||||||
env:
|
env:
|
||||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||||
|
DATABASE_URL: 'sqlite:///:memory:'
|
||||||
FLASK_DEBUG: 'false'
|
FLASK_DEBUG: 'false'
|
||||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ coverage.xml
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||||
# voir l'historique du commit `4fd27ba`).
|
# voir l'historique du commit `4fd27ba`).
|
||||||
|
# Données PostgreSQL locales — gérées par app/local_postgres.py
|
||||||
|
instance/pgdata/
|
||||||
|
instance/pg.log
|
||||||
instance/
|
instance/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
@@ -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
|
- **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
|
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`.
|
de produit en attente, voir `docs/roles-and-permissions.md`.
|
||||||
- **L'identité Discord** transite encore par un champ caché du formulaire
|
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
|
||||||
d'inscription : elle n'est pas prouvée par le passage OAuth2.
|
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.
|
`docs/security-checklist.md` détaille la liste avant mise en production.
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
|
|||||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Optional — storage
|
# Contract storage — Google Drive
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
|
||||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
# refresh token, and folder ID are secrets/configuration: do not commit them.
|
||||||
# to a release-directory layout, or a deployment will take the documents with
|
# Use `local` only for isolated development and legacy-file maintenance.
|
||||||
# it (OPS-011, app/storage.py).
|
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||||
#
|
|
||||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
# Target folder in the owner's personal Drive. The application keeps every
|
||||||
# did not, and archived `./documents` regardless — so setting this here and
|
# contract directly in this folder and stores only each Drive file ID in the
|
||||||
# nowhere else produced empty contract backups that still exited 0.
|
# 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=
|
DOCUMENTS_ROOT=
|
||||||
|
|
||||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
# 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
|
# FORCE_HTTPS=false no redirect to HTTPS
|
||||||
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
# 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
|
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||||
# development session cannot accidentally inherit a production binding.
|
# development session cannot accidentally inherit a production binding.
|
||||||
|
|||||||
@@ -411,6 +411,8 @@ def create_app(config=None):
|
|||||||
from app.routes.teams import teams_bp
|
from app.routes.teams import teams_bp
|
||||||
from app.routes.tryouts import tryouts_bp
|
from app.routes.tryouts import tryouts_bp
|
||||||
from app.routes.users import users_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(auth_bp)
|
||||||
app.register_blueprint(tryouts_bp)
|
app.register_blueprint(tryouts_bp)
|
||||||
@@ -420,6 +422,8 @@ def create_app(config=None):
|
|||||||
app.register_blueprint(teams_bp)
|
app.register_blueprint(teams_bp)
|
||||||
app.register_blueprint(matches_bp)
|
app.register_blueprint(matches_bp)
|
||||||
app.register_blueprint(team_matches_bp)
|
app.register_blueprint(team_matches_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(objectives_bp)
|
||||||
|
|
||||||
# Register custom Jinja filters
|
# Register custom Jinja filters
|
||||||
app.jinja_env.filters['nl2br'] = nl2br
|
app.jinja_env.filters['nl2br'] = nl2br
|
||||||
|
|||||||
+13
-8
@@ -65,6 +65,8 @@ from discord.ext import commands
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
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.
|
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
||||||
"""
|
"""
|
||||||
# Look up the DB user to get their Discord user ID
|
# Look up the DB user to get their Discord user ID
|
||||||
|
from app.extensions import db
|
||||||
from app.models import User as DBUser
|
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:
|
if not db_user:
|
||||||
logger.warning(f"DB user {user_id} not found for schedule notification")
|
logger.warning(f"DB user {user_id} not found for schedule notification")
|
||||||
return None
|
return None
|
||||||
@@ -754,7 +757,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
from app.models import OneOnOneRequest
|
from app.models import OneOnOneRequest
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = OneOnOneRequest.query.get(request_id)
|
request = db.session.get(OneOnOneRequest, request_id)
|
||||||
if not request:
|
if not request:
|
||||||
# The row is gone; no reaction on this message can ever mean
|
# The row is gone; no reaction on this message can ever mean
|
||||||
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
|
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
|
||||||
@@ -779,7 +782,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
coach_obj = request.coach
|
coach_obj = request.coach
|
||||||
|
|
||||||
request.status = 'approved'
|
request.status = 'approved'
|
||||||
request.responded_at = datetime.utcnow()
|
request.responded_at = utc_now_naive()
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.exception('Could not read One on One request %s to approve it', request_id)
|
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
|
from app.models import OneOnOneRequest
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = OneOnOneRequest.query.get(request_id)
|
request = db.session.get(OneOnOneRequest, request_id)
|
||||||
if not request:
|
if not request:
|
||||||
logger.info(
|
logger.info(
|
||||||
'One on One request %s no longer exists; its pending message was dropped.',
|
'One on One request %s no longer exists; its pending message was dropped.',
|
||||||
@@ -874,7 +877,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
request.status = 'rejected'
|
request.status = 'rejected'
|
||||||
request.responded_at = datetime.utcnow()
|
request.responded_at = utc_now_naive()
|
||||||
if refusal_note:
|
if refusal_note:
|
||||||
request.coach_rejection_message = refusal_note
|
request.coach_rejection_message = refusal_note
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
@@ -918,12 +921,13 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
Returns:
|
Returns:
|
||||||
tuple: (row, player_id) — either may be None.
|
tuple: (row, player_id) — either may be None.
|
||||||
"""
|
"""
|
||||||
|
from app.extensions import db
|
||||||
from app.models import MatchParticipant, TryoutRegistration
|
from app.models import MatchParticipant, TryoutRegistration
|
||||||
|
|
||||||
if event_type == 'match':
|
if event_type == 'match':
|
||||||
row = MatchParticipant.query.get(reference_id)
|
row = db.session.get(MatchParticipant, reference_id)
|
||||||
elif event_type == 'tryout':
|
elif event_type == 'tryout':
|
||||||
row = TryoutRegistration.query.get(reference_id)
|
row = db.session.get(TryoutRegistration, reference_id)
|
||||||
else:
|
else:
|
||||||
row = None
|
row = None
|
||||||
return row, getattr(row, 'player_id', 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
|
attendance handlers did not (OPS-009) — same message shape, same
|
||||||
threat, one of them checked. The asymmetry was the bug.
|
threat, one of them checked. The asymmetry was the bug.
|
||||||
"""
|
"""
|
||||||
|
from app.extensions import db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
if not player_id:
|
if not player_id:
|
||||||
return False
|
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))
|
return bool(owner and owner.discord_user_id == str(reacting_user.id))
|
||||||
|
|
||||||
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ babel = Babel()
|
|||||||
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
|
# 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
|
# 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.
|
# `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):
|
def hash_password(password):
|
||||||
|
|||||||
@@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass
|
|||||||
if not payload.get(name):
|
if not payload.get(name):
|
||||||
payload.pop(name, None)
|
payload.pop(name, None)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def form_gamertags(selected_games):
|
||||||
|
"""Validate the dynamic gamertag fields for the selected games.
|
||||||
|
|
||||||
|
These fields cannot be declared statically on the account schemas: their
|
||||||
|
names contain the game label. They are still untrusted form data, so
|
||||||
|
every caller uses this shared boundary before adding or changing rows.
|
||||||
|
"""
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
|
from app.models import GAME_PLATFORMS
|
||||||
|
from app.validators import GamertagSchema
|
||||||
|
|
||||||
|
validated = {}
|
||||||
|
for game in selected_games:
|
||||||
|
raw_gamertag = request.form.get(f'gamertag_{game}', '')
|
||||||
|
raw_platform = (
|
||||||
|
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
|
||||||
|
)
|
||||||
|
if not raw_gamertag.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
validated[game] = GamertagSchema().load(
|
||||||
|
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
|
||||||
|
)
|
||||||
|
except ValidationError as err:
|
||||||
|
messages = [message for values in err.messages.values() for message in values]
|
||||||
|
raise ValidationError({f'gamertag_{game}': messages}) from err
|
||||||
|
return validated
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Google Drive storage for contract PDFs.
|
||||||
|
|
||||||
|
The application uses an OAuth refresh token for the owner's personal Google
|
||||||
|
account. Tokens and client secrets come only from environment variables; the
|
||||||
|
database stores opaque file IDs, never a credential or a shareable Drive URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
|
||||||
|
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
|
||||||
|
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
|
||||||
|
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
|
||||||
|
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleDriveStorageError(RuntimeError):
|
||||||
|
"""A configuration or API failure while storing a contract in Drive."""
|
||||||
|
|
||||||
|
|
||||||
|
def _setting(name):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if not value:
|
||||||
|
raise GoogleDriveStorageError(
|
||||||
|
f'{name} must be configured for Google Drive document storage.'
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_service():
|
||||||
|
"""Build an authorized Drive client from the owner's refresh token."""
|
||||||
|
try:
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
except ImportError as exc:
|
||||||
|
raise GoogleDriveStorageError(
|
||||||
|
'Google Drive dependencies are not installed. Install requirements.txt again.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
credentials = Credentials(
|
||||||
|
token=None,
|
||||||
|
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
|
||||||
|
token_uri='https://oauth2.googleapis.com/token',
|
||||||
|
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
|
||||||
|
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
|
||||||
|
scopes=[GOOGLE_DRIVE_SCOPE],
|
||||||
|
)
|
||||||
|
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
|
||||||
|
|
||||||
|
|
||||||
|
def list_folder_files():
|
||||||
|
"""Return (file_id, name) tuples for every file in the configured folder.
|
||||||
|
|
||||||
|
Used by the backup script to enumerate Drive-stored contracts. Returns
|
||||||
|
only files directly in the folder (not nested inside sub-folders).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[tuple[str, str]]: (file_id, name) for each file found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
folder_id = _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
|
||||||
|
page_token = None
|
||||||
|
results = []
|
||||||
|
while True:
|
||||||
|
response = (
|
||||||
|
_drive_service()
|
||||||
|
.files()
|
||||||
|
.list(
|
||||||
|
q=f"'{folder_id}' in parents and trashed=false",
|
||||||
|
fields='nextPageToken, files(id, name)',
|
||||||
|
pageToken=page_token,
|
||||||
|
pageSize=1000,
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
for f in response.get('files', []):
|
||||||
|
results.append((f['id'], f['name']))
|
||||||
|
page_token = response.get('nextPageToken')
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(*, stream, filename, mimetype):
|
||||||
|
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
||||||
|
try:
|
||||||
|
from googleapiclient.http import MediaIoBaseUpload
|
||||||
|
|
||||||
|
stream.seek(0)
|
||||||
|
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
|
||||||
|
response = (
|
||||||
|
_drive_service()
|
||||||
|
.files()
|
||||||
|
.create(
|
||||||
|
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
||||||
|
media_body=media,
|
||||||
|
fields='id',
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # Google client exceptions share no stable base class.
|
||||||
|
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
|
||||||
|
|
||||||
|
file_id = response.get('id')
|
||||||
|
if not file_id:
|
||||||
|
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
|
||||||
|
return file_id
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(file_id):
|
||||||
|
"""Download a Drive file into memory for Flask's authenticated response."""
|
||||||
|
try:
|
||||||
|
from googleapiclient.http import MediaIoBaseDownload
|
||||||
|
|
||||||
|
destination = io.BytesIO()
|
||||||
|
downloader = MediaIoBaseDownload(
|
||||||
|
destination,
|
||||||
|
_drive_service().files().get_media(fileId=file_id),
|
||||||
|
)
|
||||||
|
complete = False
|
||||||
|
while not complete:
|
||||||
|
_status, complete = downloader.next_chunk()
|
||||||
|
return destination.getvalue()
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # Google client exceptions share no stable base class.
|
||||||
|
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(file_id):
|
||||||
|
"""Permanently delete a Drive document when its contract record is deleted."""
|
||||||
|
try:
|
||||||
|
_drive_service().files().delete(fileId=file_id).execute()
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # Google client exceptions share no stable base class.
|
||||||
|
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -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
|
||||||
@@ -20,6 +20,12 @@ from app.models._constants import (
|
|||||||
GAME_PLATFORMS,
|
GAME_PLATFORMS,
|
||||||
PLATFORM_CODES,
|
PLATFORM_CODES,
|
||||||
TRN_URLS,
|
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.team_note import TeamNote
|
||||||
from app.models.personal_note import PersonalNote
|
from app.models.personal_note import PersonalNote
|
||||||
from app.models.one_on_one_request import OneOnOneRequest
|
from app.models.one_on_one_request import OneOnOneRequest
|
||||||
|
from app.models.admin_settings import AppSettings
|
||||||
|
from app.models.backup_record import BackupRecord
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
|
|||||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tryout_managers = db.Table(
|
||||||
|
'tryout_managers',
|
||||||
|
db.Column(
|
||||||
|
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
|
db.Column(
|
||||||
|
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
|
|||||||
|
|
||||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
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 = [
|
ESPORT_GAMES = [
|
||||||
'Valorant',
|
'Valorant',
|
||||||
'League of Legends',
|
'League of Legends',
|
||||||
@@ -54,11 +69,84 @@ PLATFORM_CODES = {
|
|||||||
|
|
||||||
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
|
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 = {
|
TRN_URLS = {
|
||||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||||
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
|
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
|
||||||
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
||||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
|
'Apex Legends': 'https://tracker.gg/apex/profile/{platform_code}/{username}',
|
||||||
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
||||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
||||||
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Global application settings stored as key-value pairs in the database."""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(db.Model):
|
||||||
|
"""Key-value store for global application settings.
|
||||||
|
|
||||||
|
Stores toggles and configuration that admins control through the
|
||||||
|
admin panel, such as whether tryouts are open, season state, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'app_settings'
|
||||||
|
|
||||||
|
key = db.Column(db.String(100), primary_key=True)
|
||||||
|
value = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Well-known keys (documented here for discoverability)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# tryouts_open – "true" / "false" (default: "true")
|
||||||
|
# document_uploads_enabled – "true" / "false" (default: "true")
|
||||||
|
# season_active – "true" / "false" (default: "false")
|
||||||
|
# season_name – e.g. "Fall 2026"
|
||||||
|
# season_start – ISO date string
|
||||||
|
# season_end – ISO date string
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(key, default=None):
|
||||||
|
"""Return the value for *key*, or *default* if not set."""
|
||||||
|
row = db.session.get(AppSettings, key)
|
||||||
|
return row.value if row is not None else default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set(key, value):
|
||||||
|
"""Upsert a setting."""
|
||||||
|
row = db.session.get(AppSettings, key)
|
||||||
|
if row is None:
|
||||||
|
row = AppSettings(key=key, value=str(value) if value is not None else None)
|
||||||
|
db.session.add(row)
|
||||||
|
else:
|
||||||
|
row.value = str(value) if value is not None else None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_bool(key, default=False):
|
||||||
|
"""Return a boolean setting."""
|
||||||
|
val = AppSettings.get(key)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_bool(key, value):
|
||||||
|
"""Store a boolean setting as 'true' / 'false'."""
|
||||||
|
AppSettings.set(key, 'true' if value else 'false')
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Audit log for tracking sensitive administrative actions."""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""Append-only log of critical admin actions for accountability."""
|
||||||
|
|
||||||
|
__tablename__ = 'audit_logs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
action = db.Column(db.String(100), nullable=False)
|
||||||
|
details = db.Column(db.Text, nullable=True)
|
||||||
|
ip_address = db.Column(db.String(64), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def record(user_id, action, details=None, ip_address=None):
|
||||||
|
"""Create a new audit log entry."""
|
||||||
|
entry = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
return entry
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BaseAvailability(db.Model):
|
class BaseAvailability(db.Model):
|
||||||
@@ -13,5 +12,5 @@ class BaseAvailability(db.Model):
|
|||||||
day_of_week = db.Column(db.Integer, nullable=False)
|
day_of_week = db.Column(db.Integer, nullable=False)
|
||||||
start_time = db.Column(db.Time, nullable=False)
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
end_time = db.Column(db.Time, nullable=False)
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Contract documents for players to sign."""
|
"""Contract documents for players to sign."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Contract(db.Model):
|
class Contract(db.Model):
|
||||||
@@ -23,7 +22,7 @@ class Contract(db.Model):
|
|||||||
|
|
||||||
status = db.Column(db.String(20), default='pending')
|
status = db.Column(db.String(20), default='pending')
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
signed_at = db.Column(db.DateTime, nullable=True)
|
signed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
||||||
@@ -57,7 +56,7 @@ class Contract(db.Model):
|
|||||||
if isinstance(user, Admin):
|
if isinstance(user, Admin):
|
||||||
return True
|
return True
|
||||||
if isinstance(user, Manager):
|
if isinstance(user, Manager):
|
||||||
player = User.query.get(self.player_id)
|
player = db.session.get(User, self.player_id)
|
||||||
if player and player.get_org_teams():
|
if player and player.get_org_teams():
|
||||||
return True
|
return True
|
||||||
if isinstance(user, Coach):
|
if isinstance(user, Coach):
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Player evaluation record."""
|
"""Player evaluation record."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Evaluation(db.Model):
|
class Evaluation(db.Model):
|
||||||
@@ -25,8 +24,8 @@ class Evaluation(db.Model):
|
|||||||
overall_score = db.Column(db.Float, nullable=True)
|
overall_score = db.Column(db.Float, nullable=True)
|
||||||
comments = db.Column(db.Text, nullable=True)
|
comments = db.Column(db.Text, nullable=True)
|
||||||
position_recommendation = db.Column(db.String(50), nullable=True)
|
position_recommendation = db.Column(db.String(50), nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BaseMatch(db.Model):
|
class BaseMatch(db.Model):
|
||||||
@@ -18,4 +17,4 @@ class BaseMatch(db.Model):
|
|||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
status = db.Column(db.String(20), default='scheduled')
|
status = db.Column(db.String(20), default='scheduled')
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -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])
|
||||||
@@ -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])
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Request from player to coach for a One on One session."""
|
"""Request from player to coach for a One on One session."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class OneOnOneRequest(db.Model):
|
class OneOnOneRequest(db.Model):
|
||||||
@@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model):
|
|||||||
end_time = db.Column(db.Time, nullable=False)
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
points = db.Column(db.Text, nullable=True)
|
points = db.Column(db.Text, nullable=True)
|
||||||
status = db.Column(db.String(20), default='pending')
|
status = db.Column(db.String(20), default='pending')
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
responded_at = db.Column(db.DateTime, nullable=True)
|
responded_at = db.Column(db.DateTime, nullable=True)
|
||||||
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
||||||
coach_rejection_message = db.Column(db.Text, nullable=True)
|
coach_rejection_message = db.Column(db.Text, nullable=True)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models._associations import org_team_coaches, org_team_managers
|
from app.models._associations import org_team_coaches, org_team_managers
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class OrgTeam(db.Model):
|
class OrgTeam(db.Model):
|
||||||
@@ -13,7 +12,7 @@ class OrgTeam(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Many-to-many junction: player to org-team."""
|
"""Many-to-many junction: player to org-team."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TeamPlayer(db.Model):
|
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)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||||
position = db.Column(db.String(50), nullable=True)
|
position = db.Column(db.String(50), nullable=True)
|
||||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
||||||
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Abstract base class for match participant models."""
|
"""Abstract base class for match participant models."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BaseParticipant(db.Model):
|
class BaseParticipant(db.Model):
|
||||||
@@ -11,4 +10,4 @@ class BaseParticipant(db.Model):
|
|||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Personal notes from coach to individual player."""
|
"""Personal notes from coach to individual player."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class PersonalNote(db.Model):
|
class PersonalNote(db.Model):
|
||||||
@@ -13,8 +12,8 @@ class PersonalNote(db.Model):
|
|||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|
||||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
||||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Team(db.Model):
|
class Team(db.Model):
|
||||||
@@ -13,7 +12,7 @@ class Team(db.Model):
|
|||||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
name = db.Column(db.String(100), nullable=False)
|
name = db.Column(db.String(100), nullable=False)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
creator = db.relationship('User', backref='created_teams')
|
creator = db.relationship('User', backref='created_teams')
|
||||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Link between a player and a tryout-specific team."""
|
"""Link between a player and a tryout-specific team."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(db.Model):
|
class TeamMember(db.Model):
|
||||||
@@ -13,6 +12,6 @@ class TeamMember(db.Model):
|
|||||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
position = db.Column(db.String(50), nullable=True)
|
position = db.Column(db.String(50), nullable=True)
|
||||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Team improvement notes from coach."""
|
"""Team improvement notes from coach."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TeamNote(db.Model):
|
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)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||||
|
|
||||||
team = db.relationship('OrgTeam', backref='team_notes')
|
team = db.relationship('OrgTeam', backref='team_notes')
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Tryout event for player evaluations and team formation."""
|
"""Tryout event for player evaluations and team formation."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models._associations import tryout_coaches
|
from app.models._associations import tryout_coaches, tryout_managers
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class Tryout(db.Model):
|
class Tryout(db.Model):
|
||||||
@@ -21,16 +20,17 @@ class Tryout(db.Model):
|
|||||||
max_players = db.Column(db.Integer, nullable=True)
|
max_players = db.Column(db.Integer, nullable=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
||||||
coach_id = db.Column(
|
coach_id = db.Column(
|
||||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||||
) # deprecated, kept for migration
|
) # 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')
|
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||||
|
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||||
@@ -48,3 +48,17 @@ class Tryout(db.Model):
|
|||||||
if self.end_date is not None:
|
if self.end_date is not None:
|
||||||
return self.end_date < today
|
return self.end_date < today
|
||||||
return self.date < today
|
return self.date < today
|
||||||
|
|
||||||
|
def get_managers(self):
|
||||||
|
"""Managers attached to this tryout, both legacy and many-to-many."""
|
||||||
|
manager_list = list(self.managers)
|
||||||
|
if not manager_list and self.manager:
|
||||||
|
return [self.manager]
|
||||||
|
return manager_list
|
||||||
|
|
||||||
|
def get_coaches(self):
|
||||||
|
"""Coaches attached to this tryout, both legacy and many-to-many."""
|
||||||
|
coach_list = list(self.coaches)
|
||||||
|
if not coach_list and self.coach:
|
||||||
|
return [self.coach]
|
||||||
|
return coach_list
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Registration linking a player to a tryout."""
|
"""Registration linking a player to a tryout."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class TryoutRegistration(db.Model):
|
class TryoutRegistration(db.Model):
|
||||||
@@ -12,6 +11,6 @@ class TryoutRegistration(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
registered_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
status = db.Column(db.String(20), default='registered')
|
status = db.Column(db.String(20), default='registered')
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ class Manager(User):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_manage_this_tryout(self, tryout):
|
def can_manage_this_tryout(self, tryout):
|
||||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
return (
|
||||||
|
tryout.created_by == self.id
|
||||||
|
or tryout.manager_id == self.id
|
||||||
|
or any(m.id == self.id for m in tryout.managers)
|
||||||
|
)
|
||||||
|
|
||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return True
|
return True
|
||||||
@@ -32,7 +36,13 @@ class Manager(User):
|
|||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
return (
|
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)
|
.order_by(Tryout.date)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Base User model — shared fields and polymorphic configuration."""
|
"""Base User model — shared fields and polymorphic configuration."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
@@ -25,7 +24,7 @@ class User(UserMixin, db.Model):
|
|||||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
phone = db.Column(db.String(20), nullable=True)
|
phone = db.Column(db.String(20), nullable=True)
|
||||||
is_active_account = db.Column(db.Boolean, default=True)
|
is_active_account = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||||
locked_until = db.Column(db.DateTime, nullable=True)
|
locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -343,3 +343,73 @@ def coach_manages_tryout(coach, tryout):
|
|||||||
if tryout.target_org_team_id:
|
if tryout.target_org_team_id:
|
||||||
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
||||||
return False
|
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)
|
||||||
|
|||||||
@@ -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'))
|
||||||
+107
-22
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -18,13 +18,19 @@ from flask_login import current_user, login_required, login_user, logout_user
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import check_password, db, hash_password, limiter
|
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.i18n import LOCALE_SESSION_KEY
|
||||||
from app.logging_config import log_auth_event
|
from app.logging_config import log_auth_event
|
||||||
from app.models import ESPORT_GAMES, Player, User
|
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.
|
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||||
DISCORD_STATE_KEY = 'discord_oauth_state'
|
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
|
# 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).
|
# 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:
|
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||||
minutes = cooloff_minutes(user.failed_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(
|
log_auth_event(
|
||||||
'account.throttled',
|
'account.throttled',
|
||||||
username=username,
|
username=username,
|
||||||
@@ -354,6 +360,15 @@ def register():
|
|||||||
form_data = dict(request.form)
|
form_data = dict(request.form)
|
||||||
form_data['games'] = request.form.getlist('games')
|
form_data['games'] = request.form.getlist('games')
|
||||||
|
|
||||||
|
# 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)
|
refusal = check_registration_challenge(request.form)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
# Logged, because this is the only place abuse of the sign-up
|
# Logged, because this is the only place abuse of the sign-up
|
||||||
@@ -380,8 +395,25 @@ def register():
|
|||||||
full_name = validated['full_name']
|
full_name = validated['full_name']
|
||||||
phone = validated.get('phone')
|
phone = validated.get('phone')
|
||||||
selected_games = validated.get('games', [])
|
selected_games = validated.get('games', [])
|
||||||
discord_username = validated.get('discord_username')
|
try:
|
||||||
discord_user_id = validated.get('discord_user_id')
|
submitted_gamertags = form_gamertags(selected_games)
|
||||||
|
except ValidationError as err:
|
||||||
|
for field, messages in err.messages.items():
|
||||||
|
for msg in messages:
|
||||||
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||||
|
return _rerender_registration(form_data)
|
||||||
|
# The OAuth identity is server-side state. It used to be copied into
|
||||||
|
# hidden inputs and read back from request.form, which let anyone
|
||||||
|
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||||
|
# A manual registration may still provide a display name, but never a
|
||||||
|
# Discord snowflake: that identifier is an authentication factor for
|
||||||
|
# bot reactions and must come from Discord itself.
|
||||||
|
discord_user_id = discord_oauth.get('id')
|
||||||
|
if discord_user_id:
|
||||||
|
discord_user_id = str(discord_user_id)
|
||||||
|
discord_username = (
|
||||||
|
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
|
||||||
|
)
|
||||||
league_os_profile = validated.get('league_os_profile')
|
league_os_profile = validated.get('league_os_profile')
|
||||||
|
|
||||||
if User.query.filter_by(username=username).first():
|
if User.query.filter_by(username=username).first():
|
||||||
@@ -392,6 +424,13 @@ def register():
|
|||||||
flash(_('Email already registered.'), 'danger')
|
flash(_('Email already registered.'), 'danger')
|
||||||
return _rerender_registration(form_data)
|
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)
|
hashed_password = hash_password(password)
|
||||||
user = Player(
|
user = Player(
|
||||||
username=username,
|
username=username,
|
||||||
@@ -415,14 +454,12 @@ def register():
|
|||||||
# Create UserGamertag records for each selected game
|
# Create UserGamertag records for each selected game
|
||||||
from app.models import UserGamertag
|
from app.models import UserGamertag
|
||||||
|
|
||||||
for game in selected_games:
|
for game, gamertag_data in submitted_gamertags.items():
|
||||||
field_name = f'gamertag_{game}'
|
|
||||||
gamertag_value = request.form.get(field_name, '').strip()
|
|
||||||
if gamertag_value:
|
|
||||||
gamertag = UserGamertag(
|
gamertag = UserGamertag(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
game=game,
|
game=game,
|
||||||
gamertag=gamertag_value,
|
gamertag=gamertag_data['gamertag'],
|
||||||
|
platform=gamertag_data['platform'],
|
||||||
)
|
)
|
||||||
db.session.add(gamertag)
|
db.session.add(gamertag)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -451,11 +488,15 @@ def discord_login():
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Redirect to Discord authorization URL.
|
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
|
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||||
# raise inside the query builder rather than report a configuration error.
|
# raise inside the query builder rather than report a configuration error.
|
||||||
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||||
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
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
|
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||||
# attacker could have the victim's browser consume an authorization code
|
# attacker could have the victim's browser consume an authorization code
|
||||||
@@ -479,16 +520,24 @@ def discord_login():
|
|||||||
def discord_callback():
|
def discord_callback():
|
||||||
"""Handle the OAuth2 callback from Discord.
|
"""Handle the OAuth2 callback from Discord.
|
||||||
|
|
||||||
Exchanges the authorization code for an access token, then fetches
|
Exchanges the authorization code for an access token, then fetches the
|
||||||
the user's profile (/users/@me) and connections (/users/@me/connections).
|
user's profile. During registration, connected game accounts are also
|
||||||
Results are stored in the session and the user is redirected back to
|
loaded into server-side draft state. For a signed-in profile relink, the
|
||||||
the registration form where fields will be pre-filled.
|
verified identity is written directly without passing through a form.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: Redirect to registration page.
|
Response: Redirect to the registration form or profile editor.
|
||||||
"""
|
"""
|
||||||
# The state is consumed whatever happens next: a token is single-use, and
|
# The state is consumed whatever happens next: a token is single-use, and
|
||||||
# leaving it in the session would allow a replay.
|
# 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)
|
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||||
received_state = request.args.get('state', '')
|
received_state = request.args.get('state', '')
|
||||||
|
|
||||||
@@ -500,12 +549,12 @@ def discord_callback():
|
|||||||
),
|
),
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
code = request.args.get('code')
|
code = request.args.get('code')
|
||||||
if not code:
|
if not code:
|
||||||
flash(_('Discord authorization failed. No code received.'), 'danger')
|
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
# Exchange the authorization code for an access token
|
# Exchange the authorization code for an access token
|
||||||
token_data = {
|
token_data = {
|
||||||
@@ -529,11 +578,11 @@ def discord_callback():
|
|||||||
access_token = token_json.get('access_token')
|
access_token = token_json.get('access_token')
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
flash(_('Failed to obtain Discord access token.'), 'danger')
|
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||||
|
|
||||||
@@ -548,7 +597,43 @@ def discord_callback():
|
|||||||
user_data = user_response.json()
|
user_data = user_response.json()
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||||
return redirect(url_for('auth.register'))
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
|
discord_user_id = user_data.get('id')
|
||||||
|
try:
|
||||||
|
if not discord_user_id:
|
||||||
|
raise ValidationError('missing Discord user id')
|
||||||
|
discord_user_id = str(discord_user_id)
|
||||||
|
validate_discord_user_id(discord_user_id)
|
||||||
|
except ValidationError:
|
||||||
|
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||||
|
return redirect(url_for(return_endpoint))
|
||||||
|
|
||||||
|
if purpose == 'profile':
|
||||||
|
# If the session expired while Discord was open, do not turn a profile
|
||||||
|
# relink into registration state for an anonymous browser.
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
flash(_('Please log in to connect your Discord account.'), 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
clash = User.query.filter(
|
||||||
|
User.discord_user_id == discord_user_id,
|
||||||
|
User.id != current_user.id,
|
||||||
|
).first()
|
||||||
|
if clash:
|
||||||
|
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||||
|
return redirect(url_for('users.edit_profile'))
|
||||||
|
|
||||||
|
current_user.discord_user_id = discord_user_id
|
||||||
|
current_user.discord_username = user_data.get('username') or None
|
||||||
|
db.session.commit()
|
||||||
|
log_auth_event(
|
||||||
|
'account.discord_linked',
|
||||||
|
username=current_user.username,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
flash(_('Discord account connected!'), 'success')
|
||||||
|
return redirect(url_for('users.edit_profile'))
|
||||||
|
|
||||||
# Fetch the user's connected gaming accounts
|
# Fetch the user's connected gaming accounts
|
||||||
connections = []
|
connections = []
|
||||||
@@ -587,7 +672,7 @@ def discord_callback():
|
|||||||
|
|
||||||
# Store in session for the registration form to use
|
# Store in session for the registration form to use
|
||||||
session['discord_oauth'] = {
|
session['discord_oauth'] = {
|
||||||
'id': user_data.get('id'),
|
'id': discord_user_id,
|
||||||
'username': user_data.get('username'),
|
'username': user_data.get('username'),
|
||||||
'avatar': user_data.get('avatar'),
|
'avatar': user_data.get('avatar'),
|
||||||
'gamertag_suggestions': gamertag_suggestions,
|
'gamertag_suggestions': gamertag_suggestions,
|
||||||
|
|||||||
+131
-15
@@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
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 import func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
@@ -27,6 +35,14 @@ from app.validators import EvaluationSchema
|
|||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||||
|
|
||||||
|
|
||||||
|
def _users_by_id(user_ids):
|
||||||
|
"""Load a set of users once for aggregate/list views."""
|
||||||
|
wanted = {user_id for user_id in user_ids if user_id}
|
||||||
|
if not wanted:
|
||||||
|
return {}
|
||||||
|
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||||
|
|
||||||
|
|
||||||
@evaluations_bp.route('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
@@ -85,9 +101,10 @@ def list_evaluations():
|
|||||||
.group_by(Evaluation.player_id)
|
.group_by(Evaluation.player_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
||||||
player_scores = {}
|
player_scores = {}
|
||||||
for row in avg_scores:
|
for row in avg_scores:
|
||||||
p = User.query.get(row.player_id)
|
p = players_by_id.get(row.player_id)
|
||||||
if p:
|
if p:
|
||||||
player_scores[p.id] = {
|
player_scores[p.id] = {
|
||||||
'player': p,
|
'player': p,
|
||||||
@@ -126,7 +143,7 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
flash(_('You do not have permission to evaluate players.'), 'danger')
|
flash(_('You do not have permission to evaluate players.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
@@ -142,7 +159,7 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return 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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only evaluate players.'), 'danger')
|
flash(_('Can only evaluate players.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return 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,
|
tryout_id=tryout_id,
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
).all()
|
).all()
|
||||||
|
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
|
||||||
evaluators = [
|
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(
|
return render_template(
|
||||||
@@ -210,21 +229,118 @@ def players_to_evaluate(tryout_id):
|
|||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||||
players = []
|
players_by_id = _users_by_id(reg.player_id for reg in registrations)
|
||||||
for reg in registrations:
|
evaluated_player_ids = {
|
||||||
p = User.query.get(reg.player_id)
|
player_id
|
||||||
if p and isinstance(p, Player):
|
for (player_id,) in db.session.query(Evaluation.player_id)
|
||||||
existing = Evaluation.query.filter_by(
|
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
|
||||||
tryout_id=tryout_id,
|
.all()
|
||||||
player_id=p.id,
|
}
|
||||||
evaluator_id=current_user.id,
|
players = [
|
||||||
).first()
|
{
|
||||||
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
'player': player,
|
||||||
|
'evaluated': player.id in evaluated_player_ids,
|
||||||
|
'registration': registration,
|
||||||
|
}
|
||||||
|
for registration in registrations
|
||||||
|
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
|
||||||
|
]
|
||||||
|
|
||||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||||
|
|
||||||
|
|
||||||
|
@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
@@ -223,20 +223,18 @@ def dashboard():
|
|||||||
elif isinstance(user, Scout):
|
elif isinstance(user, Scout):
|
||||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||||
stats['total_evaluations'] = Evaluation.query.count()
|
stats['total_evaluations'] = Evaluation.query.count()
|
||||||
stats['avg_scores'] = (
|
top_rows = (
|
||||||
db.session.query(
|
db.session.query(
|
||||||
Evaluation.player_id,
|
User,
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
)
|
)
|
||||||
.group_by(Evaluation.player_id)
|
.join(Evaluation, Evaluation.player_id == User.id)
|
||||||
.order_by(func.avg(Evaluation.overall_score).desc())
|
.filter(User.role == 'player')
|
||||||
|
.group_by(User.id)
|
||||||
|
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
|
||||||
.limit(5)
|
.limit(5)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
stats['top_players'] = []
|
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
|
||||||
for row in stats['avg_scores']:
|
|
||||||
p = User.query.get(row.player_id)
|
|
||||||
if p:
|
|
||||||
stats['top_players'].append((p, round(row.avg_score, 1)))
|
|
||||||
|
|
||||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||||
|
|||||||
+27
-14
@@ -46,6 +46,25 @@ def match_form_payload():
|
|||||||
return form_payload(list_fields=('player_ids',), optional_blank=())
|
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.
|
#: How long a match lasts when the form gives a start and no end.
|
||||||
DEFAULT_MATCH_MINUTES = 30
|
DEFAULT_MATCH_MINUTES = 30
|
||||||
|
|
||||||
@@ -270,7 +289,7 @@ def api_events():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_events_for_tryout(tryout_id):
|
def api_events_for_tryout(tryout_id):
|
||||||
"""API endpoint returning calendar events for a specific tryout."""
|
"""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)
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
|
|
||||||
is_registered = False
|
is_registered = False
|
||||||
@@ -359,7 +378,7 @@ def api_events_for_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_match(tryout_id):
|
def create_match(tryout_id):
|
||||||
"""Create a new match / scrimmage within a tryout."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
all_players = registered_players(tryout_id)
|
||||||
all_players = [
|
|
||||||
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)
|
|
||||||
]
|
|
||||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
|
||||||
prefill_date = request.args.get('date', '')
|
prefill_date = request.args.get('date', '')
|
||||||
|
|
||||||
def rerender():
|
def rerender():
|
||||||
@@ -437,7 +452,7 @@ def create_match(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_match(match_id):
|
def edit_match(match_id):
|
||||||
"""Edit an existing match."""
|
"""Edit an existing match."""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = db.get_or_404(Match, match_id)
|
||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
|
all_players = registered_players(tryout.id)
|
||||||
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)
|
|
||||||
current_player_ids = [p.player_id for p in match.participants.all()]
|
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()]
|
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()]
|
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
|
@login_required
|
||||||
def delete_match(match_id):
|
def delete_match(match_id):
|
||||||
"""Delete a match."""
|
"""Delete a match."""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = db.get_or_404(Match, match_id)
|
||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
@@ -656,10 +669,10 @@ def api_available_players(date, time):
|
|||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle attendance_confirmed for a match participant."""
|
"""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
|
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:
|
if participant.match_id != match_id:
|
||||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
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, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
@@ -22,11 +20,13 @@ from app.models import (
|
|||||||
TeamMatch,
|
TeamMatch,
|
||||||
TeamMatchParticipant,
|
TeamMatchParticipant,
|
||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
|
AppSettings,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import paginate
|
||||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||||
from app.routes.matches import default_end_time
|
from app.routes.matches import default_end_time
|
||||||
from app.services.scheduling import notify_participants, zip_participants
|
from app.services.scheduling import notify_participants, zip_participants
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import TeamMatchSchema
|
from app.validators import TeamMatchSchema
|
||||||
|
|
||||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||||
@@ -41,6 +41,17 @@ def can_manage_team_match(team):
|
|||||||
return can_manage_org_team(current_user, 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('')
|
@team_matches_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_matches():
|
def list_matches():
|
||||||
@@ -100,7 +111,7 @@ def list_matches():
|
|||||||
teams=teams,
|
teams=teams,
|
||||||
match_data=match_data,
|
match_data=match_data,
|
||||||
pagination=matches_page,
|
pagination=matches_page,
|
||||||
now=datetime.utcnow(),
|
now=utc_now_naive(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +119,11 @@ def list_matches():
|
|||||||
@login_required
|
@login_required
|
||||||
def create_match(team_id):
|
def create_match(team_id):
|
||||||
"""Create a new regular-season team match."""
|
"""Create a new regular-season team match."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
|
if 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):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
@@ -213,7 +228,7 @@ def create_match(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_match(match_id):
|
def edit_match(match_id):
|
||||||
"""Edit an existing team match."""
|
"""Edit an existing team match."""
|
||||||
team_match = TeamMatch.query.get_or_404(match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
@@ -259,11 +274,16 @@ def edit_match(match_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def delete_match(match_id):
|
def delete_match(match_id):
|
||||||
"""Delete a team match."""
|
"""Delete a team match."""
|
||||||
team_match = TeamMatch.query.get_or_404(match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
|
if season_locked():
|
||||||
|
flash('The regular season is not active. Begin a season before deleting matches.', 'warning')
|
||||||
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
db.session.delete(team_match)
|
db.session.delete(team_match)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Match deleted successfully.'), 'success')
|
flash(_('Match deleted successfully.'), 'success')
|
||||||
@@ -293,10 +313,10 @@ def api_manageable_teams():
|
|||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle is_confirmed for a team match participant."""
|
"""Toggle is_confirmed for a team match participant."""
|
||||||
team_match = TeamMatch.query.get_or_404(match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
|
|
||||||
participant = TeamMatchParticipant.query.get_or_404(participant_id)
|
participant = db.get_or_404(TeamMatchParticipant, participant_id)
|
||||||
if participant.team_match_id != match_id:
|
if participant.team_match_id != match_id:
|
||||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||||
|
|
||||||
|
|||||||
+31
-24
@@ -3,9 +3,7 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
@@ -29,7 +27,8 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.permissions import visible_org_teams
|
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')
|
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ def my_teams():
|
|||||||
from app.models import TeamMatch, TeamMatchParticipant
|
from app.models import TeamMatch, TeamMatchParticipant
|
||||||
|
|
||||||
player_teams = current_user.get_org_teams()
|
player_teams = current_user.get_org_teams()
|
||||||
now = datetime.utcnow()
|
now = utc_now_naive()
|
||||||
team_data = []
|
team_data = []
|
||||||
|
|
||||||
for org_team in player_teams:
|
for org_team in player_teams:
|
||||||
@@ -223,7 +222,7 @@ def create_team():
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_team(team_id):
|
def edit_team(team_id):
|
||||||
"""Edit an existing organization team."""
|
"""Edit an existing organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to edit this team.'), 'danger')
|
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return 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 —
|
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
|
||||||
deletion narrows with it instead of staying the one way in.
|
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)):
|
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')
|
flash(_('You do not have permission to delete teams.'), 'danger')
|
||||||
@@ -336,7 +335,7 @@ def delete_team(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_coach(team_id):
|
def add_coach(team_id):
|
||||||
"""Add a coach to an organization team."""
|
"""Add a coach to an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -379,7 +378,7 @@ def add_coach(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_manager(team_id):
|
def add_manager(team_id):
|
||||||
"""Add a manager to an organization team."""
|
"""Add a manager to an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -422,7 +421,7 @@ def add_manager(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_coach(team_id):
|
def remove_coach(team_id):
|
||||||
"""Remove a coach from an organization team."""
|
"""Remove a coach from an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -450,7 +449,7 @@ def remove_coach(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_manager(team_id):
|
def remove_manager(team_id):
|
||||||
"""Remove a manager from an organization team."""
|
"""Remove a manager from an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -478,7 +477,7 @@ def remove_manager(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_player(team_id):
|
def add_player(team_id):
|
||||||
"""Add a player to an organization team."""
|
"""Add a player to an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -515,12 +514,12 @@ def add_player(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_player(team_id, player_id):
|
def remove_player(team_id, player_id):
|
||||||
"""Remove a player from an organization team."""
|
"""Remove a player from an organization team."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return 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()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
if not tp:
|
if not tp:
|
||||||
flash(
|
flash(
|
||||||
@@ -543,7 +542,7 @@ def remove_player(team_id, player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def toggle_player_status(team_id, player_id):
|
def toggle_player_status(team_id, player_id):
|
||||||
"""Toggle a player's status between starter and substitute."""
|
"""Toggle a player's status between starter and substitute."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
return jsonify({'error': 'Permission denied'}), 403
|
return jsonify({'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
@@ -567,14 +566,18 @@ def toggle_player_status(team_id, player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_team_note(team_id):
|
def add_team_note(team_id):
|
||||||
"""Add a team improvement note (coaches only)."""
|
"""Add a team improvement note (coaches only)."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return 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.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team notes added successfully!'), 'success')
|
flash(_('Team notes added successfully!'), 'success')
|
||||||
@@ -585,12 +588,12 @@ def add_team_note(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_player_note(team_id, player_id):
|
def add_player_note(team_id, player_id):
|
||||||
"""Add a personal note for a player (coaches only)."""
|
"""Add a personal note for a player (coaches only)."""
|
||||||
team = OrgTeam.query.get_or_404(team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return 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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -603,9 +606,13 @@ def add_player_note(team_id, player_id):
|
|||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return 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.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||||
|
|||||||
+137
-37
@@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations.
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
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 import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
@@ -31,8 +30,17 @@ from app.models import (
|
|||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
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')
|
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
|
||||||
|
|
||||||
@@ -42,9 +50,20 @@ def can_manage():
|
|||||||
return isinstance(current_user, (Admin, Manager))
|
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():
|
def tryout_form_payload():
|
||||||
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
"""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):
|
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()
|
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):
|
def _users_by_id(user_ids):
|
||||||
"""Load these users in one query, keyed by id.
|
"""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()}
|
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('')
|
@tryouts_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_tryouts():
|
def list_tryouts():
|
||||||
@@ -87,13 +132,17 @@ def list_tryouts():
|
|||||||
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
|
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
|
||||||
"""
|
"""
|
||||||
tryouts = current_user.get_visible_tryouts()
|
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'])
|
@tryouts_bp.route('/create', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_tryout():
|
def create_tryout():
|
||||||
"""Create a new tryout event. Requires Admin or Manager."""
|
"""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():
|
if not can_manage():
|
||||||
flash(_('You do not have permission to create tryouts.'), 'danger')
|
flash(_('You do not have permission to create tryouts.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
@@ -134,12 +183,12 @@ def create_tryout():
|
|||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
status='upcoming',
|
status='upcoming',
|
||||||
target_org_team_id=data['target_org_team_id'],
|
target_org_team_id=data['target_org_team_id'],
|
||||||
manager_id=data['manager_id'],
|
|
||||||
)
|
)
|
||||||
db.session.add(tryout)
|
db.session.add(tryout)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||||
|
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Tryout created successfully!'), 'success')
|
flash(_('Tryout created successfully!'), 'success')
|
||||||
@@ -152,7 +201,11 @@ def create_tryout():
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_tryout(tryout_id):
|
def edit_tryout(tryout_id):
|
||||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||||
@@ -195,8 +248,14 @@ def edit_tryout(tryout_id):
|
|||||||
tryout.location = data['location']
|
tryout.location = data['location']
|
||||||
tryout.max_players = data['max_players']
|
tryout.max_players = data['max_players']
|
||||||
tryout.target_org_team_id = data['target_org_team_id']
|
tryout.target_org_team_id = data['target_org_team_id']
|
||||||
tryout.manager_id = data['manager_id']
|
|
||||||
|
# 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'])
|
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()
|
db.session.commit()
|
||||||
flash(_('Tryout updated successfully!'), 'success')
|
flash(_('Tryout updated successfully!'), 'success')
|
||||||
@@ -209,13 +268,13 @@ def edit_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def view_tryout(tryout_id):
|
def view_tryout(tryout_id):
|
||||||
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
|
"""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
|
can_view = False
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Admin):
|
||||||
can_view = True
|
can_view = True
|
||||||
elif isinstance(current_user, Manager):
|
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):
|
elif isinstance(current_user, Coach):
|
||||||
can_view = current_user.can_manage_this_tryout(tryout)
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
elif isinstance(current_user, Player):
|
elif isinstance(current_user, Player):
|
||||||
@@ -398,7 +457,7 @@ def view_tryout(tryout_id):
|
|||||||
matches=matches,
|
matches=matches,
|
||||||
match_data=match_data,
|
match_data=match_data,
|
||||||
game_positions=GAME_POSITIONS,
|
game_positions=GAME_POSITIONS,
|
||||||
now=datetime.utcnow(),
|
now=utc_now_naive(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,10 +465,10 @@ def view_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def register_for_tryout(tryout_id):
|
def register_for_tryout(tryout_id):
|
||||||
"""Register a player for a tryout. Only Players can self-register."""
|
"""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):
|
if not isinstance(current_user, Player):
|
||||||
flash(_('Only players can register for tryouts.'), 'danger')
|
flash(_('Only players can register for tryouts.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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']:
|
if tryout.status not in ['upcoming', 'in_progress']:
|
||||||
flash(_('This tryout is not accepting registrations.'), 'danger')
|
flash(_('This tryout is not accepting registrations.'), 'danger')
|
||||||
@@ -439,15 +498,23 @@ def register_for_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def update_status(tryout_id):
|
def update_status(tryout_id):
|
||||||
"""Update the status of a tryout."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
new_status = request.form.get('status')
|
try:
|
||||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
||||||
tryout.status = new_status
|
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()
|
db.session.commit()
|
||||||
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
|
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -455,7 +522,11 @@ def update_status(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def update_registration_status(tryout_id, player_id):
|
def update_registration_status(tryout_id, player_id):
|
||||||
"""Update a registration's attendance status."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
@@ -463,9 +534,13 @@ def update_registration_status(tryout_id, player_id):
|
|||||||
registration = TryoutRegistration.query.filter_by(
|
registration = TryoutRegistration.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id
|
tryout_id=tryout_id, player_id=player_id
|
||||||
).first_or_404()
|
).first_or_404()
|
||||||
new_status = request.form.get('status')
|
try:
|
||||||
if new_status in ['registered', 'attended', 'no_show']:
|
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
|
||||||
registration.status = new_status
|
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()
|
db.session.commit()
|
||||||
flash(_('Registration status updated.'), 'success')
|
flash(_('Registration status updated.'), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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
|
@login_required
|
||||||
def register_player(tryout_id):
|
def register_player(tryout_id):
|
||||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
@@ -522,12 +601,16 @@ def register_player(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_player(tryout_id, player_id):
|
def remove_player(tryout_id, player_id):
|
||||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
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(
|
registration = TryoutRegistration.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id
|
tryout_id=tryout_id, player_id=player_id
|
||||||
@@ -558,17 +641,25 @@ def remove_player(tryout_id, player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_team(tryout_id):
|
def create_team(tryout_id):
|
||||||
"""Create a tryout-specific team."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
team_name = request.form.get('team_name')
|
try:
|
||||||
if team_name:
|
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
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.add(team)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
|
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -576,8 +667,12 @@ def create_team(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_to_team(tryout_id, team_id):
|
def add_to_team(tryout_id, team_id):
|
||||||
"""Add a player to a tryout team."""
|
"""Add a player to a tryout team."""
|
||||||
team = Team.query.get_or_404(team_id)
|
team = db.get_or_404(Team, team_id)
|
||||||
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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:
|
if team.tryout_id != tryout_id:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
if not player_id:
|
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
|
||||||
flash(_('Please select a player.'), 'danger')
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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.
|
# Only players registered for this tryout may be placed on its teams.
|
||||||
is_registered = (
|
is_registered = (
|
||||||
@@ -602,12 +699,11 @@ def add_to_team(tryout_id, team_id):
|
|||||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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()
|
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
flash(_('Player is already on this team.'), 'info')
|
flash(_('Player is already on this team.'), 'info')
|
||||||
else:
|
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.add(member)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Player added to team!'), 'success')
|
flash(_('Player added to team!'), 'success')
|
||||||
@@ -618,7 +714,11 @@ def add_to_team(tryout_id, team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def delete_tryout(tryout_id):
|
def delete_tryout(tryout_id):
|
||||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|||||||
+35
-11
@@ -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.
|
can call them with a request context and nothing else.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from app.extensions import db
|
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
|
# 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.
|
# 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.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_CONTRACT_EXTENSIONS = {'pdf'}
|
||||||
ALLOWED_SIGNED_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
|
could put an arbitrary file on the server under a name the application
|
||||||
later hands back for download (SEC-021).
|
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:
|
Args:
|
||||||
file: The uploaded FileStorage, or None.
|
file: The uploaded FileStorage, or None.
|
||||||
allowed_extensions: Extensions to accept, lowercase and without dot.
|
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:
|
if not (stem and dot) or extension.lower() not in allowed_extensions:
|
||||||
return _('Only PDF files are allowed for contracts.')
|
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))
|
head = file.stream.read(len(PDF_SIGNATURE))
|
||||||
file.stream.seek(0)
|
file.stream.seek(0)
|
||||||
if head != PDF_SIGNATURE:
|
if head != PDF_SIGNATURE:
|
||||||
return _('That file is not a PDF, whatever its name says.')
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_user_gamertags(user, selected_games):
|
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}
|
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||||
for game in selected_games:
|
for game in selected_games:
|
||||||
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
payload = submitted.get(game)
|
||||||
platform = (
|
|
||||||
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
|
||||||
)
|
|
||||||
existing = existing_gamertags.get(game)
|
existing = existing_gamertags.get(game)
|
||||||
if gamertag:
|
if payload:
|
||||||
if existing:
|
if existing:
|
||||||
existing.gamertag = gamertag
|
existing.gamertag = payload['gamertag']
|
||||||
existing.platform = platform
|
existing.platform = payload['platform']
|
||||||
else:
|
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)
|
db.session.add(gt)
|
||||||
elif existing:
|
elif existing:
|
||||||
db.session.delete(existing)
|
db.session.delete(existing)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from app.routes.users._shared import (
|
|||||||
)
|
)
|
||||||
from app.routes.users.blueprint import users_bp
|
from app.routes.users.blueprint import users_bp
|
||||||
from app.storage import discard_documents
|
from app.storage import discard_documents
|
||||||
|
from app.timeslots import total_hours
|
||||||
from app.validators import CreateUserSchema, EditUserSchema
|
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
|
# query without a stable ORDER BY can show the same row twice and never
|
||||||
# show another (MNT-14).
|
# show another (MNT-14).
|
||||||
page = paginate(User.query.order_by(User.role, User.username, User.id))
|
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'])
|
@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')
|
flash(_('Only the president can edit users.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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':
|
if request.method == 'POST':
|
||||||
actor_name, actor_id = current_user.username, current_user.id
|
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')
|
flash(_('Email already in use by another account.'), 'danger')
|
||||||
return _rerender()
|
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
|
role_changed = user.role != role
|
||||||
previous_role = user.role
|
previous_role = user.role
|
||||||
|
|
||||||
@@ -173,8 +222,6 @@ def edit_user(user_id):
|
|||||||
user.discord_user_id = discord_user_id or None
|
user.discord_user_id = discord_user_id or None
|
||||||
user.league_os_profile = league_os_profile 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
|
# Blank means "keep the current password"; anything else has already
|
||||||
# been checked against the policy by the schema.
|
# been checked against the policy by the schema.
|
||||||
password = validated.get('password')
|
password = validated.get('password')
|
||||||
@@ -239,7 +286,7 @@ def delete_user(user_id):
|
|||||||
flash(_('You cannot delete your own account.'), 'danger')
|
flash(_('You cannot delete your own account.'), 'danger')
|
||||||
return redirect(url_for('users.list_users'))
|
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(
|
Evaluation.query.filter(
|
||||||
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
|
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
|
||||||
@@ -365,5 +412,20 @@ def create_user():
|
|||||||
@login_required
|
@login_required
|
||||||
def view_user(user_id):
|
def view_user(user_id):
|
||||||
"""View a public profile for any user."""
|
"""View a public profile for any user."""
|
||||||
user = User.query.get_or_404(user_id)
|
user = db.get_or_404(User, user_id)
|
||||||
return render_template('pages/view_user.html', profile_user=user)
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
|
|||||||
sessions.
|
sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
from flask import jsonify, request
|
||||||
from flask_babel import gettext as _
|
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
@@ -141,19 +140,23 @@ def add_disponibility():
|
|||||||
@json_endpoint
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def add_disponibilities_bulk():
|
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 {}
|
data = request.get_json(silent=True) or {}
|
||||||
accepted, rejected = _load_slots(data.get('slots'))
|
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 = []
|
created = []
|
||||||
for slot in accepted:
|
for slot in accepted:
|
||||||
start_time = slot['start_time']
|
start_time = slot['start_time']
|
||||||
existing = PlayerDisponibility.query.filter_by(
|
|
||||||
player_id=current_user.id,
|
|
||||||
day_of_week=slot['day_of_week'],
|
|
||||||
start_time=start_time,
|
|
||||||
).first()
|
|
||||||
if not existing:
|
|
||||||
disponibility = PlayerDisponibility(
|
disponibility = PlayerDisponibility(
|
||||||
player_id=current_user.id,
|
player_id=current_user.id,
|
||||||
day_of_week=slot['day_of_week'],
|
day_of_week=slot['day_of_week'],
|
||||||
@@ -171,11 +174,7 @@ def add_disponibilities_bulk():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
# `rejected` is reported rather than swallowed. What was accepted is
|
return jsonify({'success': True, 'created': created, 'rejected': []})
|
||||||
# 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})
|
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
||||||
@@ -193,7 +192,7 @@ def clear_disponibilities():
|
|||||||
@login_required
|
@login_required
|
||||||
def delete_disponibility(disponibility_id):
|
def delete_disponibility(disponibility_id):
|
||||||
"""Delete a disponibility block."""
|
"""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:
|
if disponibility.player_id != current_user.id:
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
db.session.delete(disponibility)
|
db.session.delete(disponibility)
|
||||||
@@ -201,16 +200,14 @@ def delete_disponibility(disponibility_id):
|
|||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
@users_bp.route('/coach-availability', methods=['POST'])
|
||||||
@json_endpoint
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def manage_coach_availability():
|
def manage_coach_availability():
|
||||||
"""Manage coach availability for One on One sessions."""
|
"""Manage coach availability for One on One sessions."""
|
||||||
if not isinstance(current_user, Coach):
|
if not isinstance(current_user, Coach):
|
||||||
flash(_('Only coaches can manage availability.'), 'danger')
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
accepted, rejected = _load_slots(data.get('slots'))
|
accepted, rejected = _load_slots(data.get('slots'))
|
||||||
|
|
||||||
@@ -248,14 +245,6 @@ def manage_coach_availability():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'success': True, 'saved': len(accepted)})
|
return jsonify({'success': True, 'saved': len(accepted)})
|
||||||
|
|
||||||
existing_availability = CoachAvailability.query.filter_by(
|
|
||||||
coach_id=current_user.id,
|
|
||||||
).all()
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'pages/coach_availability.html', existing_availability=existing_availability
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||||
@json_endpoint
|
@json_endpoint
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
@@ -12,6 +11,7 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
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.permissions import can_manage_player_contract, coach_player_ids
|
||||||
from app.routes.users._shared import (
|
from app.routes.users._shared import (
|
||||||
ALLOWED_CONTRACT_EXTENSIONS,
|
ALLOWED_CONTRACT_EXTENSIONS,
|
||||||
@@ -19,7 +19,14 @@ from app.routes.users._shared import (
|
|||||||
pdf_upload_error,
|
pdf_upload_error,
|
||||||
)
|
)
|
||||||
from app.routes.users.blueprint import users_bp
|
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
|
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()
|
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')
|
@users_bp.route('/contracts')
|
||||||
@login_required
|
@login_required
|
||||||
def list_contracts():
|
def list_contracts():
|
||||||
@@ -86,6 +104,10 @@ def upload_contract():
|
|||||||
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
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()
|
players = manageable_players()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -111,7 +133,7 @@ def upload_contract():
|
|||||||
flash(error, 'danger')
|
flash(error, 'danger')
|
||||||
return redirect(url_for('users.upload_contract'))
|
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()
|
player_teams = player.get_org_teams()
|
||||||
team = player_teams[0] if player_teams else None
|
team = player_teams[0] if player_teams else None
|
||||||
|
|
||||||
@@ -126,9 +148,13 @@ def upload_contract():
|
|||||||
if team:
|
if team:
|
||||||
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
||||||
|
|
||||||
absolute_path = document_path(relative_path)
|
try:
|
||||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
stored_path = store_uploaded_document(file, relative_path)
|
||||||
file.save(absolute_path)
|
except GoogleDriveStorageError:
|
||||||
|
flash(
|
||||||
|
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
|
||||||
|
)
|
||||||
|
return redirect(url_for('users.upload_contract'))
|
||||||
|
|
||||||
contract = Contract(
|
contract = Contract(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -136,7 +162,7 @@ def upload_contract():
|
|||||||
uploaded_by_id=current_user.id,
|
uploaded_by_id=current_user.id,
|
||||||
original_filename=original_filename,
|
original_filename=original_filename,
|
||||||
stored_filename=stored_filename,
|
stored_filename=stored_filename,
|
||||||
file_path=relative_path,
|
file_path=stored_path,
|
||||||
notes=notes if notes else None,
|
notes=notes if notes else None,
|
||||||
)
|
)
|
||||||
db.session.add(contract)
|
db.session.add(contract)
|
||||||
@@ -154,11 +180,15 @@ def upload_contract():
|
|||||||
@login_required
|
@login_required
|
||||||
def upload_signed_contract(contract_id):
|
def upload_signed_contract(contract_id):
|
||||||
"""Upload a signed contract (player only)."""
|
"""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):
|
if not contract.can_upload_signed(current_user):
|
||||||
flash(_('Only the player can upload their signed contract.'), 'danger')
|
flash(_('Only the player can upload their signed contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
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')
|
file = request.files.get('signed_file')
|
||||||
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
||||||
if error:
|
if error:
|
||||||
@@ -166,13 +196,24 @@ def upload_signed_contract(contract_id):
|
|||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
signed_filename = f"signed_{contract.stored_filename}"
|
signed_filename = f"signed_{contract.stored_filename}"
|
||||||
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
# Legacy local records keep their signed copy beside the original. Drive
|
||||||
file.save(document_path(signed_path))
|
# 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_filename = signed_filename
|
||||||
contract.signed_file_path = signed_path
|
contract.signed_file_path = stored_signed_path
|
||||||
contract.status = 'signed'
|
contract.status = 'signed'
|
||||||
contract.signed_at = datetime.utcnow()
|
contract.signed_at = utc_now_naive()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Signed contract uploaded successfully!'), 'success')
|
flash(_('Signed contract uploaded successfully!'), 'success')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
@@ -182,30 +223,32 @@ def upload_signed_contract(contract_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def download_contract(contract_id):
|
def download_contract(contract_id):
|
||||||
"""Download a contract file."""
|
"""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):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
return send_file(
|
try:
|
||||||
document_path(contract.file_path),
|
document = open_document(contract.file_path)
|
||||||
as_attachment=True,
|
except GoogleDriveStorageError:
|
||||||
download_name=contract.original_filename,
|
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')
|
@users_bp.route('/contracts/<int:contract_id>/download_signed')
|
||||||
@login_required
|
@login_required
|
||||||
def download_signed_contract(contract_id):
|
def download_signed_contract(contract_id):
|
||||||
"""Download a signed contract file."""
|
"""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):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
if not contract.signed_file_path:
|
if not contract.signed_file_path:
|
||||||
flash(_('No signed contract available.'), 'danger')
|
flash(_('No signed contract available.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
return send_file(
|
try:
|
||||||
document_path(contract.signed_file_path),
|
document = open_document(contract.signed_file_path)
|
||||||
as_attachment=True,
|
except GoogleDriveStorageError:
|
||||||
download_name=contract.signed_filename,
|
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)
|
||||||
|
|||||||
+109
-48
@@ -7,23 +7,32 @@ it reads exactly what the coach routes write.
|
|||||||
from flask import flash, redirect, render_template, request, url_for
|
from flask import flash, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Coach,
|
Coach,
|
||||||
Match,
|
Match,
|
||||||
MatchParticipant,
|
MatchParticipant,
|
||||||
OneOnOneRequest,
|
OneOnOneRequest,
|
||||||
OrgTeam,
|
|
||||||
PersonalNote,
|
PersonalNote,
|
||||||
Player,
|
Player,
|
||||||
|
Team,
|
||||||
|
TeamMember,
|
||||||
TeamNote,
|
TeamNote,
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
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.routes.users.blueprint import users_bp
|
||||||
|
from app.validators import NoteContentSchema, PersonalNoteSchema
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/my-notes')
|
@users_bp.route('/my-notes')
|
||||||
@@ -116,23 +125,25 @@ def notes_dashboard():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# For context selectors in the form
|
# 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 = (
|
matches = (
|
||||||
Match.query.filter(
|
Match.query.filter(Match.tryout_id.in_(tryout_ids))
|
||||||
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
|
||||||
)
|
|
||||||
.order_by(Match.date.desc())
|
.order_by(Match.date.desc())
|
||||||
.limit(20)
|
.limit(20)
|
||||||
.all()
|
.all()
|
||||||
|
if tryout_ids
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
tryouts = (
|
teams = (
|
||||||
Tryout.query.filter_by(
|
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
|
||||||
created_by=current_user.id,
|
if tryout_ids
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
.order_by(Tryout.date.desc())
|
|
||||||
.limit(20)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/notes.html',
|
'pages/notes.html',
|
||||||
@@ -168,12 +179,16 @@ def manage_team_notes():
|
|||||||
return redirect(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
org_team = org_teams[0]
|
org_team = org_teams[0]
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = TeamNote(
|
note = TeamNote(
|
||||||
org_team_id=org_team.id,
|
org_team_id=org_team.id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -195,14 +210,14 @@ def manage_personal_notes():
|
|||||||
flash(_('Only coaches can manage personal notes.'), 'danger')
|
flash(_('Only coaches can manage personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
if not player_id or not content:
|
flash_validation_errors(err)
|
||||||
flash(_('Player and content are required.'), 'danger')
|
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
@@ -214,7 +229,7 @@ def manage_personal_notes():
|
|||||||
note = PersonalNote(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -235,17 +250,14 @@ def add_personal_note():
|
|||||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
match_id = request.form.get('match_id', type=int)
|
except ValidationError as err:
|
||||||
tryout_id = request.form.get('tryout_id', type=int)
|
flash_validation_errors(err)
|
||||||
team_id_str = request.form.get('team_id')
|
|
||||||
|
|
||||||
if not player_id or not content:
|
|
||||||
flash(_('Player and content are required.'), 'danger')
|
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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')
|
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
match_id=match_id if match_id else None,
|
match_id=data['match_id'],
|
||||||
tryout_id=tryout_id if tryout_id else None,
|
tryout_id=data['tryout_id'],
|
||||||
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
team_id=data['team_id'],
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id):
|
|||||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
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)
|
preselected_player_id = request.args.get('player_id', type=int)
|
||||||
|
|
||||||
# Get registrations as players for the select list
|
# 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]
|
players = [r.player for r in registrations if r.player]
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
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:
|
if data['tryout_id'] not in (None, tryout_id):
|
||||||
flash(_('Player and content are required.'), 'danger')
|
flash(_('Invalid tryout context.'), 'danger')
|
||||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
if not coach_can_access_player(current_user, player_id):
|
||||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
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))
|
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(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
@@ -334,7 +384,10 @@ def add_note_from_match(match_id):
|
|||||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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
|
# Get participants as players for the select list
|
||||||
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
|
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)
|
preselected_player_id = request.args.get('player_id', type=int)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
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:
|
if data['match_id'] not in (None, match_id):
|
||||||
flash(_('Player and content are required.'), 'danger')
|
flash(_('Invalid match context.'), 'danger')
|
||||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
if not coach_can_access_player(current_user, player_id):
|
||||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
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))
|
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(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""One-on-one sessions between a player and their coach."""
|
"""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 import flash, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
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.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||||
from app.routes.users.blueprint import users_bp
|
from app.routes.users.blueprint import users_bp
|
||||||
from app.services.notifications import send_discord_notification
|
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'])
|
@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')
|
flash(_('Only coaches can accept One on One requests.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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:
|
if request_obj.coach_id != current_user.id:
|
||||||
flash(_('This request is not for you.'), 'danger')
|
flash(_('This request is not for you.'), 'danger')
|
||||||
@@ -178,7 +177,7 @@ def accept_one_on_one(request_id):
|
|||||||
|
|
||||||
player = request_obj.player
|
player = request_obj.player
|
||||||
request_obj.status = 'approved'
|
request_obj.status = 'approved'
|
||||||
request_obj.responded_at = datetime.utcnow()
|
request_obj.responded_at = utc_now_naive()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
# 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')
|
flash(_('Only coaches can reject One on One requests.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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:
|
if request_obj.coach_id != current_user.id:
|
||||||
flash(_('This request is not for you.'), 'danger')
|
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')
|
flash(_('This request has already been processed.'), 'info')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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
|
player = request_obj.player
|
||||||
|
|
||||||
request_obj.status = 'rejected'
|
request_obj.status = 'rejected'
|
||||||
request_obj.responded_at = datetime.utcnow()
|
request_obj.responded_at = utc_now_naive()
|
||||||
if rejection_reason:
|
if rejection_reason:
|
||||||
request_obj.coach_rejection_message = rejection_reason
|
request_obj.coach_rejection_message = rejection_reason
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ def edit_profile():
|
|||||||
phone = validated.get('phone')
|
phone = validated.get('phone')
|
||||||
selected_games = validated.get('games', [])
|
selected_games = validated.get('games', [])
|
||||||
discord_username = validated.get('discord_username')
|
discord_username = validated.get('discord_username')
|
||||||
discord_user_id = validated.get('discord_user_id')
|
|
||||||
league_os_profile = validated.get('league_os_profile')
|
league_os_profile = validated.get('league_os_profile')
|
||||||
|
|
||||||
if username != current_user.username and User.query.filter_by(username=username).first():
|
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(),
|
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.username = username
|
||||||
current_user.full_name = full_name
|
current_user.full_name = full_name
|
||||||
current_user.email = email
|
current_user.email = email
|
||||||
current_user.phone = phone
|
current_user.phone = phone
|
||||||
current_user.games = ','.join(selected_games) if selected_games else None
|
current_user.games = ','.join(selected_games) if selected_games else None
|
||||||
current_user.discord_username = discord_username or 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
|
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
|
# Blank means "keep the current password"; anything else has already
|
||||||
# been checked against the policy by the schema.
|
# been checked against the policy by the schema.
|
||||||
password = validated.get('password')
|
password = validated.get('password')
|
||||||
|
|||||||
@@ -1256,6 +1256,64 @@ a:hover { color: var(--primary-dark); }
|
|||||||
color: white !important;
|
color: white !important;
|
||||||
border-color: #4f46e5 !important;
|
border-color: #4f46e5 !important;
|
||||||
}
|
}
|
||||||
|
/* Read-only weekly availability grid (public profile) */
|
||||||
|
.readonly-availability-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ra-day-column {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ra-day-header {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ra-slot {
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ra-slot.selected {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ra-slot.ra-empty {
|
||||||
|
color: var(--gray-400);
|
||||||
|
border: 1px dashed var(--gray-300);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.readonly-availability-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.readonly-availability-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.merged-disponibility-time-block.high-availability {
|
.merged-disponibility-time-block.high-availability {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
@@ -1730,6 +1788,296 @@ a:hover { color: var(--primary-dark); }
|
|||||||
box-shadow: var(--shadow-md);
|
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 {
|
.card-header .drag-handle {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
@@ -2037,3 +2385,30 @@ a:hover { color: var(--primary-dark); }
|
|||||||
.honeypot {
|
.honeypot {
|
||||||
display: none;
|
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
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
|
|||||||
* Confirmation before a destructive submit.
|
* Confirmation before a destructive submit.
|
||||||
*
|
*
|
||||||
* <form data-confirm="Delete this match?">
|
* <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
|
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
||||||
* markup where it can be translated.
|
* markup where it can be translated.
|
||||||
*/
|
*/
|
||||||
document.addEventListener('submit', function (event) {
|
document.addEventListener('submit', function (event) {
|
||||||
const form = event.target.closest('[data-confirm]');
|
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();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+82
-7
@@ -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).
|
migration and can ship before Alembic does (DB-002).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
import os
|
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
|
#: Environment override for the document root. What a release-directory
|
||||||
#: deployment sets, to a path outside the releases — alongside them, not
|
#: deployment sets, to a path outside the releases — alongside them, not
|
||||||
#: inside whichever one is current.
|
#: inside whichever one is current.
|
||||||
@@ -45,6 +60,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
|
|||||||
#: Sub-directory holding uploaded contracts, under the document root.
|
#: Sub-directory holding uploaded contracts, under the document root.
|
||||||
CONTRACTS_DIR = 'contrats signés'
|
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():
|
def project_root():
|
||||||
"""Absolute path of the project, derived from this file's location.
|
"""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)
|
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):
|
def discard_documents(stored_paths):
|
||||||
"""Remove these documents from disk. Returns how many went (DATA-012).
|
"""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
|
A path that cannot be removed is logged and skipped. Nothing here should
|
||||||
be able to abort the deletion of an account.
|
be able to abort the deletion of an account.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
removed = 0
|
removed = 0
|
||||||
for stored_path in stored_paths:
|
for stored_path in stored_paths:
|
||||||
if not stored_path:
|
if not stored_path:
|
||||||
continue
|
continue
|
||||||
target = document_path(stored_path)
|
|
||||||
try:
|
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
|
removed += 1
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Already gone. Two contracts sharing a stem, or a previous
|
# Already gone. Two contracts sharing a stem, or a previous
|
||||||
# attempt: not a problem, and not worth an error line.
|
# attempt: not a problem, and not worth an error line.
|
||||||
logger.info('Document already absent: %s', target)
|
logger.info('Document already absent: %s', stored_path)
|
||||||
except OSError as exc:
|
except (GoogleDriveStorageError, OSError) as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
'Could not remove %s (%s). It is now an orphan: no database row '
|
'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 '
|
'refers to it, so nothing in the application will ever offer to '
|
||||||
'delete it again.',
|
'delete it again.',
|
||||||
target,
|
stored_path,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return removed
|
return removed
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ from urllib.parse import unquote, urlparse
|
|||||||
# to be — see DOCUMENTS_DIR below.
|
# to be — see DOCUMENTS_DIR below.
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
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
|
# Configuration
|
||||||
BACKUP_DIR = backups_root()
|
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
|
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
||||||
# keep uploads outside the releases, and docs/deployment.md now tells the
|
# 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
|
# 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
|
# application had never written to. A missing or unreadable document store
|
||||||
# either; it prints "No documents directory found", skips, and exits 0.
|
# is now a failed full-backup run rather than a database-only green result.
|
||||||
#
|
#
|
||||||
# So the more correctly an operator followed the deployment documentation,
|
# So the more correctly an operator followed the deployment documentation,
|
||||||
# the more certainly their contract backups were empty (OBS-006).
|
# the more certainly their contract backups were empty (OBS-006).
|
||||||
@@ -262,38 +266,105 @@ def backup_documents():
|
|||||||
module happened to be imported with.
|
module happened to be imported with.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Path to the created archive, or None if there is nothing to
|
str: Path to the created archive.
|
||||||
archive. Signed contracts live only on disk, so losing this
|
|
||||||
directory loses the documents themselves.
|
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()
|
documents_dir = documents_root()
|
||||||
|
|
||||||
if not os.path.exists(documents_dir):
|
if not os.path.exists(documents_dir):
|
||||||
# Says where it looked. The previous message named no path, so an
|
raise BackupError(f'Documents directory does not exist: {documents_dir}')
|
||||||
# operator who had moved the documents read it as "there are no
|
if not os.path.isdir(documents_dir):
|
||||||
# documents" rather than "I am looking in the wrong place".
|
raise BackupError(f'Documents path is not a directory: {documents_dir}')
|
||||||
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
|
|
||||||
return None
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
||||||
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
|
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
|
||||||
# This runs after the database dump has already succeeded. Letting
|
raise BackupError(f'Document backup failed: {exc}') from exc
|
||||||
# anything through here would abort the script with a traceback and
|
|
||||||
# take the one part that worked down with it. Reported to stdout, in
|
|
||||||
# the format the rest of this script uses; it has no logger.
|
|
||||||
print(f'[ERROR] Document backup failed: {exc}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
zip_path = f'{archive_basename}.zip'
|
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)
|
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||||
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||||
return zip_path
|
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():
|
def cleanup_old_backups():
|
||||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||||
if not os.path.exists(BACKUP_DIR):
|
if not os.path.exists(BACKUP_DIR):
|
||||||
@@ -361,15 +432,26 @@ def main(argv=None):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
verified = verify_backup(backup_path)
|
verified = verify_backup(backup_path)
|
||||||
|
documents_ok = True
|
||||||
|
try:
|
||||||
backup_documents()
|
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()
|
cleanup_old_backups()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if verified:
|
if verified and documents_ok and drive_ok:
|
||||||
print('=== Backup completed successfully ===')
|
print('=== Backup completed successfully ===')
|
||||||
return 0
|
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
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -29,10 +29,13 @@ Usage
|
|||||||
# Also look for the seeded admin/password account (SEC-003)
|
# Also look for the seeded admin/password account (SEC-003)
|
||||||
python app/supporting_scripts/schema_report.py --check-seed-accounts
|
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
|
Exit codes
|
||||||
----------
|
----------
|
||||||
0 the live schema matches the models
|
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
|
2 could not connect or read the catalogue
|
||||||
|
|
||||||
Reading the output
|
Reading the output
|
||||||
@@ -315,6 +318,42 @@ def find_seed_accounts(engine):
|
|||||||
return results
|
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):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -327,6 +366,11 @@ def main(argv=None):
|
|||||||
action='store_true',
|
action='store_true',
|
||||||
help='Also look for the admin/password account seeded by clear_db.py (SEC-003).',
|
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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if not args.url:
|
if not args.url:
|
||||||
@@ -375,9 +419,24 @@ def main(argv=None):
|
|||||||
)
|
)
|
||||||
print(f' {username} ({role}): {verdict}')
|
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)
|
blocking = sum(1 for f in findings if f.severity == BLOCKING)
|
||||||
print(f'\n{len(findings)} finding(s), {blocking} 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
|
if __name__ == '__main__': # pragma: no cover
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
|
|||||||
- Debug mode status
|
- Debug mode status
|
||||||
- HTTPS configuration
|
- HTTPS configuration
|
||||||
- Dependency vulnerabilities
|
- Dependency vulnerabilities
|
||||||
- Database connectivity
|
- Required database configuration
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python security_scan.py [--url http://localhost:5000]
|
python security_scan.py [--url http://localhost:5000]
|
||||||
@@ -19,6 +19,10 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
||||||
|
|
||||||
|
|
||||||
def check_environment():
|
def check_environment():
|
||||||
@@ -31,8 +35,8 @@ def check_environment():
|
|||||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||||
print('=' * 60)
|
print('=' * 60)
|
||||||
|
|
||||||
critical_vars = ['SECRET_KEY']
|
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
|
||||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
recommended_vars = ['CORS_ALLOWED_ORIGINS']
|
||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
for var in critical_vars:
|
for var in critical_vars:
|
||||||
@@ -58,7 +62,8 @@ def check_environment():
|
|||||||
# Check FLASK_DEBUG
|
# Check FLASK_DEBUG
|
||||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||||
if debug == 'true':
|
if debug == 'true':
|
||||||
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
|
print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
|
||||||
|
all_ok = False
|
||||||
else:
|
else:
|
||||||
print('[OK] FLASK_DEBUG is disabled')
|
print('[OK] FLASK_DEBUG is disabled')
|
||||||
|
|
||||||
@@ -91,10 +96,11 @@ def check_https_headers(url):
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a context that doesn't verify SSL (for local testing)
|
# Keep the default certificate and hostname verification. A scanner
|
||||||
|
# that accepts an invalid certificate can validate headers while the
|
||||||
|
# transport itself is impersonated. Local runs without TLS should use
|
||||||
|
# http:// explicitly or opt out with --skip-http.
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, method='HEAD')
|
req = urllib.request.Request(url, method='HEAD')
|
||||||
|
|
||||||
@@ -148,9 +154,9 @@ def check_https_headers(url):
|
|||||||
all_ok = False
|
all_ok = False
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
|
||||||
print('[SKIP] Run with --url <application_url> to check headers')
|
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
||||||
return True # Not a failure, just can't check
|
return False
|
||||||
|
|
||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
@@ -167,7 +173,15 @@ def check_dependencies():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
[
|
||||||
|
sys.executable,
|
||||||
|
'-m',
|
||||||
|
'pip_audit',
|
||||||
|
'--requirement',
|
||||||
|
str(REQUIREMENTS_FILE),
|
||||||
|
'--format',
|
||||||
|
'json',
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
@@ -195,14 +209,16 @@ def check_dependencies():
|
|||||||
if result.stdout:
|
if result.stdout:
|
||||||
print(f'[INFO] {result.stdout.strip()}')
|
print(f'[INFO] {result.stdout.strip()}')
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
print(f'[WARN] {result.stderr.strip()}')
|
print(f'[FAIL] {result.stderr.strip()}')
|
||||||
return True
|
else:
|
||||||
|
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
|
||||||
|
return False
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
||||||
return True
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('[WARN] pip-audit timed out')
|
print('[FAIL] pip-audit timed out')
|
||||||
return True
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_file_permissions():
|
def check_file_permissions():
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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' %}
|
{% if current_user.role == 'player' %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
||||||
@@ -90,6 +96,12 @@
|
|||||||
<span>{{ _('Manage Users') }}</span>
|
<span>{{ _('Manage Users') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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 %}
|
{% endif %}
|
||||||
{% if current_user.role == 'player' %}
|
{% if current_user.role == 'player' %}
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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">×</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 %}
|
|
||||||
@@ -94,8 +94,8 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="signed_file">{{ _('Select Signed Contract File') }}</label>
|
<label for="signed_file">{{ _('Select Signed Contract File') }}</label>
|
||||||
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
<input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||||
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
|
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
|
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
|
||||||
|
|||||||
@@ -79,14 +79,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<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>
|
<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') }}">
|
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="{{ _('e.g. Name#1234') }}">
|
||||||
</div>
|
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-secondary mt-2">
|
||||||
<div class="form-group col-6">
|
<i class="fab fa-discord"></i>
|
||||||
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>{{ _('(for DMs)') }}</small></label>
|
{% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %}
|
||||||
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="{{ _('Numeric ID (e.g. 123456789012345678)') }}">
|
</a>
|
||||||
<small class="text-muted">{{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -450,11 +450,18 @@
|
|||||||
var playerDataById = {
|
var playerDataById = {
|
||||||
player_data: {
|
player_data: {
|
||||||
{%- for p in all_players %}
|
{%- for p in all_players %}
|
||||||
{{ p.id }}: "{{ p.username | escape }}",
|
{{ p.id }}: {{ p.username | tojson }},
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var HTML_ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, function (character) {
|
||||||
|
return HTML_ESCAPES[character];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// All registered player IDs
|
// All registered player IDs
|
||||||
var allRegisteredPlayers = [
|
var allRegisteredPlayers = [
|
||||||
{%- for p in all_players %}
|
{%- for p in all_players %}
|
||||||
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
|
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 +
|
slotData.display +
|
||||||
'<span class="merged-disponibility-count">' + count + '</span>' +
|
'<span class="merged-disponibility-count">' + count + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -920,7 +927,7 @@ function updatePlayerPool() {
|
|||||||
var availabilityClass = isAvailable ? 'available' : 'unavailable';
|
var availabilityClass = isAvailable ? 'available' : 'unavailable';
|
||||||
|
|
||||||
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
|
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 += '<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-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>';
|
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;
|
if (!playerName) return;
|
||||||
|
|
||||||
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) {
|
|||||||
registerActions({
|
registerActions({
|
||||||
'toggle-match-type': toggleMatchType,
|
'toggle-match-type': toggleMatchType,
|
||||||
'clear-time-selection': clearTimeSelection,
|
'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,
|
'update-randomize-preview': updateRandomizePreview,
|
||||||
'randomize-teams': randomizeTeams,
|
'randomize-teams': randomizeTeams,
|
||||||
'return-to-pool': returnToPool,
|
'return-to-pool': returnToPool,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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();">×</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 %}
|
||||||
@@ -6,23 +6,29 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<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">
|
<div class="table-container">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ _('Player') }}</th>
|
<th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
|
||||||
<th>{{ _('Contact') }}</th>
|
<th>Player</th>
|
||||||
<th>{{ _('Attendance') }}</th>
|
<th>Contact</th>
|
||||||
<th>{{ _('Status') }}</th>
|
<th>Attendance</th>
|
||||||
<th>{{ _('Actions') }}</th>
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in players %}
|
{% for entry in players %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="user-mini">
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||||
@@ -41,19 +47,43 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
|
<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 %}Evaluate{% endif %}
|
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
|
<td colspan="6" class="text-center">No players registered for this tryout.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -193,9 +193,6 @@
|
|||||||
<p class="text-muted">{{ _('Loading...') }}</p>
|
<p class="text-muted">{{ _('Loading...') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<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">
|
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
|
||||||
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -369,7 +366,7 @@ function saveCoachAvailability() {
|
|||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.className = 'alert alert-success';
|
msg.className = 'alert alert-success';
|
||||||
msg.style.marginTop = '10px';
|
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);
|
document.getElementById('availability-grid').appendChild(msg);
|
||||||
setTimeout(() => msg.remove(), 3000);
|
setTimeout(() => msg.remove(), 3000);
|
||||||
}
|
}
|
||||||
@@ -396,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
loadCoachAvailability();
|
loadCoachAvailability();
|
||||||
renderCoachGrid();
|
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>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -571,7 +575,7 @@ function saveDisponibilities() {
|
|||||||
var msg = document.createElement('div');
|
var msg = document.createElement('div');
|
||||||
msg.className = 'alert alert-success';
|
msg.className = 'alert alert-success';
|
||||||
msg.style.marginTop = '10px';
|
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);
|
document.getElementById('disponibilities-grid').appendChild(msg);
|
||||||
setTimeout(function() { msg.remove(); }, 3000);
|
setTimeout(function() { msg.remove(); }, 3000);
|
||||||
}
|
}
|
||||||
@@ -610,9 +614,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// dispatched by the delegated listener in main.js. This replaces inline
|
// dispatched by the delegated listener in main.js. This replaces inline
|
||||||
// onclick attributes, which no CSP nonce is able to authorise.
|
// onclick attributes, which no CSP nonce is able to authorise.
|
||||||
registerActions({
|
registerActions({
|
||||||
'save-disponibilities': saveDisponibilities,
|
|
||||||
'clear-disponibilities': clearDisponibilities,
|
'clear-disponibilities': clearDisponibilities,
|
||||||
'clear-availability': clearAllAvailability,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -57,8 +57,6 @@
|
|||||||
<i class="fas fa-sync-alt"></i> {{ _('Reconnect') }}
|
<i class="fas fa-sync-alt"></i> {{ _('Reconnect') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<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.') }}
|
<i class="fas fa-check-circle"></i> {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }}
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -300,6 +300,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<script nonce="{{ csp_nonce }}">
|
<script nonce="{{ csp_nonce }}">
|
||||||
function showCreateForm() {
|
function showCreateForm() {
|
||||||
document.getElementById('createTeamForm').classList.remove('hidden');
|
document.getElementById('createTeamForm').classList.remove('hidden');
|
||||||
|
|||||||
@@ -67,15 +67,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-6">
|
<div class="form-group col-6">
|
||||||
<label for="manager_id">{{ _('Assigned Manager') }}</label>
|
<label>{{ _('Assigned Managers') }}</label>
|
||||||
<select id="manager_id" name="manager_id" class="form-select">
|
<div class="checkbox-grid">
|
||||||
<option value="">{{ _('-- No manager assigned --') }}</option>
|
|
||||||
{% for manager in managers %}
|
{% for manager in managers %}
|
||||||
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
|
{% set is_checked = false %}
|
||||||
{{ manager.username }}
|
{% if tryout %}
|
||||||
</option>
|
{% for m in tryout.get_managers() %}
|
||||||
|
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="contract_file">{{ _('Contract File') }}</label>
|
<label for="contract_file">{{ _('Contract File') }}</label>
|
||||||
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
<input type="file" id="contract_file" name="contract_file" accept=".pdf" required>
|
||||||
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
|
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<th>{{ _('Username') }}</th>
|
<th>{{ _('Username') }}</th>
|
||||||
<th>{{ _('Email') }}</th>
|
<th>{{ _('Email') }}</th>
|
||||||
<th>{{ _('Role') }}</th>
|
<th>{{ _('Role') }}</th>
|
||||||
|
<th>{{ _('Hours') }}</th>
|
||||||
<th>{{ _('Status') }}</th>
|
<th>{{ _('Status') }}</th>
|
||||||
<th>{{ _('Joined') }}</th>
|
<th>{{ _('Joined') }}</th>
|
||||||
<th>{{ _('Actions') }}</th>
|
<th>{{ _('Actions') }}</th>
|
||||||
@@ -38,6 +39,13 @@
|
|||||||
<td>{{ u.username }}</td>
|
<td>{{ u.username }}</td>
|
||||||
<td>{{ u.email }}</td>
|
<td>{{ u.email }}</td>
|
||||||
<td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></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>
|
<td>
|
||||||
{% if u.is_active_account %}
|
{% if u.is_active_account %}
|
||||||
<span class="badge badge-success">Active</span>
|
<span class="badge badge-success">Active</span>
|
||||||
|
|||||||
@@ -70,16 +70,22 @@
|
|||||||
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Manager</span>
|
<span class="detail-label">Manager(s)</span>
|
||||||
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
|
<span class="detail-value">
|
||||||
|
{% set mgrs = tryout.get_managers() %}
|
||||||
|
{% if mgrs %}
|
||||||
|
{{ mgrs | map(attribute='username') | join(', ') }}
|
||||||
|
{% else %}
|
||||||
|
Not assigned
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Coaches</span>
|
<span class="detail-label">Coaches</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
{% if tryout.coaches %}
|
{% set cos = tryout.get_coaches() %}
|
||||||
{{ tryout.coaches | map(attribute='username') | join(', ') }}
|
{% if cos %}
|
||||||
{% elif tryout.coach %}
|
{{ cos | map(attribute='username') | join(', ') }}
|
||||||
{{ tryout.coach.username }}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
Not assigned
|
Not assigned
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -534,6 +540,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<style>
|
<style>
|
||||||
.presence-toggle-btn {
|
.presence-toggle-btn {
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
|
|||||||
@@ -81,6 +81,31 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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)
|
||||||
@@ -58,3 +58,39 @@ def day_name(day_of_week):
|
|||||||
if 0 <= day_of_week < len(DAY_NAMES):
|
if 0 <= day_of_week < len(DAY_NAMES):
|
||||||
return DAY_NAMES[day_of_week]
|
return DAY_NAMES[day_of_week]
|
||||||
return f'Day {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
@@ -23,7 +23,15 @@ from marshmallow import (
|
|||||||
validates_schema,
|
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
|
# Custom Validators
|
||||||
@@ -256,11 +264,6 @@ class RegisterSchema(StripMixin):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
load_default=None,
|
load_default=None,
|
||||||
)
|
)
|
||||||
discord_user_id = fields.String(
|
|
||||||
validate=validate_discord_user_id,
|
|
||||||
allow_none=True,
|
|
||||||
load_default=None,
|
|
||||||
)
|
|
||||||
league_os_profile = fields.String(
|
league_os_profile = fields.String(
|
||||||
validate=validate.Length(max=256),
|
validate=validate.Length(max=256),
|
||||||
allow_none=True,
|
allow_none=True,
|
||||||
@@ -281,6 +284,36 @@ class RegisterSchema(StripMixin):
|
|||||||
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password')
|
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):
|
class CreateUserSchema(StripMixin):
|
||||||
"""Validate president-created user form input.
|
"""Validate president-created user form input.
|
||||||
|
|
||||||
@@ -392,7 +425,6 @@ class EditProfileSchema(StripMixin):
|
|||||||
phone: Optional.
|
phone: Optional.
|
||||||
password: Optional (only if changing).
|
password: Optional (only if changing).
|
||||||
discord_username: Optional.
|
discord_username: Optional.
|
||||||
discord_user_id: Optional.
|
|
||||||
league_os_profile: Optional.
|
league_os_profile: Optional.
|
||||||
games: Optional list.
|
games: Optional list.
|
||||||
"""
|
"""
|
||||||
@@ -428,11 +460,6 @@ class EditProfileSchema(StripMixin):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
load_default=None,
|
load_default=None,
|
||||||
)
|
)
|
||||||
discord_user_id = fields.String(
|
|
||||||
validate=validate_discord_user_id,
|
|
||||||
allow_none=True,
|
|
||||||
load_default=None,
|
|
||||||
)
|
|
||||||
league_os_profile = fields.String(
|
league_os_profile = fields.String(
|
||||||
validate=validate.Length(max=256),
|
validate=validate.Length(max=256),
|
||||||
allow_none=True,
|
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):
|
class OneOnOneRequestSchema(StripMixin):
|
||||||
"""A player asking their coach for a session (MNT-12).
|
"""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)
|
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
||||||
manager_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)
|
coach_ids = fields.List(fields.Integer(), load_default=list)
|
||||||
|
manager_ids = fields.List(fields.Integer(), load_default=list)
|
||||||
|
|
||||||
@validates_schema
|
@validates_schema
|
||||||
def validate_span(self, data, **kwargs):
|
def validate_span(self, data, **kwargs):
|
||||||
@@ -920,3 +1057,137 @@ class EvaluationSchema(StripMixin):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
load_default=None,
|
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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ incident.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record |
|
| `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_<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
|
Losing either one alone loses data. A database restore without the document
|
||||||
archive leaves contract rows pointing at files that no longer exist.
|
archive leaves contract rows pointing at files that no longer exist.
|
||||||
|
|||||||
+14
-5
@@ -1,8 +1,9 @@
|
|||||||
# Le schéma réel, et comment sortir de `create_all()`
|
# 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é
|
> **État au 2026-08-16** : l'outil de relevé existe et est testé, y compris
|
||||||
> lui-même n'a pas été exécuté — il demande un accès à la base de production,
|
> pour les collisions d'identité Discord. Le relevé lui-même n'a pas été
|
||||||
> qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution.
|
> 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
|
## 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
|
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`)
|
## É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
|
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-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-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-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-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 |
|
| `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 |
|
| `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
|
## Ce qu'on ne fait pas
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -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
|
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
|
||||||
than one that starts and is wide open (OPS-003).
|
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)
|
### Binding and proxy trust — read this before going live (OPS-002)
|
||||||
|
|
||||||
Two variables decide whether the rate limiter, the account lockout and the
|
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
|
`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
|
backup script kept its own copy of the document path — so it archived
|
||||||
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
|
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
|
||||||
was therefore enough, on its own, to make every contract backup empty; the
|
was therefore enough, on its own, to make every contract backup empty. All
|
||||||
script prints `No documents directory…` and still exits 0, so a scheduled
|
three roots now come from `app/storage.py`, and the backup run prints the
|
||||||
task watching the exit code would have seen green indefinitely. All three
|
document source it used. A missing or unarchivable document store makes the
|
||||||
roots now come from `app/storage.py`, and the backup run prints the document
|
run exit non-zero even when the database dump itself is valid, so a scheduler
|
||||||
source it used.
|
cannot report a database-only recovery point as a complete backup.
|
||||||
|
|
||||||
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
|
**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
|
and check the `Document source:` line and the size of the resulting
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ Run through this checklist before deploying to production.
|
|||||||
|
|
||||||
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
|
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
|
||||||
- [ ] File extensions restricted to PDF only
|
- [ ] 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)
|
- [ ] 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
|
## Phase 13 — Logging & Monitoring
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ was marked by the automated pass. Jinja escaped it to `&times;`, so
|
|||||||
the button would have shown the literal text `×` instead of ×.
|
the button would have shown the literal text `×` instead of ×.
|
||||||
Anything that is markup rather than prose must stay out of `_()`.
|
Anything that is markup rather than prose must stay out of `_()`.
|
||||||
|
|
||||||
**Strings inside `<script>` blocks.** Eight remain, in `match_form.html`,
|
**Strings inside `<script>` blocks.** Seven remain, in `match_form.html` and
|
||||||
`one_on_one.html` and `coach_availability.html`. They work, but they are
|
`one_on_one.html`. They work, but they are
|
||||||
fragile: Jinja escapes `& < > " '` inside a script block, and those
|
fragile: Jinja escapes `& < > " '` inside a script block, and those
|
||||||
entities are *not* decoded there — a translation containing an apostrophe
|
entities are *not* decoded there — a translation containing an apostrophe
|
||||||
would land in the JavaScript string as `'`. The French wording uses
|
would land in the JavaScript string as `'`. 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
@@ -21,14 +21,17 @@ filterwarnings = [
|
|||||||
"default",
|
"default",
|
||||||
# discord.py imports audioop, removed from the stdlib in 3.13.
|
# discord.py imports audioop, removed from the stdlib in 3.13.
|
||||||
"ignore:'audioop' is deprecated:DeprecationWarning",
|
"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]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py312"
|
target-version = "py313"
|
||||||
exclude = [".venv", "venv", "migrations", "docs"]
|
exclude = [".venv", "venv", "migrations", "docs"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ Flask-SQLAlchemy==3.1.1
|
|||||||
Flask-WTF==1.3.0
|
Flask-WTF==1.3.0
|
||||||
frozenlist==1.8.0
|
frozenlist==1.8.0
|
||||||
greenlet==3.5.4
|
greenlet==3.5.4
|
||||||
|
google-api-python-client==2.198.0
|
||||||
|
google-auth==2.56.3
|
||||||
|
pypdf==6.16.2
|
||||||
idna==3.18
|
idna==3.18
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user