This commit is contained in:
cedrick2711
2026-08-25 14:03:07 -04:00
191 changed files with 28790 additions and 5066 deletions
+67 -45
View File
@@ -19,17 +19,16 @@ from app.models import (
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
)
from app.supporting_scripts import backup as backup_helper
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.
This returns False for non-admins so the caller can stop processing,
but the redirect/abort should be handled by the caller.
"""
"""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')
@@ -96,6 +95,27 @@ def dashboard():
# 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():
@@ -105,25 +125,14 @@ def create_backup():
notes = request.form.get('notes', '').strip() or None
try:
result = backup_helper.create_backup(backup_type='manual', notes=notes)
except RuntimeError as e:
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'))
record = BackupRecord(
filename=result['filename'],
file_path=result['file_path'],
size_bytes=result['size_bytes'],
backup_type='manual',
notes=notes,
created_by_id=current_user.id,
)
db.session.add(record)
db.session.commit()
_log('backup_created', f'File: {result["filename"]} ({result["size_bytes"]} bytes)')
flash(f'Backup created successfully: {result["filename"]}', 'success')
_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'))
@@ -171,7 +180,7 @@ 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 psql.
selected dump is restored via pg_restore.
"""
if not require_admin():
return redirect(url_for('main.dashboard'))
@@ -183,30 +192,50 @@ def restore_backup(backup_id):
# Create a safety backup of the current state before restoring.
try:
safety = backup_helper.create_backup(backup_type='pre_restore', notes='Pre-restore safety backup')
safety_record = BackupRecord(
filename=safety['filename'],
file_path=safety['file_path'],
size_bytes=safety['size_bytes'],
backup_type='auto',
safety = _create_backup_record(
backup_type='pre_restore',
notes='Automatic safety backup before restoring ' + record.filename,
created_by_id=current_user.id,
)
db.session.add(safety_record)
db.session.commit()
except RuntimeError as e:
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:
backup_helper.restore_backup(record.file_path)
except RuntimeError as e:
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')
flash(
f'Backup {record.filename} restored successfully. The database has been rolled back.',
'success',
)
return redirect(url_for('admin.dashboard'))
@@ -322,18 +351,11 @@ def wipe_teams():
# Safety backup before destructive operation
try:
safety = backup_helper.create_backup(backup_type='pre_wipe', notes='Pre-wipe safety backup')
safety_record = BackupRecord(
filename=safety['filename'],
file_path=safety['file_path'],
size_bytes=safety['size_bytes'],
backup_type='auto',
_create_backup_record(
backup_type='pre_wipe',
notes='Automatic safety backup before wiping teams',
created_by_id=current_user.id,
)
db.session.add(safety_record)
db.session.commit()
except RuntimeError as e:
except BackupError as e:
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
+416 -178
View File
@@ -2,24 +2,59 @@
This module handles user authentication including login with account lockout
protection, logout with session clearing, and new user registration with
password policy enforcement and CAPTCHA verification.
password policy enforcement and sign-up screening.
"""
import uuid
import os
from datetime import datetime, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
from flask_login import login_user, logout_user, login_required, current_user
from app.extensions import db, hash_password, check_password, limiter
from app.models import User, Player, ESPORT_GAMES
from app.validators import RegisterSchema, LoginSchema
from marshmallow import ValidationError
from urllib.parse import urlparse
import requests
import secrets
import time
from datetime import timedelta
from urllib.parse import urlencode, urlparse
# Account lockout settings
import requests
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required, login_user, logout_user
from marshmallow import ValidationError
from app.extensions import check_password, db, hash_password, limiter
from app.forms import form_gamertags
from app.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User
from app.time_utils import utc_now_naive
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state'
#: Whether the OAuth result should create a registration draft or relink the
#: signed-in account. Kept server-side and covered by the same signed session
#: as the anti-forgery state.
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
# Failed-attempt tracking. The tally is kept for the audit trail and for the
# cool-off marker below; it no longer refuses a correct password (SEC-018).
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
#: Ceiling on the doubling cool-off window.
MAX_LOCKOUT_MINUTES = 240
#: Hash of a value nobody can submit. Verifying against it when the username
#: is unknown makes that path cost the same scrypt work as a real one, so the
#: response time stops telling a caller which usernames exist (SEC-017).
_ABSENT_USER_HASH = None
#: Session key recording when the registration form was handed out.
REGISTRATION_ISSUED_KEY = 'registration_form_issued_at'
#: Name of the honeypot input. Plausible enough that a form-filler wants it,
#: absent from the visible form. Hidden by .honeypot in style.css — not by an
#: inline style, so that the rule survives a tightening of style-src.
REGISTRATION_HONEYPOT_FIELD = 'website'
#: Floor on how long a genuine registration takes. Eleven fields and a
#: password typed twice; three seconds is generous.
MIN_REGISTRATION_SECONDS = 3
# Discord OAuth2 configuration
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
@@ -40,53 +75,124 @@ DISCORD_PLATFORM_TO_GAMES = {
def is_safe_url(url):
"""Validate that a URL is safe for redirection (same origin).
Accepts an absolute URL on this host, or a path beginning with exactly
one slash. Everything else is refused, including the two forms that
read differently to urlparse and to a browser:
/\\evil.com several browsers normalise the backslash to a slash,
turning this into the protocol-relative //evil.com.
urlparse reports no netloc at all, so the old check
let it through and the redirect left the site.
/\\n//evil.com control characters are stripped before parsing.
Args:
url: The URL to validate.
Returns:
bool: True if the URL is safe (relative or same origin).
bool: True if the URL is safe.
"""
if not url:
return False
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
return False
parsed = urlparse(url)
# Allow relative URLs (no netloc) or same-origin URLs
return not parsed.netloc or parsed.netloc == request.host
if parsed.netloc:
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
# not paths on this site.
return url.startswith('/')
def generate_captcha():
"""Generate a simple math CAPTCHA challenge.
def _absent_user_hash():
"""A hash to verify against when the submitted username does not exist.
Creates a random addition problem and stores the answer in the session.
Returns:
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
check_password() used to be reached only when a user row was found, so
an unknown username answered as fast as the database lookup, and a known
one as slowly as scrypt. The gap is measurable and enumerates accounts.
Computed once per process, from a random secret, so no submitted password
can ever match it.
"""
import random
a = random.randint(1, 10)
b = random.randint(1, 10)
captcha_id = str(uuid.uuid4())
session['captcha_id'] = captcha_id
session['captcha_answer'] = a + b
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
global _ABSENT_USER_HASH
if _ABSENT_USER_HASH is None:
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
return _ABSENT_USER_HASH
def verify_captcha(user_answer):
"""Verify the CAPTCHA answer from the session.
def cooloff_minutes(failed_attempts):
"""Length of the cool-off window earned by this many failed attempts.
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
Args:
user_answer: The user's submitted answer (string or int).
failed_attempts: Consecutive failures recorded on the account.
Returns:
bool: True if the answer matches the stored CAPTCHA, False otherwise.
int: Minutes.
"""
try:
expected = session.pop('captcha_answer', None)
session.pop('captcha_id', None)
if expected is None:
return False
return int(user_answer) == expected
except (ValueError, TypeError):
return False
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
def issue_registration_challenge():
"""Mark that the registration form has been handed out, and when.
Kept in the signed session rather than in a form field, so that the
timestamp is not something the submitter can choose. Left in place across
a failed submission: someone correcting a typo should not be told to slow
down, and a robot has already paid for the round trip by then.
"""
session.setdefault(REGISTRATION_ISSUED_KEY, time.time())
def check_registration_challenge(form):
"""Say why this registration should be refused, or None to accept.
What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008).
`a + b = ?` with both operands between 1 and 10 has nineteen possible
answers and is solvable by reading the string. It stopped no automated
registration whatsoever. What it did do was add a step for every human,
including anyone using a screen reader, in exchange for an appearance of
protection — which is worse than no protection, because it gets counted
as one.
The audit's alternative was a real CAPTCHA service. That means a third
party, an API key, a request on every page load, and putting a foreign
script back into script-src — undoing the CSP work that closed
SEC-WEB-001. Disproportionate for a club site.
So: two checks that cost the visitor nothing.
- a honeypot field, hidden in the stylesheet, that a form-filling
robot completes and a person never sees;
- a minimum dwell time between being handed the form and sending it
back. Eleven fields and a password typed twice do not get filled in
under three seconds, and a POST with no issued form at all never
fetched the page.
Be clear about the ceiling: this stops commodity spam, not somebody who
looks at the form for five minutes. The thing that would actually gate
registration is staff activation of new accounts, which does not exist —
`is_active_account` defaults to True. That is a product decision, not one
to slip in here.
The session-forgery angle in the constat is moot: with SECRET_KEY
compromised (SEC-001) an attacker forges a logged-in session for any
account and has no reason to register at all.
Returns:
str | None: a short reason for the log, or None to let it through.
"""
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
return 'honeypot'
issued_at = session.get(REGISTRATION_ISSUED_KEY)
if issued_at is None:
return 'no-form-issued'
if time.time() - issued_at < MIN_REGISTRATION_SECONDS:
return 'too-fast'
return None
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@@ -95,16 +201,17 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit("10 per minute")
def login():
"""Handle user login authentication with account lockout protection.
"""Handle user login authentication.
GET: Render the login form.
POST: Authenticate user credentials with lockout check and audit logging.
POST: Authenticate user credentials, with audit logging.
Account lockout: After 5 consecutive failed attempts, the account is
locked for 15 minutes. Successful login resets the counter.
Redirects authenticated users to dashboard. Validates credentials and checks
account status before login.
Failed attempts are counted and open a cool-off window, recorded in
``locked_until`` and in the authentication log. The window does not
refuse correct credentials: when it did, five wrong guesses against a
known username took that account out of service for fifteen minutes,
repeatably, and on a president's account that meant no administration
at all. Guess rate is bounded by the rate limit on this view.
Returns:
Response: Login form or redirect to dashboard/next page.
@@ -120,80 +227,124 @@ def login():
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(f'{field}: {msg}', 'danger')
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return render_template('pages/login.html')
username = validated['username']
password = validated['password']
user = User.query.filter_by(username=username).first()
# Check if account is locked
if user and user.locked_until and user.locked_until > datetime.utcnow():
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
flash(
f'Account is locked due to too many failed attempts. '
f'Please try again in {remaining} minute(s).',
'danger'
)
return render_template('pages/login.html')
# Verified before anything else is decided, and on both branches.
# Reaching this only when a row exists made the response time a
# reliable oracle for which usernames are registered (SEC-017).
credentials_ok = check_password(
user.password_hash if user else _absent_user_hash(), password
)
if user and check_password(user.password_hash, password):
if user and credentials_ok:
if not user.is_active_account:
flash('This account has been deactivated.', 'danger')
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
flash(_('This account has been deactivated.'), 'danger')
return render_template('pages/login.html')
# Reset failed login attempts on successful login
# Correct credentials clear the tally, cool-off window included.
# The window used to refuse them too, which is what turned it
# into a way to lock a known account out at will (SEC-018).
user.failed_login_attempts = 0
user.locked_until = None
db.session.commit()
# Clear old session data and preserve CSRF token to prevent
# session fixation attacks (Flask-Login rotates the session ID)
_csrf_token = session.get('csrf_token')
# session fixation attacks (Flask-Login rotates the session ID).
#
# The language choice is carried across too. Someone who reads the
# login page in English and signs in would otherwise be dropped
# back into French — the preference lives in the session, and
# clearing it discards a decision the user just made.
_preserved = {
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
}
session.clear()
if _csrf_token:
session['csrf_token'] = _csrf_token
session.update(_preserved)
# Mark the session permanent so PERMANENT_SESSION_LIFETIME applies.
# Without this, Flask emits a browser-session cookie with no expiry
# and the configured lifetime is silently ignored.
session.permanent = True
login_user(user)
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
# Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next')
if next_page and not is_safe_url(next_page):
next_page = None
flash(f'Welcome back, {user.username}!', 'success')
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
# One message for every failure. The old code said "N attempts
# remaining" to a real account and "check username and password"
# to an unknown one, which listed the club's accounts to anyone
# who asked (SEC-017).
if user:
user.failed_login_attempts += 1
log_auth_event(
'login.failure',
username=username,
user_id=user.id,
attempts=user.failed_login_attempts,
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
log_auth_event(
'account.throttled',
username=username,
user_id=user.id,
minutes=minutes,
attempts=user.failed_login_attempts,
)
db.session.commit()
else:
# Track failed login attempt
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
flash(
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
'danger'
)
else:
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
flash(
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
'danger'
)
db.session.commit()
else:
flash('Login unsuccessful. Please check username and password.', 'danger')
log_auth_event('login.failure.unknown_user', username=username)
flash(
_(
'Login unsuccessful. Please check your username and '
'password, or ask a president for help.'
),
'danger',
)
return render_template('pages/login.html')
def _rerender_registration(form_data):
"""Re-render the registration form after a refusal.
Was copied out four times, near-identically (ARCH-005). Dropping the two
password fields is the part that must not be forgotten in the fifth copy:
echoing a password back into the HTML puts it in the browser's cache and
in any proxy log along the way.
"""
form_data = dict(form_data)
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
honeypot_field=REGISTRATION_HONEYPOT_FIELD,
form_data=form_data,
)
@auth_bp.route('/register', methods=['GET', 'POST'])
@limiter.limit("20 per hour")
def register():
"""Handle new player registration with CAPTCHA and password policy.
"""Handle new player registration.
GET: Render the registration form with E-Sports games list and CAPTCHA.
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
and create a new player account.
GET: Render the registration form with the E-Sports games list.
POST: Screen the submission (see check_registration_challenge), validate
every input against RegisterSchema, and create a new player account.
Only players can register through this form. Validates username/email
uniqueness and password confirmation.
@@ -209,20 +360,24 @@ def register():
form_data = dict(request.form)
form_data['games'] = request.form.getlist('games')
# Validate CAPTCHA first
captcha_answer = request.form.get('captcha_answer', '')
if not verify_captcha(captcha_answer):
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
captcha = generate_captcha()
# Clear password fields only on CAPTCHA failure
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data=form_data,
)
# Once Discord has authenticated the identity, neither its display
# name nor its snowflake is input data anymore. Remove any client
# copies before validation as well as before persistence: otherwise a
# forged, malformed hidden value can still make the verified flow fail.
discord_oauth = session.get('discord_oauth') or {}
if discord_oauth.get('id'):
form_data.pop('discord_username', None)
form_data.pop('discord_user_id', None)
refusal = check_registration_challenge(request.form)
if refusal is not None:
# Logged, because this is the only place abuse of the sign-up
# form becomes visible at all. Deliberately vague to the sender:
# naming the honeypot tells whoever tripped it how to avoid it.
log_auth_event('account.registration_refused', reason=refusal)
flash(_('Your registration could not be processed. Please try again.'), 'danger')
issue_registration_challenge()
return _rerender_registration(form_data)
# Validate input with marshmallow schema
register_schema = RegisterSchema()
@@ -231,17 +386,8 @@ def register():
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(f'{field}: {msg}', 'danger')
captcha = generate_captcha()
# Clear password fields on validation failure
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data=form_data,
)
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return _rerender_registration(form_data)
username = validated['username']
email = validated['email']
@@ -249,33 +395,41 @@ def register():
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
try:
submitted_gamertags = form_gamertags(selected_games)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return _rerender_registration(form_data)
# The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005).
# A manual registration may still provide a display name, but never a
# Discord snowflake: that identifier is an authentication factor for
# bot reactions and must come from Discord itself.
discord_user_id = discord_oauth.get('id')
if discord_user_id:
discord_user_id = str(discord_user_id)
discord_username = (
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
)
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
captcha = generate_captcha()
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data=form_data,
)
flash(_('Username already exists.'), 'danger')
return _rerender_registration(form_data)
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
captcha = generate_captcha()
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data=form_data,
)
flash(_('Email already registered.'), 'danger')
return _rerender_registration(form_data)
# The database constraint belongs to DB-002, after production has
# been backed up and deduplicated. Refuse new duplicates now instead
# of leaving the critical impersonation path open until then.
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender_registration(form_data)
hashed_password = hash_password(password)
user = Player(
@@ -291,36 +445,37 @@ def register():
league_os_profile=league_os_profile,
)
db.session.add(user)
db.session.commit()
# flush, not commit: the id is needed for the gamertag rows below,
# and signing up is one operation. Committing here made it two, so a
# failure while writing the gamertags left an account whose declared
# games were silently absent (ARCH-006).
db.session.flush()
# Create UserGamertag records for each selected game
from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
if gamertag_value:
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
)
db.session.add(gamertag)
for game, gamertag_data in submitted_gamertags.items():
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
)
db.session.add(gamertag)
db.session.commit()
# Clear Discord OAuth data from session after successful registration
session.pop('discord_oauth', None)
session.pop(REGISTRATION_ISSUED_KEY, None)
flash('Your account has been created! You can now log in.', 'success')
log_auth_event('account.registered', username=user.username, user_id=user.id)
flash(_('Your account has been created! You can now log in.'), 'success')
return redirect(url_for('auth.login'))
# GET request — render empty form
captcha = generate_captcha()
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data={},
)
issue_registration_challenge()
return _rerender_registration({})
@auth_bp.route('/discord/login')
@@ -333,18 +488,31 @@ def discord_login():
Returns:
Response: Redirect to Discord authorization URL.
"""
if not DISCORD_CLIENT_ID:
flash('Discord OAuth2 is not configured.', 'danger')
return redirect(url_for('auth.register'))
purpose = 'profile' if current_user.is_authenticated else 'registration'
session[DISCORD_PURPOSE_KEY] = purpose
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
# raise inside the query builder rather than report a configuration error.
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
flash(_('Discord OAuth2 is not configured.'), 'danger')
return redirect(url_for(return_endpoint))
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
# attacker could have the victim's browser consume an authorization code
# obtained for the attacker's own Discord account, silently binding that
# identity to the victim's registration form.
state = secrets.token_urlsafe(32)
session[DISCORD_STATE_KEY] = state
params = {
'client_id': DISCORD_CLIENT_ID,
'redirect_uri': DISCORD_REDIRECT_URI,
'response_type': 'code',
'scope': 'identify connections',
'state': state,
}
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
return redirect(auth_url)
@@ -352,18 +520,41 @@ def discord_login():
def discord_callback():
"""Handle the OAuth2 callback from Discord.
Exchanges the authorization code for an access token, then fetches
the user's profile (/users/@me) and connections (/users/@me/connections).
Results are stored in the session and the user is redirected back to
the registration form where fields will be pre-filled.
Exchanges the authorization code for an access token, then fetches the
user's profile. During registration, connected game accounts are also
loaded into server-side draft state. For a signed-in profile relink, the
verified identity is written directly without passing through a form.
Returns:
Response: Redirect to registration page.
Response: Redirect to the registration form or profile editor.
"""
# The state is consumed whatever happens next: a token is single-use, and
# leaving it in the session would allow a replay.
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
if purpose == 'profile' and current_user.is_authenticated:
return_endpoint = 'users.edit_profile'
elif purpose == 'profile':
return_endpoint = 'auth.login'
else:
return_endpoint = 'auth.register'
expected_state = session.pop(DISCORD_STATE_KEY, None)
received_state = request.args.get('state', '')
if not expected_state or not secrets.compare_digest(expected_state, received_state):
flash(
_(
'Discord authorization could not be verified. '
'Please start the connection again from this page.'
),
'danger',
)
return redirect(url_for(return_endpoint))
code = request.args.get('code')
if not code:
flash('Discord authorization failed. No code received.', 'danger')
return redirect(url_for('auth.register'))
flash(_('Discord authorization failed. No code received.'), 'danger')
return redirect(url_for(return_endpoint))
# Exchange the authorization code for an access token
token_data = {
@@ -385,13 +576,13 @@ def discord_callback():
token_response.raise_for_status()
token_json = token_response.json()
access_token = token_json.get('access_token')
except requests.RequestException as e:
flash(f'Failed to connect to Discord. Please try again.', 'danger')
return redirect(url_for('auth.register'))
except requests.RequestException:
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for(return_endpoint))
if not access_token:
flash('Failed to obtain Discord access token.', 'danger')
return redirect(url_for('auth.register'))
flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for(return_endpoint))
auth_headers = {'Authorization': f'Bearer {access_token}'}
@@ -405,8 +596,44 @@ def discord_callback():
user_response.raise_for_status()
user_data = user_response.json()
except requests.RequestException:
flash('Failed to fetch Discord user profile.', 'danger')
return redirect(url_for('auth.register'))
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
discord_user_id = user_data.get('id')
try:
if not discord_user_id:
raise ValidationError('missing Discord user id')
discord_user_id = str(discord_user_id)
validate_discord_user_id(discord_user_id)
except ValidationError:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
if purpose == 'profile':
# If the session expired while Discord was open, do not turn a profile
# relink into registration state for an anonymous browser.
if not current_user.is_authenticated:
flash(_('Please log in to connect your Discord account.'), 'danger')
return redirect(url_for('auth.login'))
clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != current_user.id,
).first()
if clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return redirect(url_for('users.edit_profile'))
current_user.discord_user_id = discord_user_id
current_user.discord_username = user_data.get('username') or None
db.session.commit()
log_auth_event(
'account.discord_linked',
username=current_user.username,
user_id=current_user.id,
)
flash(_('Discord account connected!'), 'success')
return redirect(url_for('users.edit_profile'))
# Fetch the user's connected gaming accounts
connections = []
@@ -445,29 +672,40 @@ def discord_callback():
# Store in session for the registration form to use
session['discord_oauth'] = {
'id': user_data.get('id'),
'id': discord_user_id,
'username': user_data.get('username'),
'avatar': user_data.get('avatar'),
'gamertag_suggestions': gamertag_suggestions,
'auto_select_games': auto_select_games,
}
flash('Discord account connected! Your profile has been pre-filled.', 'success')
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
return redirect(url_for('auth.register'))
@auth_bp.route('/logout')
@auth_bp.route('/logout', methods=['POST'])
@login_required
def logout():
"""Log out the current user and clear the session.
POST, not GET: a GET route is not covered by CSRF protection, so any
page on the internet could sign a user out with an <img> tag pointing
here. A nuisance rather than a compromise, but it costs one form to
close (SEC-019).
Clears the user session and regenerates session ID to prevent
session fixation/replay after logout.
Returns:
Response: Redirect to login page with logout message.
"""
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
logout_user()
# Same reasoning as at login: the language is a display preference, not
# session state belonging to the account being signed out.
_locale = session.get(LOCALE_SESSION_KEY)
session.clear()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
if _locale:
session[LOCALE_SESSION_KEY] = _locale
flash(_('You have been logged out.'), 'info')
return redirect(url_for('auth.login'))
+250 -137
View File
@@ -3,31 +3,44 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS,
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
)
from sqlalchemy import func
from sqlalchemy.orm import aliased
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
GAME_POSITIONS,
Admin,
Evaluation,
Player,
Tryout,
TryoutRegistration,
User,
)
from app.pagination import paginate
from app.validators import EvaluationSchema
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def validate_score(score_value):
"""Validate that a score is between 1 and 10."""
if score_value is None:
return None
try:
score = int(score_value)
if 1 <= score <= 10:
return score
return None
except (ValueError, TypeError):
return None
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('')
@@ -37,7 +50,7 @@ def list_evaluations():
user = current_user
if isinstance(user, Player):
flash('You do not have permission to view evaluations.', 'danger')
flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard'))
sort_column = request.args.get('sort', 'created_at')
@@ -73,50 +86,183 @@ def list_evaluations():
sort_expr = sort_expr.desc()
if isinstance(user, Admin):
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
.order_by(sort_expr).all()
avg_scores = db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).all()
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr, Evaluation.id)
)
avg_scores = (
db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in avg_scores)
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
p = players_by_id.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p, 'count': row.eval_count,
'player': p,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
elif user.can_evaluate():
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
.filter(Evaluation.evaluator_id == user.id) \
.order_by(sort_expr).all()
player_scores = {}
else:
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
.filter(Evaluation.player_id == user.id) \
.order_by(sort_expr).all()
# Everyone still here evaluates: players were redirected above, and
# can_evaluate() is true for the four remaining roles. The former
# `else` branch listed evaluations *received* — a player's view,
# unreachable from this point (ARCH-007).
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(Evaluation.evaluator_id == user.id)
.order_by(sort_expr, Evaluation.id)
)
player_scores = {}
return render_template('pages/evaluations.html',
evaluations=evaluations, player_scores=player_scores,
sort_column=sort_column, sort_order=sort_order)
return render_template(
'pages/evaluations.html',
evaluations=evaluations_page.items,
pagination=evaluations_page,
player_scores=player_scores,
sort_column=sort_column,
sort_order=sort_order,
)
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
@login_required
def evaluate_player(tryout_id, player_id):
"""Evaluate a specific player in a tryout."""
if not current_user.can_evaluate():
flash(_('You do not have permission to evaluate players.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
).first()
is not None
)
if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id,
).first()
def render_evaluation_form():
evaluators = None
if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
).all()
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
evaluators = [
{'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e}
for e in all_evaluations
]
return render_template(
'pages/evaluate_player.html',
tryout=tryout,
player=player,
existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS,
)
if request.method == 'POST':
try:
data = EvaluationSchema().load(form_payload(list_fields=(), optional_blank=()))
except ValidationError as err:
flash_validation_errors(err)
return render_evaluation_form()
evaluation = existing_eval
if evaluation is None:
evaluation = Evaluation(
tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id,
)
db.session.add(evaluation)
flash(_('Evaluation submitted successfully!'), 'success')
else:
flash(_('Evaluation updated!'), 'success')
evaluation.apply_scores(data)
evaluation.comments = data['comments']
evaluation.position_recommendation = data['position_recommendation']
db.session.commit()
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_evaluation_form()
@evaluations_bp.route('/<int:tryout_id>/players')
@login_required
def players_to_evaluate(tryout_id):
"""List players that need evaluation in a specific tryout."""
if not current_user.can_evaluate():
flash(_('Permission denied.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players_by_id = _users_by_id(reg.player_id for reg in registrations)
evaluated_player_ids = {
player_id
for (player_id,) in db.session.query(Evaluation.player_id)
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
.all()
}
players = [
{
'player': player,
'evaluated': player.id in evaluated_player_ids,
'registration': registration,
}
for registration in registrations
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
]
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
@login_required
def batch_evaluate(tryout_id):
"""Evaluate multiple players at once in a tryout.
GET renders a single form listing every selected player with their
evaluation criteria. POST saves (creates or updates) all of them.
"""
if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard'))
@@ -126,108 +272,75 @@ def evaluate_player(tryout_id, player_id):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id,
).first() is not None
if not is_registered:
flash('Player is not registered for this tryout.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Resolve selected player ids (query string on GET, hidden fields on POST).
player_ids = []
for raw in request.values.getlist('player_ids'):
try:
pid = int(raw)
except (ValueError, TypeError):
continue
if pid not in player_ids:
player_ids.append(pid)
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash('Can only evaluate players.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not player_ids:
flash('Please select at least one player to evaluate.', 'warning')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
).first()
players = []
for pid in player_ids:
player = User.query.get(pid)
if not player or not isinstance(player, Player):
continue
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=pid,
).first() is not None
if not is_registered:
continue
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
).first()
existing_scores = {
field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA
}
players.append({
'player': player,
'existing': existing,
'existing_scores': existing_scores,
})
if not players:
flash('No valid players selected for evaluation.', 'danger')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
if request.method == 'POST':
mecanics = validate_score(request.form.get('mecanics_score'))
cohesion = validate_score(request.form.get('cohesion_score'))
communication = validate_score(request.form.get('communication_score'))
gamesense = validate_score(request.form.get('gamesense_score'))
versatility = validate_score(request.form.get('versatility_score'))
discipline = validate_score(request.form.get('discipline_score'))
analysis = validate_score(request.form.get('analysis_score'))
sport_ethics = validate_score(request.form.get('sport_ethics_score'))
mental = validate_score(request.form.get('mental_score'))
comments = request.form.get('comments')
position = request.form.get('position_recommendation')
saved = 0
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
scores = [s for s in [mecanics, cohesion, communication, gamesense,
versatility, discipline, analysis, sport_ethics, mental]
if s is not None]
overall = sum(scores) / len(scores) if scores else None
if existing_eval:
existing_eval.mecanics_score = mecanics
existing_eval.cohesion_score = cohesion
existing_eval.communication_score = communication
existing_eval.gamesense_score = gamesense
existing_eval.versatility_score = versatility
existing_eval.discipline_score = discipline
existing_eval.analysis_score = analysis
existing_eval.sport_ethics_score = sport_ethics
existing_eval.mental_score = mental
existing_eval.overall_score = overall
existing_eval.comments = comments
existing_eval.position_recommendation = position
flash('Evaluation updated!', 'success')
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=player_id,
evaluator_id=current_user.id,
mecanics_score=mecanics, cohesion_score=cohesion,
communication_score=communication, gamesense_score=gamesense,
versatility_score=versatility, discipline_score=discipline,
analysis_score=analysis, sport_ethics_score=sport_ethics,
mental_score=mental, overall_score=overall,
comments=comments, position_recommendation=position,
)
db.session.add(evaluation)
flash('Evaluation submitted successfully!', 'success')
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))
evaluators = None
if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=player_id,
).all()
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
for e in all_evaluations]
return render_template('pages/evaluate_player.html',
tryout=tryout, player=player,
existing_eval=existing_eval,
evaluators=evaluators,
return render_template('pages/batch_evaluate.html',
tryout=tryout, players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS)
@evaluations_bp.route('/<int:tryout_id>/players')
@login_required
def players_to_evaluate(tryout_id):
"""List players that need evaluation in a specific tryout."""
if not current_user.can_evaluate():
flash('Permission denied.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None,
'registration': reg})
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
+157 -56
View File
@@ -3,16 +3,29 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash
from flask_login import login_required, current_user
from datetime import date
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import func
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
Match, MatchParticipant, OrgTeam,
Admin,
Coach,
Evaluation,
Manager,
Match,
MatchParticipant,
Player,
Scout,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from sqlalchemy import func
from datetime import date
from app.permissions import coach_tryout_ids
main_bp = Blueprint('main', __name__)
@@ -23,6 +36,32 @@ def index():
return redirect(url_for('auth.login'))
@main_bp.route('/lang/<locale>')
def set_language(locale):
"""Switch the interface language and return where the user came from.
Available to anonymous visitors too: the login page has to be readable
before anyone can sign in.
A GET link rather than a form: the only thing a forged request could
achieve is changing the visitor's own display language, which carries
no consequence worth a token. The redirect target is still validated —
an unchecked `Referer` would make this an open redirect.
"""
from app.i18n import set_locale
from app.routes.auth import is_safe_url
if not set_locale(locale):
flash(_('That language is not available.'), 'warning')
target = request.referrer
if target and is_safe_url(target):
return redirect(target)
return redirect(
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
)
@main_bp.route('/dashboard')
@login_required
def dashboard():
@@ -43,46 +82,95 @@ def dashboard():
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
today = date.today()
stats['upcoming_matches'] = Match.query.filter(
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all()
stats['upcoming_matches'] = (
Match.query.filter(
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
)
elif isinstance(user, Manager):
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
stats['active_tryouts'] = Tryout.query.filter_by(
created_by=user.id, status='in_progress').count()
created_by=user.id, status='in_progress'
).count()
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['my_tryouts'] = Tryout.query.filter_by(
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
stats['my_tryouts'] = (
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
)
today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
stats['upcoming_matches'] = (
Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if manager_tryout_ids
else []
)
elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
registrations = TryoutRegistration.query.filter(
TryoutRegistration.status.in_(['registered', 'attended'])).all()
registered_player_ids = [r.player_id for r in registrations]
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
# A count, computed as a count. This used to load every registration
# row in the club and every evaluation this coach had written, build
# two Python sets and subtract them — two full table reads to produce
# one integer (PERF-004).
already_evaluated = (
db.session.query(Evaluation.player_id)
.filter(
Evaluation.evaluator_id == user.id,
Evaluation.player_id == TryoutRegistration.player_id,
)
.exists()
)
stats['pending_evaluations'] = (
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
.filter(
TryoutRegistration.status.in_(['registered', 'attended']),
~already_evaluated,
)
.scalar()
)
stats['my_recent_evaluations'] = (
Evaluation.query.filter_by(evaluator_id=user.id)
.order_by(Evaluation.created_at.desc())
.limit(10)
.all()
)
today = date.today()
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
target_org_team_id=org_team.id).all()] if org_team else []
stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(coach_tryout_ids),
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
# Was: the first team matching the legacy coach_id column, and only
# the tryouts targeting it. A coach attached by the many-to-many
# relationship, or coaching a second team, saw no upcoming match.
tryout_ids = coach_tryout_ids(user)
stats['upcoming_matches'] = (
Match.query.filter(
Match.tryout_id.in_(tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if tryout_ids
else []
)
elif isinstance(user, Player):
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
stats['my_registrations'] = TryoutRegistration.query.filter_by(
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
stats['my_registrations'] = (
TryoutRegistration.query.filter_by(player_id=user.id)
.order_by(TryoutRegistration.registered_at.desc())
.limit(5)
.all()
)
today = date.today()
next_matches = []
@@ -93,10 +181,15 @@ def dashboard():
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships]
upcoming_matches = Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).all()
upcoming_matches = (
Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.all()
)
for match in upcoming_matches:
is_participant = False
@@ -104,36 +197,44 @@ def dashboard():
if match.match_type == 'team_vs_team':
if match.team1_id in player_team_ids:
is_participant = True
team = next((tm for tm in player_team_memberships
if tm.team_id == match.team1_id), None)
team = next(
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
)
elif match.team2_id in player_team_ids:
is_participant = True
team = next((tm for tm in player_team_memberships
if tm.team_id == match.team2_id), None)
team = next(
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
)
else:
if match.id in player_match_ids:
is_participant = True
if is_participant:
next_matches.append({
'tryout': match.tryout, 'match': match,
'team': team.team if team else None,
})
next_matches.append(
{
'tryout': match.tryout,
'match': match,
'team': team.team if team else None,
}
)
stats['next_matches'] = next_matches
elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).order_by(
func.avg(Evaluation.overall_score).desc()).limit(5).all()
stats['top_players'] = []
for row in stats['avg_scores']:
p = User.query.get(row.player_id)
if p:
stats['top_players'].append((p, round(row.avg_score, 1)))
top_rows = (
db.session.query(
User,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.join(Evaluation, Evaluation.player_id == User.id)
.filter(User.role == 'player')
.group_by(User.id)
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
.limit(5)
.all()
)
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
return render_template('pages/dashboard.html', user=user, stats=stats)
return render_template('pages/dashboard.html', user=user, stats=stats)
+451 -343
View File
@@ -3,21 +3,110 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from datetime import datetime, timedelta
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from sqlalchemy.orm import joinedload
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Match, MatchParticipant, Team, TeamMember,
OrgTeam, TryoutRegistration, PlayerDisponibility,
Admin,
Coach,
Manager,
Match,
MatchParticipant,
OneOnOneRequest,
PersonalNote,
Player,
PlayerDisponibility,
Scout,
Team,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from datetime import datetime, time, timedelta
from app.discord_bot import send_schedule_notification
from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
def match_form_payload():
"""The match form, shaped for marshmallow.
`player_ids` is a repeated checkbox, so it needs getlist(); `games` — the
default list field — has nothing to do with this form.
"""
return form_payload(list_fields=('player_ids',), optional_blank=())
def registered_players(tryout_id):
"""Players registered for one tryout, loaded in a single query.
The create form previously called ``User.query.get`` twice per
registration (once in the filter and once in the result expression),
and the edit form called it once per row. Besides scaling linearly, both
paths could return duplicates while DB-006 is still pending. The join is
bounded and ``distinct`` preserves the form's intended one-option-per-
player contract until the database constraint lands.
"""
return (
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
.filter(TryoutRegistration.tryout_id == tryout_id)
.order_by(User.username)
.distinct()
.all()
)
#: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30
def default_end_time(date, start_time):
"""End time for a match whose form left it blank."""
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
def create_participants(match, data):
"""Attach participants to a match, per its type.
Was written out twice, in create_match and in edit_match, and had already
drifted: the copy in edit_match kept its player ids as strings and called
int() on them one line later, the one in create_match did not (ARCH-005).
Returns:
tuple: (player ids to notify, the participant rows created).
"""
sides = []
if match.match_type == 'team_vs_team':
for side, team_id in ((1, match.team1_id), (2, match.team2_id)):
if team_id:
members = TeamMember.query.filter_by(team_id=team_id).all()
sides.append((side, [m.player_id for m in members]))
elif match.match_type == 'player_vs_player':
sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])]
elif match.match_type == 'player_scrim':
sides = [(None, data['player_ids'])]
player_ids = []
participant_ids = []
for side, ids in sides:
for player_id in ids:
participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side)
db.session.add(participant)
db.session.flush()
participant_ids.append(participant.id)
player_ids.append(player_id)
return player_ids, participant_ids
def can_schedule_match():
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
return isinstance(current_user, (Admin, Manager, Coach, Scout))
@@ -38,17 +127,84 @@ def calendar():
return render_template('pages/calendar.html')
def calendar_window(args):
"""The date range FullCalendar is asking about, if it said.
A URL event source appends `start` and `end` automatically, in ISO 8601
with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
needed here, and a value that does not parse is treated as absent
rather than as an error: a calendar that shows too much is a
performance problem, one that 400s is a broken page.
Args:
args: request.args.
Returns:
tuple[date | None, date | None]: Inclusive bounds.
"""
def _parse(value):
if not value:
return None
try:
return datetime.strptime(value[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
return _parse(args.get('start')), _parse(args.get('end'))
@matches_bp.route('/api/events')
@json_endpoint
@login_required
def api_events():
"""API endpoint returning calendar events for FullCalendar."""
"""Calendar events for FullCalendar.
Bounded and batched (PERF-002). This used to walk `tryout.matches` for
every visible tryout — every tryout the club has ever run, for a
president — and then issue one MatchParticipant query per match to find
out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation.
"""
events = []
tryouts = get_visible_tryouts_for_user()
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
for tryout in tryouts:
for match in tryout.matches:
if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start:
query = query.filter(Match.date >= window_start)
if window_end:
query = query.filter(Match.date <= window_end)
matches = query.all()
# Participants for every match in the window, in one query rather
# than one per match. `participants` is a dynamic relationship, so
# eager loading options do not apply to it.
match_ids = [match.id for match in matches]
participants_by_match = {}
mine_by_match = {}
if match_ids:
rows = (
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
.options(joinedload(MatchParticipant.player))
.all()
)
for row in rows:
participants_by_match.setdefault(row.match_id, []).append(row)
if row.player_id == current_user.id:
mine_by_match[row.match_id] = row
for match in matches:
tryout = tryouts_by_id[match.tryout_id]
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
match_desc = match.description or ''
# 'description' used to be participants_str + '<br>' + description.
# Building presentation markup inside a JSON field is what carried
# the stored XSS: the browser dropped it straight into innerHTML,
# and player usernames travelled through it unescaped. The two
# values are already separate keys, so the concatenation also made
# the modal show the participants twice.
participants_str = ''
if match.match_type == 'team_vs_team':
teams = []
@@ -56,102 +212,104 @@ def api_events():
teams.append(match.team1.name)
if match.team2:
teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}"
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
participants_str = ' vs '.join(teams)
else:
player_names = []
for p in match.participants.all():
player_names.append(p.player.username if p.player else 'Unknown Player')
player_names = [
p.player.username if p.player else 'Unknown Player'
for p in participants_by_match.get(match.id, [])
]
participants_str = ', '.join(player_names) if player_names else 'No players'
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
user_participant = MatchParticipant.query.filter_by(
match_id=match.id, player_id=current_user.id,
).first()
user_participant = mine_by_match.get(match.id)
events.append({
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status, 'description': match_desc,
'match_type': match.match_type,
'tryout_id': tryout.id, 'match_id': match.id,
'start_time': start_time_str, 'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
},
})
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed
if user_participant
else False,
},
}
)
# Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player):
one_on_ones = OneOnOneRequest.query.filter_by(
player_id=current_user.id,
status='approved'
player_id=current_user.id, status='approved'
).all()
elif isinstance(current_user, Coach):
one_on_ones = OneOnOneRequest.query.filter_by(
coach_id=current_user.id,
status='approved'
coach_id=current_user.id, status='approved'
).all()
else:
one_on_ones = []
for ooo in one_on_ones:
event = {
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
'player_name': ooo.player.full_name,
'coach_name': ooo.coach.full_name,
},
}
# Provide real start/end datetimes so FullCalendar places the event
# in the correct time slot (Week/Day views) instead of all-day.
if ooo.start_time and ooo.end_time:
start_dt = datetime.combine(ooo.date, ooo.start_time)
end_dt = datetime.combine(ooo.date, ooo.end_time)
event['start'] = start_dt.isoformat()
event['end'] = end_dt.isoformat()
events.append(event)
events.append(
{
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
}
)
return jsonify(events)
@matches_bp.route('/api/events/<int:tryout_id>')
@json_endpoint
@login_required
def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False
player_in_match = False
if isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).all()
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
player_matches = (
Match.query.join(MatchParticipant)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.all()
)
player_in_match = len(player_matches) > 0
if not can_view and not is_registered and not player_in_match:
@@ -160,7 +318,9 @@ def api_events_for_tryout(tryout_id):
events = []
for match in tryout.matches:
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
match_color = (
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
)
participants_str = ''
if match.match_type == 'team_vs_team':
teams = []
@@ -170,8 +330,16 @@ def api_events_for_tryout(tryout_id):
teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}"
elif match.match_type == 'player_vs_player':
team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
team1_players = [
p.player.username
for p in match.participants.filter_by(team_side=1).all()
if p.player
]
team2_players = [
p.player.username
for p in match.participants.filter_by(team_side=2).all()
if p.player
]
if team1_players and team2_players:
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
else:
@@ -183,19 +351,25 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append({
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status, 'match_type': match.match_type,
'tryout_id': tryout.id, 'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str, 'end_time': end_time_str,
},
})
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str,
'end_time': end_time_str,
},
}
)
return jsonify(events)
@@ -204,288 +378,185 @@ def api_events_for_tryout(tryout_id):
@login_required
def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to schedule matches for this tryout.', 'danger')
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout_id)
prefill_date = request.args.get('date', '')
def rerender():
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location')
match_type = request.form.get('match_type')
if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
payload = match_form_payload()
# A tryout match with no date of its own happens on the tryout's day.
payload.setdefault('date', tryout.date.isoformat())
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
start_time = None
end_time = None
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time()
except ValueError:
flash('Invalid time format.', 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
data = MatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return rerender()
match = Match(
tryout_id=tryout_id, title=title, description=description,
date=date_obj, start_time=start_time, end_time=end_time,
location=location, match_type=match_type, created_by=current_user.id,
tryout_id=tryout_id,
title=data['title'],
description=data['description'],
date=data['date'],
start_time=data['start_time'],
end_time=data['end_time'] or default_end_time(data['date'], data['start_time']),
location=data['location'],
match_type=data['match_type'],
created_by=current_user.id,
)
db.session.add(match)
db.session.flush()
notified_player_ids = []
notified_participant_ids = []
if data['match_type'] == 'team_vs_team':
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
if match_type == 'team_vs_team':
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
match.team1_id = int(team1_id) if team1_id else None
match.team2_id = int(team2_id) if team2_id else None
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
elif match_type == 'player_vs_player':
team1_player_ids = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.get('team2_player_ids', '')
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in team2_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = team1_ids + team2_ids
elif match_type == 'player_scrim':
player_ids = request.form.getlist('player_ids')
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in player_ids]
notified_player_ids, notified_participant_ids = create_participants(match, data)
db.session.commit()
# Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d')
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
for i, player_id in enumerate(notified_player_ids):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title,
event_date=event_date_str, event_time=event_time_str,
reference_id=reference_id,
)
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=zip_participants(notified_player_ids, notified_participant_ids),
fallback_id=match.id,
)
flash('Match scheduled successfully!', 'success')
flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
return rerender()
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_match(match_id):
"""Edit an existing match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this match.', 'danger')
flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout.id)
current_player_ids = [p.player_id for p in match.participants.all()]
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
def rerender():
"""The form, with everything the template needs.
One context, used by the GET and by a rejected POST alike. The
rejection paths used to pass a shorter list, and match_form.html
serialises participants_map into a <script> block — so a rejected
edit died in `tojson` on an Undefined, turning a validation message
into a 500.
"""
participants_map = {
p.player_id: {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side,
}
for p in match.participants.all()
}
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map,
)
if request.method == 'POST':
match.title = request.form.get('title')
match.description = request.form.get('description')
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location')
status = request.form.get('status')
try:
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids)
data = MatchEditSchema().load(match_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
if not start_time_str:
flash('Start time is required.', 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids)
try:
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(match.date, match.start_time)
end_dt = start_dt + timedelta(minutes=30)
match.end_time = end_dt.time()
except ValueError:
match.start_time = None
match.location = location
if status in ['scheduled', 'completed', 'cancelled']:
match.status = status
# Assigned only once the whole form has been accepted. Assigning as
# each field was read meant a form rejected halfway had already
# changed the record in the session.
match.title = data['title']
match.description = data['description']
match.date = data['date']
match.start_time = data['start_time']
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
match.location = data['location']
match.status = data['status']
notified_player_ids = []
notified_participant_ids = []
if match.match_type == 'team_vs_team':
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
new_team1_id = int(team1_id) if team1_id else None
new_team2_id = int(team2_id) if team2_id else None
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
if teams_changed:
MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = new_team1_id
match.team2_id = new_team2_id
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data)
else:
if match.team1_id:
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
if match.team2_id:
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
elif match.match_type == 'player_vs_player':
# Same teams: the roster stands, but everyone is told again,
# because the date or the time may have moved.
for team_id in (match.team1_id, match.team2_id):
if team_id:
notified_player_ids.extend(
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
)
else:
MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_str = request.form.get('team1_player_ids', '')
team2_str = request.form.get('team2_player_ids', '')
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
for pid in t1_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in t2_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
elif match.match_type == 'player_scrim':
MatchParticipant.query.filter_by(match_id=match.id).delete()
player_ids = request.form.getlist('player_ids')
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in player_ids]
notified_player_ids, notified_participant_ids = create_participants(match, data)
db.session.commit()
# Discord notifications
end_time_val = match.end_time or (match.start_time if match.start_time else None)
if match.start_time and end_time_val:
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
else:
event_time_str = 'TBD'
event_date_str = match.date.strftime('%Y-%m-%d')
for i, player_id in enumerate(notified_player_ids):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title,
event_date=event_date_str, event_time=event_time_str,
reference_id=reference_id,
)
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=zip_participants(notified_player_ids, notified_participant_ids),
fallback_id=match.id,
)
flash('Match updated successfully!', 'success')
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
participants_map = {}
for p in match.participants.all():
participants_map[p.player_id] = {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side,
}
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map)
return rerender()
@matches_bp.route('/api/manageable-tryouts')
@json_endpoint
@login_required
def api_manageable_tryouts():
"""API endpoint returning tryouts the current user can manage."""
@@ -496,11 +567,14 @@ def api_manageable_tryouts():
manageable = []
for t in tryouts:
if current_user.can_manage_this_tryout(t):
manageable.append({
'id': t.id, 'title': t.title,
'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
})
manageable.append(
{
'id': t.id,
'title': t.title,
'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
}
)
return jsonify(manageable)
@@ -508,48 +582,79 @@ def api_manageable_tryouts():
@login_required
def delete_match(match_id):
"""Delete a match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this match.', 'danger')
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be deleted.', 'danger')
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Notes outlive the match they were taken during: a coach's observation
# keeps its value, and deleting it here would destroy unrelated content.
# Only the context link is dropped. Participants go through the
# relationship's delete-orphan cascade.
PersonalNote.query.filter_by(match_id=match_id).update(
{'match_id': None}, synchronize_session=False
)
db.session.delete(match)
db.session.commit()
flash('Match deleted successfully.', 'success')
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
def get_players_available_at_time(date_str, time_str):
"""Get list of player IDs available at a specific date and time."""
"""Player IDs whose weekly availability covers this date and time.
Two queries, whatever the size of the club. This used to load every
active player and then run one PlayerDisponibility query per player, on
an unindexed column — sixty players meant sixty-one round trips to
answer a question the database can answer in one (PERF-003).
Args:
date_str: 'YYYY-MM-DD'.
time_str: 'HH:MM'.
Returns:
list[int]: Player IDs, empty when the input does not parse.
"""
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
time_obj = datetime.strptime(time_str, '%H:%M').time()
except (ValueError, TypeError):
return []
date_for_day = datetime.strptime(date_str, '%Y-%m-%d')
day_of_week = date_for_day.weekday()
day_of_week = parsed_date.weekday()
active_player_ids = {
row.id
for row in User.query.with_entities(User.id)
.filter_by(role='player', is_active_account=True)
.all()
}
if not active_player_ids:
return []
players = User.query.filter_by(role='player', is_active_account=True).all()
available_players = []
for player in players:
disponibilities = PlayerDisponibility.query.filter_by(
player_id=player.id, day_of_week=day_of_week,
).all()
for disp in disponibilities:
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
match_time = time_obj.hour * 60 + time_obj.minute
if disp_start <= match_time < disp_end:
available_players.append(player.id)
break
return available_players
# The comparison stays in Python: start_time and end_time are stored as
# time columns, and comparing them in SQL across three backends is not
# worth the portability risk for a single day's rows.
minutes = time_obj.hour * 60 + time_obj.minute
available = []
seen = set()
for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
if disp.player_id in seen or disp.player_id not in active_player_ids:
continue
start = disp.start_time.hour * 60 + disp.start_time.minute
end = disp.end_time.hour * 60 + disp.end_time.minute
if start <= minutes < end:
available.append(disp.player_id)
seen.add(disp.player_id)
return available
@matches_bp.route('/api/available_players/<date>/<time>')
@json_endpoint
@login_required
def api_available_players(date, time):
"""API endpoint to get players available at a specific date/time slot."""
@@ -560,13 +665,14 @@ def api_available_players(date, time):
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
participant = MatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(MatchParticipant, participant_id)
if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
@@ -576,8 +682,10 @@ def toggle_presence(match_id, participant_id):
participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit()
return jsonify({
'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
})
return jsonify(
{
'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+164 -170
View File
@@ -3,32 +3,42 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
Admin,
Coach,
Manager,
OrgTeam,
Player,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings,
)
from datetime import datetime, timedelta
from app.discord_bot import send_schedule_notification
from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive
from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
def can_manage_team_match(team):
"""Check if current user can manage matches for this team."""
if isinstance(current_user, Admin):
return True
if isinstance(current_user, Manager):
return True
if isinstance(current_user, Coach):
if team.coaches.filter_by(id=current_user.id).first():
return True
if team.coach_id == current_user.id:
return True
return False
"""Whether the current user can manage matches for this team.
Same rule as administering the team itself, so it is the same call.
This function used to restate it, and the restatement drifted.
"""
return can_manage_org_team(current_user, team)
def season_locked():
@@ -48,29 +58,21 @@ def list_matches():
"""List all team matches visible to the current user."""
filter_team_id = request.args.get('team_id', type=int)
if isinstance(current_user, Admin):
# A manager administers every team, so the listing shows them all;
# visible_org_teams() only reports the teams they are attached to.
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, Manager):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, (Coach, Player)):
teams = visible_org_teams(current_user)
team_ids = [t.id for t in teams]
matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
elif isinstance(current_user, Player):
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(player_team_ids),
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
matches_query = (
TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
)
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
@@ -78,46 +80,57 @@ def list_matches():
if filter_team_id:
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
matches = matches_query.order_by(TeamMatch.date.desc()).all()
# Pagination also bounds the per-match participant loop below, which is
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
matches = matches_page.items
match_data = []
for tm in matches:
confirmed, total = tm.get_confirmed_count()
participants = []
for p in tm.participants.all():
participants.append({
'id': p.id, 'player': p.player,
'is_confirmed': p.is_confirmed,
})
match_data.append({
'match': tm, 'participants': participants,
'confirmed_count': confirmed, 'total_count': total,
})
participants.append(
{
'id': p.id,
'player': p.player,
'is_confirmed': p.is_confirmed,
}
)
match_data.append(
{
'match': tm,
'participants': participants,
'confirmed_count': confirmed,
'total_count': total,
}
)
return render_template('pages/team_matches.html',
teams=teams, match_data=match_data,
now=datetime.utcnow())
return render_template(
'pages/team_matches.html',
teams=teams,
match_data=match_data,
pagination=matches_page,
now=utc_now_naive(),
)
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not can_manage_team_match(team):
flash('You do not have permission to schedule matches for this team.', 'danger')
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before scheduling matches.', 'danger')
return redirect(url_for('team_matches.list_matches'))
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
prefill_date = request.args.get('date', '')
is_practice = request.args.get('type') == 'practice'
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
if is_practice and request.method == 'GET':
class TryoutProxy:
def __init__(self, team_obj):
self.id = 0
@@ -129,56 +142,49 @@ def create_match(team_id):
proxy_tryout = TryoutProxy(team)
all_players = [tp.player for tp in team_players if tp.player]
return render_template('pages/match_form.html',
tryout=proxy_tryout, teams=[], all_players=all_players,
prefill_date=prefill_date, is_practice=True,
team_id=team_id, team=team)
return render_template(
'pages/match_form.html',
tryout=proxy_tryout,
teams=[],
all_players=all_players,
prefill_date=prefill_date,
is_practice=True,
team_id=team_id,
team=team,
)
if request.method == 'POST':
title = request.form.get('title', default_title)
opponent = request.form.get('opponent', '').strip() if not is_practice else None
description = request.form.get('description', '')
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location', '')
if not date_str:
flash('Date is required.', 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date)
payload = form_payload(list_fields=(), optional_blank=())
# A practice has no opponent, whatever the form sent.
payload.setdefault('title', default_title)
if is_practice:
payload.pop('opponent', None)
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
data = TeamMatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
start_time = None
end_time = None
if start_time_str:
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time()
except ValueError:
flash('Invalid time format.', 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
start_time = data['start_time']
end_time = data['end_time'] or default_end_time(data['date'], start_time)
team_match = TeamMatch(
org_team_id=team_id, title=title,
description=description or None,
opponent=opponent or None,
date=date_obj, start_time=start_time, end_time=end_time,
location=location or None, created_by=current_user.id,
org_team_id=team_id,
title=data['title'],
description=data['description'],
opponent=data['opponent'],
date=data['date'],
start_time=start_time,
end_time=end_time,
location=data['location'],
created_by=current_user.id,
)
db.session.add(team_match)
db.session.flush()
@@ -186,7 +192,8 @@ def create_match(team_id):
notified_participant_ids = []
for tp in team_players:
participant = TeamMatchParticipant(
team_match_id=team_match.id, player_id=tp.player_id,
team_match_id=team_match.id,
player_id=tp.player_id,
)
db.session.add(participant)
db.session.flush()
@@ -194,20 +201,20 @@ def create_match(team_id):
db.session.commit()
# Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d')
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
notify_participants(
title=team_match.title,
date=team_match.date,
start_time=start_time,
end_time=end_time,
participants=zip_participants(
[tp.player_id for tp in team_players], notified_participant_ids
),
fallback_id=team_match.id,
)
for i, tp in enumerate(team_players):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
send_schedule_notification(
user_id=tp.player_id, event_type='match',
event_title=team_match.title,
event_date=event_date_str, event_time=event_time_str,
reference_id=reference_id,
)
flash(f'Team match "{title}" scheduled successfully!', 'success')
flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
)
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -217,76 +224,65 @@ def create_match(team_id):
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash('You do not have permission to edit this match.', 'danger')
return redirect(url_for('team_matches.list_matches'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before editing matches.', 'danger')
flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
if request.method == 'POST':
team_match.title = request.form.get('title', team_match.title)
team_match.description = request.form.get('description', '') or None
team_match.opponent = request.form.get('opponent', '').strip() or None
# The three date and time fields used to be checked one at a time,
# each flashing and redirecting on its own: a form with two mistakes
# took two round trips to be told about both. One schema now, every
# problem reported at once and in place.
#
# Known limit: the re-render reads the stored record, so what was
# typed is not echoed back. Repopulating the form from the
# submission is a separate change to the template.
try:
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
date_str = request.form.get('date')
if date_str:
try:
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return redirect(url_for('team_matches.edit_match', match_id=match_id))
start_time_str = request.form.get('start_time')
if start_time_str:
try:
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
except ValueError:
pass
end_time_str = request.form.get('end_time')
if end_time_str:
try:
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
except ValueError:
pass
team_match.location = request.form.get('location', '') or None
status = request.form.get('status')
if status in ['scheduled', 'completed', 'cancelled']:
team_match.status = status
team_match.title = data['title']
team_match.description = data['description']
team_match.opponent = data['opponent']
team_match.date = data['date']
team_match.start_time = data['start_time']
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
team_match.location = data['location']
team_match.status = data['status']
db.session.commit()
flash('Match updated successfully!', 'success')
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html',
match=team_match, team=team, team_players=[])
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required
def delete_match(match_id):
"""Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash('You do not have permission to delete this match.', 'danger')
return redirect(url_for('team_matches.list_matches'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before deleting matches.', 'danger')
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match)
db.session.commit()
flash('Match deleted successfully.', 'success')
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams')
@json_endpoint
@login_required
def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for."""
@@ -296,12 +292,7 @@ def api_manageable_teams():
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
teams = coach_org_teams(current_user)
else:
return jsonify([])
@@ -309,13 +300,14 @@ def api_manageable_teams():
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(TeamMatchParticipant, participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
@@ -325,8 +317,10 @@ def toggle_presence(match_id, participant_id):
participant.is_confirmed = not participant.is_confirmed
db.session.commit()
return jsonify({
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
})
return jsonify(
{
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+355 -191
View File
@@ -3,15 +3,32 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, Team, TeamMember,
PersonalNote, TeamNote, Tryout, TeamPlayer,
Admin,
Coach,
Contract,
Manager,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
TeamMatch,
TeamNote,
TeamPlayer,
Tryout,
User,
)
from datetime import datetime
from app.permissions import visible_org_teams
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -22,34 +39,35 @@ def list_teams():
"""List all organization teams visible to the current user."""
can_manage = current_user.can_manage_teams()
if isinstance(current_user, Admin):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, Manager):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=current_user.id),
OrgTeam.manager_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, Player):
flash('Use My Team(s) to view your teams.', 'info')
if isinstance(current_user, Player):
flash(_('Use My Team(s) to view your teams.'), 'info')
return redirect(url_for('teams.my_teams'))
else:
flash('You do not have permission to view teams.', 'danger')
if not isinstance(current_user, (Admin, Coach, Manager)):
flash(_('You do not have permission to view teams.'), 'danger')
return redirect(url_for('main.dashboard'))
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
all_players = User.query.filter_by(role='player').order_by(User.username).all()
return render_template('pages/teams.html', teams=teams, coaches=coaches,
managers=managers, all_players=all_players, can_manage=can_manage)
teams = visible_org_teams(current_user)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
# is_active_account, like the two queries above it. Without it the "add
# player" select offered accounts that had been deactivated, and
# add_player accepted them.
all_players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
return render_template(
'pages/teams.html',
teams=teams,
coaches=coaches,
managers=managers,
all_players=all_players,
can_manage=can_manage,
)
@teams_bp.route('/my-teams')
@@ -57,84 +75,146 @@ def list_teams():
def my_teams():
"""View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player):
flash('This page is for players.', 'info')
flash(_('This page is for players.'), 'info')
return redirect(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams()
now = datetime.utcnow()
now = utc_now_naive()
team_data = []
for org_team in player_teams:
matches = TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled',
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
matches = (
TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled',
)
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
.all()
)
matches_data = []
for tm in matches:
confirmed, total = tm.get_confirmed_count()
participant = TeamMatchParticipant.query.filter_by(
team_match_id=tm.id, player_id=current_user.id,
team_match_id=tm.id,
player_id=current_user.id,
).first()
matches_data.append({
'match': tm,
'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed, 'total_count': total,
})
matches_data.append(
{
'match': tm,
'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed,
'total_count': total,
}
)
team_data.append({
'team': org_team, 'matches': matches_data,
'coaches': org_team.get_coaches(),
'managers': org_team.get_managers(),
})
team_data.append(
{
'team': org_team,
'matches': matches_data,
'coaches': org_team.get_coaches(),
'managers': org_team.get_managers(),
}
)
return render_template('pages/my_teams.html', team_data=team_data, now=now)
def _posted(schema):
"""Load a form through `schema`, or None when it will not load.
The five assignment routes below each answer a bad field with their own
flash and a redirect to the same page, so a shared "it did not validate"
return is enough; the field-level message is flashed on the way out.
"""
try:
return schema.load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return None
def _assignable(user, expected_class):
"""Whether this account may be given a role on a team.
Deactivated accounts were offered by the selects and accepted by the
routes. `is_active_account` is what stops someone logging in — a person
who has left the club — so putting them on a roster contradicts the one
control that says they are gone. The listings filtered it for coaches and
managers and not for players, two lines apart, which is how it went
unnoticed.
"""
return isinstance(user, expected_class) and bool(user.is_active_account)
def _staff_member(user_id, expected_class):
"""The user behind an id, only if they may hold the role being assigned.
Returns None for a missing id, an unknown id, an account of the wrong
role, or a deactivated one. The role check is the point (SEC-16): the id
comes from a `<select>` the browser rendered, so it is a value the client
chooses, and nothing checked it in two of the three places that used it.
A forged submission could therefore list a player among a team's coaches
— the same defect wave G fixed in `tryouts.py`, left standing here.
Defers to `_assignable` rather than repeating `isinstance`: two functions
in one file answering "may this account take this role" differently is
the shape of every defect this module has had.
Args:
user_id: Already an int or None, thanks to the schema.
expected_class: Coach or Manager.
Returns:
User | None: The account, when it may take the role.
"""
if not user_id:
return None
user = db.session.get(User, user_id)
return user if user and _assignable(user, expected_class) else None
@teams_bp.route('/create', methods=['POST'])
@login_required
def create_team():
"""Create a new organization team."""
if not current_user.can_manage_teams():
flash('You do not have permission to create teams.', 'danger')
flash(_('You do not have permission to create teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
name = request.form.get('name')
coach_id = request.form.get('coach_id')
manager_id = request.form.get('manager_id')
if not name:
flash('Team name is required.', 'danger')
try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
existing = OrgTeam.query.filter_by(name=name).first()
if existing:
flash(f'Team "{name}" already exists.', 'danger')
name = data['name']
if OrgTeam.query.filter_by(name=name).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams'))
coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager)
team = OrgTeam(
name=name,
coach_id=int(coach_id) if coach_id else None,
manager_id=int(manager_id) if manager_id else None,
coach_id=coach.id if coach else None,
manager_id=manager.id if manager else None,
created_by=current_user.id,
)
db.session.add(team)
db.session.flush()
if coach_id:
coach_user = User.query.get(int(coach_id))
if coach_user:
team.coaches.append(coach_user)
if manager_id:
manager_user = User.query.get(int(manager_id))
if manager_user:
team.managers.append(manager_user)
if coach:
team.coaches.append(coach)
if manager:
team.managers.append(manager)
db.session.commit()
flash(f'Team "{name}" created successfully!', 'success')
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams'))
@@ -142,85 +222,112 @@ def create_team():
@login_required
def edit_team(team_id):
"""Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to edit this team.', 'danger')
flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
name = request.form.get('name')
coach_id = request.form.get('coach_id')
manager_id = request.form.get('manager_id')
if not name:
flash('Team name is required.', 'danger')
try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first()
if existing:
flash(f'Team "{name}" already exists.', 'danger')
name = data['name']
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams'))
if request.form.get('sync_staff') == '1':
coach_ids = request.form.getlist('coach_ids')
manager_ids = request.form.getlist('manager_ids')
team.name = name
team.coaches = []
for cid in coach_ids:
if cid and cid.strip():
coach_user = User.query.get(int(cid))
if coach_user and isinstance(coach_user, Coach):
team.coaches.append(coach_user)
if data['sync_staff'] == '1':
team.coaches = [
user for user in (_staff_member(cid, Coach) for cid in data['coach_ids']) if user
]
coach_list = team.coaches.all()
team.coach_id = coach_list[0].id if coach_list else None
team.managers = []
for mid in manager_ids:
if mid and mid.strip():
manager_user = User.query.get(int(mid))
if manager_user and isinstance(manager_user, Manager):
team.managers.append(manager_user)
team.managers = [
user for user in (_staff_member(mid, Manager) for mid in data['manager_ids']) if user
]
manager_list = team.managers.all()
team.manager_id = manager_list[0].id if manager_list else None
else:
team.coach_id = int(coach_id) if coach_id else None
team.manager_id = int(manager_id) if manager_id else None
# This branch never checked the role, while the one above did — the
# same file disagreeing with itself (SEC-16). _staff_member is the
# single answer now.
coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager)
if coach_id:
coach_user = User.query.get(int(coach_id))
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
team.coaches.append(coach_user)
if manager_id:
manager_user = User.query.get(int(manager_id))
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
team.managers.append(manager_user)
team.coach_id = coach.id if coach else None
team.manager_id = manager.id if manager else None
if coach and not team.coaches.filter_by(id=coach.id).first():
team.coaches.append(coach)
if manager and not team.managers.filter_by(id=manager.id).first():
team.managers.append(manager)
db.session.commit()
flash(f'Team "{name}" updated successfully!', 'success')
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(team_id):
"""Delete an organization team."""
if not current_user.can_manage_teams():
flash('You do not have permission to delete teams.', 'danger')
"""Delete an organization team.
Two checks, not one, and not the one the audit recommended (SEC-AUTHZ-006).
The constat was right about the inconsistency: this was the only team
operation guarded by the global `can_manage_teams()` while the other
nine use `can_manage_this_org_team(team)`. It was wrong about the fix.
Simply swapping to the per-object check **widens** access — `Coach`
returns False for the global capability and True for its own teams, so
the swap would hand every coach the power to delete the team they coach,
along with its notes and its match history. The constat reasoned about
`Manager`, where both return True, and missed the role where they differ.
Requiring both preserves today's behaviour exactly (admins and managers
yes, coaches no) and still closes the debt the constat was about: the
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
deletion narrows with it instead of staying the one way in.
"""
team = db.get_or_404(OrgTeam, team_id)
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
team = OrgTeam.query.get_or_404(team_id)
name = team.name
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
for t in tryouts:
t.target_org_team_id = None
db.session.commit()
# One transaction. This used to commit three times, so a failure at the
# third step left the tryouts detached and the players removed without
# the team being deleted — an inconsistent state nothing could undo.
#
# TeamNote.org_team_id and TeamMatch.org_team_id are NOT NULL, and were
# not handled at all: deleting a team that had ever been used raised
# IntegrityError. Contract.team_id and OneOnOneRequest.org_team_id are
# nullable, and the rows outlive the team, so they are only detached.
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
db.session.commit()
# Entities that only make sense as part of the team.
TeamNote.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
for team_match in TeamMatch.query.filter_by(org_team_id=team_id).all():
db.session.delete(team_match) # participants follow by cascade
TeamPlayer.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
# Entities that survive it.
Tryout.query.filter_by(target_org_team_id=team_id).update(
{'target_org_team_id': None}, synchronize_session=False
)
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
{'org_team_id': None}, synchronize_session=False
)
db.session.delete(team)
db.session.commit()
flash(f'Team "{name}" deleted successfully.', 'success')
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
return redirect(url_for('teams.list_teams'))
@@ -228,30 +335,42 @@ def delete_team(team_id):
@login_required
def add_coach(team_id):
"""Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id')
if not coach_id:
flash('Please select a coach.', 'danger')
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['coach_id']:
flash(_('Please select a coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach = User.query.get_or_404(int(coach_id))
if not isinstance(coach, Coach):
flash('Only coaches can be assigned as coach.', 'danger')
coach = db.session.get(User, data['coach_id'])
if not coach or not _assignable(coach, Coach):
flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first():
flash(f'{coach.username} is already a coach of {team.name}.', 'info')
flash(
_(
'%(username)s is already a coach of %(name)s.',
username=coach.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams'))
team.coaches.append(coach)
if not team.coach_id:
team.coach_id = coach.id
db.session.commit()
flash(f'{coach.username} added as coach of {team.name}.', 'success')
flash(
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@@ -259,30 +378,42 @@ def add_coach(team_id):
@login_required
def add_manager(team_id):
"""Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id')
if not manager_id:
flash('Please select a manager.', 'danger')
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['manager_id']:
flash(_('Please select a manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager = User.query.get_or_404(int(manager_id))
if not isinstance(manager, Manager):
flash('Only managers can be assigned as manager.', 'danger')
manager = db.session.get(User, data['manager_id'])
if not manager or not _assignable(manager, Manager):
flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first():
flash(f'{manager.username} is already a manager of {team.name}.', 'info')
flash(
_(
'%(username)s is already a manager of %(name)s.',
username=manager.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams'))
team.managers.append(manager)
if not team.manager_id:
team.manager_id = manager.id
db.session.commit()
flash(f'{manager.username} added as manager of {team.name}.', 'success')
flash(
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@@ -290,14 +421,17 @@ def add_manager(team_id):
@login_required
def remove_coach(team_id):
"""Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id')
if coach_id:
coach = User.query.get(int(coach_id))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if data['coach_id']:
coach = db.session.get(User, data['coach_id'])
if coach and team.coaches.filter_by(id=coach.id).first():
team.coaches.remove(coach)
if team.coach_id == coach.id:
@@ -307,7 +441,7 @@ def remove_coach(team_id):
team.coach_id = None
db.session.commit()
flash(f'Coach removed from {team.name}.', 'success')
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
@@ -315,14 +449,17 @@ def remove_coach(team_id):
@login_required
def remove_manager(team_id):
"""Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id')
if manager_id:
manager = User.query.get(int(manager_id))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if data['manager_id']:
manager = db.session.get(User, data['manager_id'])
if manager and team.managers.filter_by(id=manager.id).first():
team.managers.remove(manager)
if team.manager_id == manager.id:
@@ -332,7 +469,7 @@ def remove_manager(team_id):
team.manager_id = None
db.session.commit()
flash(f'Manager removed from {team.name}.', 'success')
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
@@ -340,31 +477,36 @@ def remove_manager(team_id):
@login_required
def add_player(team_id):
"""Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player_id = request.form.get('player_id')
status = request.form.get('status', 'starter')
if not player_id:
flash('Please select a player.', 'danger')
data = _posted(TeamPlayerSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(int(player_id))
if not isinstance(player, Player):
flash('Can only assign players to teams.', 'danger')
status = data['status']
player = db.session.get(User, data['player_id'])
if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing:
flash(f'{player.username} is already on {team.name}.', 'info')
flash(
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info',
)
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
db.session.add(tp)
db.session.commit()
flash(f'{player.username} added to {team.name}!', 'success')
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
@@ -372,28 +514,35 @@ def add_player(team_id):
@login_required
def remove_player(team_id, player_id):
"""Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(f'{player.username} is not on {team.name}.', 'danger')
flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
db.session.delete(tp)
db.session.commit()
flash(f'{player.username} removed from {team.name}.', 'success')
flash(
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403
@@ -403,27 +552,35 @@ def toggle_player_status(team_id, player_id):
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
db.session.commit()
return jsonify({
'success': True, 'player_id': player_id,
'new_status': tp.status, 'player_name': tp.player.username,
})
return jsonify(
{
'success': True,
'player_id': player_id,
'new_status': tp.status,
'player_name': tp.player.username,
}
)
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required
def add_team_note(team_id):
"""Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger')
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash('Team notes added successfully!', 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams'))
@@ -431,25 +588,32 @@ def add_team_note(team_id):
@login_required
def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger')
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash('Can only add notes for players.', 'danger')
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(f'{player.username} is not on {team.name}.', 'danger')
flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(f'Note added for {player.username}!', 'success')
return redirect(url_for('teams.list_teams'))
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams'))
+424 -276
View File
@@ -4,17 +4,42 @@ This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from sqlalchemy import select
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, Match, MatchParticipant,
ESPORT_GAMES, GAME_POSITIONS,
AppSettings,
ESPORT_GAMES,
GAME_POSITIONS,
Admin,
Coach,
Evaluation,
Manager,
Match,
MatchParticipant,
OrgTeam,
PersonalNote,
Player,
Scout,
Team,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from app.time_utils import utc_now_naive
from app.validators import (
PlayerSelectionSchema,
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
from datetime import datetime
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -24,15 +49,67 @@ def can_manage():
return isinstance(current_user, (Admin, Manager))
def tryouts_locked():
"""Return True when tryouts are globally closed to coaches/managers.
def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open.
def coaches_from_ids(coach_ids):
"""The coach accounts behind these ids.
Filtered by role, which the previous `User.id.in_(...)` was not: the form
posts a list of ids and nothing stopped a hand-made submission from
naming a player, who then appeared as a coach of the tryout and inherited
every permission that comes with it.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('tryouts_open', default=True)
if not coach_ids:
return []
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def managers_from_ids(manager_ids):
"""The manager accounts behind these ids, filtered by role."""
if not manager_ids:
return []
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
def _users_by_id(user_ids):
"""Load these users in one query, keyed by id.
Replaces the `User.query.get()`-inside-a-loop that view_tryout used in
three separate places (PERF-001). Missing ids are simply absent from
the result, which is what a per-row get() returning None amounted to.
Args:
user_ids: Iterable of primary keys, may repeat and may be empty.
Returns:
dict[int, User]
"""
wanted = {user_id for user_id in user_ids if user_id}
if not wanted:
return {}
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
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('')
@@ -43,7 +120,7 @@ def list_tryouts():
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
"""
tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive())
@tryouts_bp.route('/create', methods=['GET', 'POST'])
@@ -51,222 +128,232 @@ def list_tryouts():
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if not can_manage():
flash('You do not have permission to create tryouts.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
def rerender():
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid start date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
data = TryoutSchema().load(tryout_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
tryout = Tryout(
title=title, description=description, game=game, date=date_obj,
end_date=end_date_obj,
location=location,
max_players=int(max_players) if max_players else None,
created_by=current_user.id, status='upcoming',
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
manager_id=int(manager_id) if manager_id else None,
title=data['title'],
description=data['description'],
game=data['game'],
date=data['date'],
end_date=data['end_date'],
location=data['location'],
max_players=data['max_players'],
created_by=current_user.id,
status='upcoming',
target_org_team_id=data['target_org_team_id'],
)
db.session.add(tryout)
db.session.flush()
# Assign coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
tryout.coaches = coaches_from_ids(data['coach_ids'])
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit()
flash('Tryout created successfully!', 'success')
flash(_('Tryout created successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return rerender()
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this tryout.', 'danger')
flash(_('You do not have permission to edit this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if 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 tryout.is_ended:
flash('This tryout has ended and can no longer be modified.', 'danger')
flash(_('This tryout has ended and can no longer be modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
)
def rerender():
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid start date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
data = TryoutSchema().load(tryout_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout.title = data['title']
tryout.description = data['description']
tryout.game = data['game']
tryout.date = data['date']
tryout.end_date = data['end_date']
tryout.location = data['location']
tryout.max_players = data['max_players']
tryout.target_org_team_id = data['target_org_team_id']
tryout.title = title
tryout.description = description
tryout.game = game
tryout.date = date_obj
tryout.end_date = end_date_obj
tryout.location = location
tryout.max_players = int(max_players) if max_players else None
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
tryout.manager_id = int(manager_id) if manager_id else None
# Update coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
else:
tryout.coaches = []
# Only update staff lists when the form explicitly sends them.
# An absent checkbox group (all unchecked or JS failed) means
# \"don't change\", not \"remove everyone\".
if 'coach_ids' in request.form:
tryout.coaches = coaches_from_ids(data['coach_ids'])
if 'manager_ids' in request.form:
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit()
flash('Tryout updated successfully!', 'success')
flash(_('Tryout updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return rerender()
@tryouts_bp.route('/<int:tryout_id>')
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = False
if isinstance(current_user, Admin):
can_view = True
elif isinstance(current_user, Manager):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first() is not None
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
).first()
is not None
)
player_in_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view = is_registered or player_in_match
elif isinstance(current_user, Scout):
can_view = True
if not can_view:
flash('You do not have permission to view this tryout.', 'danger')
flash(_('You do not have permission to view this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Everything below used to run one query per row (PERF-001): one
# User.query.get() per registration, one Evaluation lookup per player,
# one TeamMember query per team and one more User.query.get() per
# member. Thirty registrants and four teams put this page well past a
# hundred round trips, on unindexed columns.
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
registered_player_ids = [r.player_id for r in registrations if r.player_id]
players_by_id = _users_by_id(registered_player_ids)
registered_players = [
players_by_id[player_id]
for player_id in registered_player_ids
if player_id in players_by_id
]
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
player_eval_status = {}
if current_user.can_evaluate():
for p in registered_players:
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
).first()
player_eval_status[p.id] = existing is not None
evaluated_by_me = {
row.player_id
for row in evaluations
if row.evaluator_id == current_user.id and row.player_id
}
player_eval_status = {p.id: p.id in evaluated_by_me for p in registered_players}
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
teams = Team.query.filter_by(tryout_id=tryout_id).all()
team_data = []
for team in teams:
members = TeamMember.query.filter_by(team_id=team.id).all()
team_data.append({
'team': team,
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
for m in members],
})
team_ids = [team.id for team in teams]
members_by_team = {}
if team_ids:
member_rows = TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).all()
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows:
members_by_team.setdefault(row.team_id, []).append(
{'player': member_players.get(row.player_id), 'position': row.position}
)
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
can_edit = current_user.can_manage_this_tryout(tryout)
can_view_calendar = can_edit
if isinstance(current_user, Player):
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).first() is not None
player_in_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view_calendar = is_registered or player_in_match
all_players = None
if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all()
# is_active_account, like the manager and coach queries in this same
# module. Offering a deactivated account in a roster select
# contradicts the one control that says the person has left.
all_players = (
User.query.filter_by(role='player', is_active_account=True)
.order_by(User.username)
.all()
)
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
matches = (
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
)
match_data = []
for match in matches:
all_participants = list(match.participants.all())
@@ -276,78 +363,114 @@ def view_tryout(tryout_id):
player_presence = []
for p in all_participants:
if p.player:
player_presence.append({
'participant_id': p.id, 'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
})
player_presence.append(
{
'participant_id': p.id,
'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
}
)
if match.match_type == 'team_vs_team':
participants = {
'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [{'name': m.player.username, 'position': m.position}
for m in match.team1.members.all()] if match.team1 else [],
'team2_players': [{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()] if match.team2 else [],
'team1_players': [
{'name': m.player.username, 'position': m.position}
for m in match.team1.members.all()
]
if match.team1
else [],
'team2_players': [
{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()
]
if match.team2
else [],
}
elif match.match_type == 'player_vs_player':
team1_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=1).all() if p.player]
team2_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=2).all() if p.player]
# Filtered from the list already in hand. Asking the dynamic
# relationship again cost two more round trips per match for
# rows that were loaded a dozen lines above.
team1_players = [
{'name': p.player.username, 'position': p.position}
for p in all_participants
if p.team_side == 1 and p.player
]
team2_players = [
{'name': p.player.username, 'position': p.position}
for p in all_participants
if p.team_side == 2 and p.player
]
participants = {
'team1': 'Team 1', 'team2': 'Team 2',
'team1_players': team1_players, 'team2_players': team2_players,
'team1': 'Team 1',
'team2': 'Team 2',
'team1_players': team1_players,
'team2_players': team2_players,
}
else:
participants = [p.player.username for p in match.participants.all()]
match_data.append({
'match': match, 'participants': participants,
'confirmed_count': confirmed_count, 'total_count': total_count,
'player_presence': player_presence,
})
match_data.append(
{
'match': match,
'participants': participants,
'confirmed_count': confirmed_count,
'total_count': total_count,
'player_presence': player_presence,
}
)
return render_template('pages/view_tryout.html',
tryout=tryout, registered_players=registered_players,
evaluations=evaluations, player_eval_status=player_eval_status,
is_registered=is_registered, registrations=registrations,
team_data=team_data, can_edit=can_edit,
can_view_calendar=can_view_calendar, all_players=all_players,
matches=matches, match_data=match_data,
game_positions=GAME_POSITIONS, now=datetime.utcnow())
return render_template(
'pages/view_tryout.html',
tryout=tryout,
registered_players=registered_players,
evaluations=evaluations,
player_eval_status=player_eval_status,
is_registered=is_registered,
registrations=registrations,
team_data=team_data,
can_edit=can_edit,
can_view_calendar=can_view_calendar,
all_players=all_players,
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=utc_now_naive(),
)
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@login_required
def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player):
flash('Only players can register for tryouts.', 'danger')
flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']:
flash('This tryout is not accepting registrations.', 'danger')
flash(_('This tryout is not accepting registrations.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first()
tryout_id=tryout_id, player_id=current_user.id
).first()
if existing:
flash('You are already registered for this tryout.', 'info')
flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash('This tryout is full.', 'danger')
flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration)
db.session.commit()
flash('Successfully registered for tryout!', 'success')
flash(_('Successfully registered for tryout!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -355,18 +478,19 @@ def register_for_tryout(tryout_id):
@login_required
def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
try:
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']:
tryout.status = new_status
db.session.commit()
flash(f'Tryout status updated to {new_status}.', 'success')
tryout.status = data['status']
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -374,22 +498,23 @@ def update_status(tryout_id):
@login_required
def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first_or_404()
try:
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first_or_404()
new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
db.session.commit()
flash('Registration status updated.', 'success')
registration.status = data['status']
db.session.commit()
flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -397,39 +522,46 @@ def update_registration_status(tryout_id, player_id):
@login_required
def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = locked_tryout_or_404(tryout_id)
if 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))
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))
player_id = request.form.get('player_id')
if not player_id:
flash('Please select a player.', 'danger')
try:
data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(int(player_id))
if not isinstance(player, Player):
flash('Can only register players.', 'danger')
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player.id).first()
# Same two checks as the team roster (SEC-16): the right role, and an
# account that has not been deactivated. The select this comes from now
# filters both, but the select is not the control.
player = db.session.get(User, data['player_id'])
if not player or not isinstance(player, Player) or not player.is_active_account:
flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
if existing:
flash(f'{player.username} is already registered for this tryout.', 'info')
flash(
_('%(username)s is already registered for this tryout.', username=player.username),
'info',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash('This tryout is full.', 'danger')
flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration)
db.session.commit()
flash(f'{player.username} registered for tryout!', 'success')
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -437,19 +569,16 @@ def register_player(tryout_id):
@login_required
def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
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))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first()
tryout_id=tryout_id, player_id=player_id
).first()
if registration:
db.session.delete(registration)
@@ -468,7 +597,7 @@ def remove_player(tryout_id, player_id):
).delete(synchronize_session=False)
db.session.commit()
flash(f'{player.username} removed from tryout.', 'success')
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -476,21 +605,21 @@ def remove_player(tryout_id, player_id):
@login_required
def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if 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))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name')
if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(f'Team "{team_name}" created!', 'success')
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -498,26 +627,42 @@ def create_team(tryout_id):
@login_required
def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team."""
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if 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))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
# The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to
# tryout B, since only the tryout was authorised.
if team.tryout_id != tryout_id:
abort(404)
try:
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_id']
# Only players registered for this tryout may be placed on its teams.
is_registered = (
TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
is not None
)
if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = request.form.get('player_id')
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
flash('Player is already on this team.', 'info')
flash(_('Player is already on this team.'), 'info')
else:
member = TeamMember(team_id=team_id, player_id=int(player_id), position=position)
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
db.session.add(member)
db.session.commit()
flash('Player added to team!', 'success')
flash(_('Player added to team!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -525,40 +670,43 @@ def add_to_team(tryout_id, team_id):
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if 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'))
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))
# Delete match participants for all matches in this tryout
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
# Personal notes outlive the tryout: they are a coach's observations
# about a player, not tryout data. Only their context links are cleared.
# Missing this step made the deletion fail on the foreign keys below.
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
{'tryout_id': None}, synchronize_session=False
)
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids)
).delete(synchronize_session=False)
# Delete matches
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
{'match_id': None}, synchronize_session=False
)
if team_ids:
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
{'team_id': None}, synchronize_session=False
)
if match_ids:
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
synchronize_session=False
)
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
# Delete team members for all teams in this tryout
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
# Delete teams
TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
# Delete registrations
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
# Delete evaluations
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
db.session.delete(tryout)
db.session.commit()
flash('Tryout deleted successfully.', 'success')
return redirect(url_for('tryouts.list_tryouts'))
flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts'))
+22 -17
View File
@@ -408,9 +408,18 @@ def add_disponibility():
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
@login_required
def add_disponibilities_bulk():
"""Add multiple disponibility blocks at once."""
"""Replace the player's disponibilities with the submitted slots.
Performs a full replace (delete existing + insert submitted) so that
deselected slots are correctly removed, mirroring the coach availability
flow. This keeps the auto-save idempotent and correct.
"""
data = request.get_json()
slots = data.get('slots', [])
slots = data.get('slots', []) if data else []
# Clear existing disponibilities for the current player.
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = []
for slot in slots:
day_of_week = slot.get('day_of_week')
@@ -423,21 +432,17 @@ def add_disponibilities_bulk():
continue
end_time = add_30_minutes(start_time)
existing = PlayerDisponibility.query.filter_by(
player_id=current_user.id, day_of_week=day_of_week, start_time=start_time,
).first()
if not existing:
disponibility = PlayerDisponibility(
player_id=current_user.id, day_of_week=day_of_week,
start_time=start_time, end_time=end_time,
)
db.session.add(disponibility)
db.session.flush()
created.append({
'id': disponibility.id, 'day_of_week': disponibility.day_of_week,
'day_name': DAY_NAMES[disponibility.day_of_week],
'start_time': disponibility.start_time.strftime('%H:%M'),
})
disponibility = PlayerDisponibility(
player_id=current_user.id, day_of_week=day_of_week,
start_time=start_time, end_time=end_time,
)
db.session.add(disponibility)
db.session.flush()
created.append({
'id': disponibility.id, 'day_of_week': disponibility.day_of_week,
'day_name': DAY_NAMES[disponibility.day_of_week],
'start_time': disponibility.start_time.strftime('%H:%M'),
})
db.session.commit()
return jsonify({'success': True, 'created': created})
+36
View File
@@ -0,0 +1,36 @@
"""User-facing routes, split by subject.
Was a single 1 699-line module covering account administration, profiles,
availability calendars, contracts, one-on-one sessions and coach notes
six subjects that shared nothing but a URL prefix (ARCH-004).
Importing this package registers every route on `users_bp`, so app.py
keeps its single `from app.routes.users import users_bp`. The blueprint
itself lives in blueprint.py to keep that import one-directional.
"""
# Imported for their side effect: each module attaches its routes to
# users_bp. Order does not matter; none of them import each other.
from app.routes.users import (
accounts, # noqa: F401,E402
availability, # noqa: F401,E402
contracts, # noqa: F401,E402
notes, # noqa: F401,E402
one_on_one, # noqa: F401,E402
profile, # noqa: F401,E402
)
# Re-exported because tests and other modules reach for them by name.
from app.routes.users._shared import ( # noqa: F401,E402
ALLOWED_CONTRACT_EXTENSIONS,
ALLOWED_SIGNED_EXTENSIONS,
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
__all__ = [
'ALLOWED_CONTRACT_EXTENSIONS',
'ALLOWED_SIGNED_EXTENSIONS',
'pdf_upload_error',
'users_bp',
]
+89
View File
@@ -0,0 +1,89 @@
"""Helpers used by more than one route module in this package.
Nothing here touches the blueprint: these are plain functions, so a test
can call them with a request context and nothing else.
"""
from flask_babel import gettext as _
from app.extensions import db
# Re-exported: these two moved to app/forms.py once the match and tryout
# routes needed them as well (ARCH-005). Importing them from here still
# works, so the thirty call sites in this package did not have to move.
from app.forms import flash_validation_errors, form_payload # noqa: F401
from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
#: Every PDF starts with this. Checking the name alone accepted a file
#: called anything.pdf holding anything at all.
PDF_SIGNATURE = b'%PDF-'
#: USER_TYPE → model class, for create_user.
USER_CLASS_MAP = {
'admin': Admin,
'manager': Manager,
'coach': Coach,
'player': Player,
'scout': Scout,
}
def pdf_upload_error(file, allowed_extensions):
"""Why this upload is not an acceptable PDF, or None if it is.
upload_signed_contract checked nothing beyond a non-empty filename
ALLOWED_SIGNED_EXTENSIONS was declared and never read so a player
could put an arbitrary file on the server under a name the application
later hands back for download (SEC-021).
Args:
file: The uploaded FileStorage, or None.
allowed_extensions: Extensions to accept, lowercase and without dot.
Returns:
str | None: A message to flash, or None when the file is acceptable.
"""
if file is None or not file.filename:
return _('No file selected.')
stem, dot, extension = file.filename.rpartition('.')
if not (stem and dot) or extension.lower() not in allowed_extensions:
return _('Only PDF files are allowed for contracts.')
head = file.stream.read(len(PDF_SIGNATURE))
file.stream.seek(0)
if head != PDF_SIGNATURE:
return _('That file is not a PDF, whatever its name says.')
return None
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user from validated dynamic form fields."""
from app.forms import form_gamertags
submitted = form_gamertags(selected_games)
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
payload = submitted.get(game)
existing = existing_gamertags.get(game)
if payload:
if existing:
existing.gamertag = payload['gamertag']
existing.platform = payload['platform']
else:
gt = UserGamertag(
user_id=user.id,
game=game,
gamertag=payload['gamertag'],
platform=payload['platform'],
)
db.session.add(gt)
elif existing:
db.session.delete(existing)
for game in existing_gamertags:
if game not in selected_games:
db.session.delete(existing_gamertags[game])
+383
View File
@@ -0,0 +1,383 @@
"""Account administration — the president's view of the user list.
Creating, editing, deleting and viewing accounts. Everything here is
admin-only except view_user, which renders a public profile.
"""
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db, hash_password
from app.logging_config import log_auth_event
from app.models import (
ESPORT_GAMES,
GAME_PLATFORMS,
USER_TYPES,
Admin,
CoachAvailability,
Contract,
Evaluation,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
PlayerDisponibility,
Team,
TeamMember,
TeamNote,
TeamPlayer,
Tryout,
TryoutRegistration,
User,
UserGamertag,
)
from app.pagination import paginate
from app.routes.users._shared import (
USER_CLASS_MAP,
flash_validation_errors,
form_payload,
update_user_gamertags,
)
from app.routes.users.blueprint import users_bp
from app.storage import discard_documents
from app.validators import CreateUserSchema, EditUserSchema
@users_bp.route('')
@login_required
def list_users():
"""List all users for management (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can manage users.'), 'danger')
return redirect(url_for('main.dashboard'))
# Ordered before paginated, and by a unique-enough key: a paginated
# query without a stable ORDER BY can show the same row twice and never
# show another (MNT-14).
page = paginate(User.query.order_by(User.role, User.username, User.id))
return render_template('pages/users.html', users=page.items, pagination=page, roles=USER_TYPES)
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_user(user_id):
"""Edit an existing user (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = db.get_or_404(User, user_id)
if request.method == 'POST':
actor_name, actor_id = current_user.username, current_user.id
def _rerender():
return render_template(
'pages/edit_user.html',
user=user,
roles=USER_TYPES,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags={
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
for gt in user.gamertags
},
)
try:
validated = EditUserSchema().load(form_payload(checkboxes=('is_active_account',)))
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
full_name = validated['full_name']
email = validated['email']
phone = validated.get('phone')
role = validated['role']
is_active = validated['is_active_account']
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
league_os_profile = validated.get('league_os_profile')
# Previously absent: the column is unique, so assigning a taken
# address surfaced as an IntegrityError, i.e. a 500.
clash = User.query.filter(User.email == email, User.id != user.id).first()
if clash:
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
discord_clash = None
if discord_user_id:
discord_clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != user.id,
).first()
if discord_clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender()
try:
update_user_gamertags(user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
role_changed = user.role != role
previous_role = user.role
if role_changed:
# Two ways to lock everyone out of administration, neither of
# which any interface can undo afterwards.
if user.id == actor_id:
flash(
_('You cannot change your own role. Ask another president to do it.'), 'danger'
)
return _rerender()
if user.role == 'admin':
remaining_admins = User.query.filter(
User.role == 'admin',
User.is_active_account.is_(True),
User.id != user.id,
).count()
if remaining_admins == 0:
flash(
_(
'This is the last active president. Promote '
'another account before changing this one.'
),
'danger',
)
return _rerender()
if role_changed:
# The role column is the polymorphic discriminator, and SQLAlchemy
# decides an instance's class when it loads it. Assigning to it
# through the ORM leaves a Player object in the identity map for a
# row that now says 'coach', so every later isinstance() check —
# which is how this application does authorisation — answers with
# the old role. Hence the statement-level UPDATE.
#
# The instance then has to be re-read. This used to call
# db.session.remove(), which throws away the whole session:
# everything the request still held was detached, current_user
# included, and the next attribute access on any of them raised
# DetachedInstanceError. Expunging the one stale instance is
# enough, and it leaves the transaction open — so the role change
# and the rest of the edit now commit together instead of the
# role landing on its own and the remaining fields failing after
# it (ARCH-008).
user_pk = user.id
db.session.execute(
db.text('UPDATE users SET role = :role WHERE id = :id'),
{'role': role, 'id': user_pk},
)
db.session.expunge(user)
user = db.session.get(User, user_pk)
user.full_name = full_name
user.email = email
user.phone = phone
user.is_active_account = is_active
user.games = ','.join(selected_games) if selected_games else None
user.discord_username = discord_username or None
user.discord_user_id = discord_user_id or None
user.league_os_profile = league_os_profile or None
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
if password:
user.password_hash = hash_password(password)
db.session.commit()
# Logged after the commit, not before: the audit trail should record
# what happened, and until this point nothing had.
if role_changed:
log_auth_event(
'account.role_changed',
actor=actor_name,
actor_id=actor_id,
target=user.username,
target_id=user.id,
previous_role=previous_role,
new_role=role,
)
if password:
log_auth_event(
'account.password_reset_by_admin',
actor=actor_name,
actor_id=actor_id,
target=user.username,
target_id=user.id,
)
log_auth_event(
'account.updated',
actor=actor_name,
actor_id=actor_id,
target=user.username,
target_id=user.id,
active=is_active,
)
flash(_('User %(username)s updated successfully!', username=user.username), 'success')
return redirect(url_for('users.list_users'))
user_gamertags = {
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in user.gamertags
}
return render_template(
'pages/edit_user.html',
user=user,
roles=USER_TYPES,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=user_gamertags,
)
@users_bp.route('/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_user(user_id):
"""Delete a user (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can delete users.'), 'danger')
return redirect(url_for('main.dashboard'))
if current_user.id == user_id:
flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users'))
user = db.get_or_404(User, user_id)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
).delete(synchronize_session=False)
PlayerDisponibility.query.filter_by(player_id=user_id).delete()
CoachAvailability.query.filter_by(coach_id=user_id).delete()
PersonalNote.query.filter(
db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id),
).delete(synchronize_session=False)
TeamNote.query.filter_by(coach_id=user_id).delete()
OneOnOneRequest.query.filter(
db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id),
).delete(synchronize_session=False)
UserGamertag.query.filter_by(user_id=user_id).delete()
# Read the file paths before the rows go: afterwards there is nothing
# left to say where the PDFs are (DATA-012). The files themselves are
# removed after the commit, below.
contract_files = [
path
for contract in Contract.query.filter_by(player_id=user_id).all()
for path in (contract.file_path, contract.signed_file_path)
]
Contract.query.filter_by(player_id=user_id).delete()
TryoutRegistration.query.filter_by(player_id=user_id).delete()
TeamPlayer.query.filter_by(player_id=user_id).delete()
TeamMember.query.filter_by(player_id=user_id).delete()
MatchParticipant.query.filter_by(player_id=user_id).delete()
OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None})
OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None})
Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id})
deleted_username, deleted_role = user.username, user.role
db.session.delete(user)
db.session.commit()
# After the commit, deliberately. A failure here leaves a file with no
# row — recoverable, and exactly what happened before this existed —
# rather than a row with no file, which is a download that 500s for ever.
discarded = discard_documents(contract_files)
log_auth_event(
'account.deleted',
actor=current_user.username,
actor_id=current_user.id,
target=deleted_username,
target_id=user_id,
role=deleted_role,
contract_files_removed=discarded,
)
flash(
_('User %(deleted_username)s has been removed.', deleted_username=deleted_username),
'success',
)
return redirect(url_for('users.list_users'))
@users_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_user():
"""Create a new user (Admin only). Uses the correct polymorphic subclass."""
if not isinstance(current_user, Admin):
flash(_('Only the president can create users.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
try:
validated = CreateUserSchema().load(request.form)
except ValidationError as err:
flash_validation_errors(err)
return render_template('pages/create_user.html', roles=USER_TYPES)
username = validated['username']
email = validated['email']
password = validated['password']
full_name = validated['full_name']
phone = validated.get('phone')
# The schema constrains role with OneOf(USER_TYPES), so the former
# manual membership check is now redundant.
role = validated['role']
if User.query.filter_by(username=username).first():
flash(_('Username already exists.'), 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
if User.query.filter_by(email=email).first():
flash(_('Email already registered.'), 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
hashed_password = hash_password(password)
user_cls = USER_CLASS_MAP.get(role, Player)
user = user_cls(
username=username,
password_hash=hashed_password,
role=role,
full_name=full_name,
email=email,
phone=phone,
)
db.session.add(user)
db.session.commit()
log_auth_event(
'account.created_by_admin',
actor=current_user.username,
actor_id=current_user.id,
target=user.username,
target_id=user.id,
role=role,
)
flash(
_('User %(full_name)s created as %(role)s!', full_name=full_name, role=role), 'success'
)
return redirect(url_for('users.list_users'))
return render_template('pages/create_user.html', roles=USER_TYPES)
@users_bp.route('/<int:user_id>/view')
@login_required
def view_user(user_id):
"""View a public profile for any user."""
user = db.get_or_404(User, user_id)
return render_template('pages/view_user.html', profile_user=user)
+270
View File
@@ -0,0 +1,270 @@
"""When people are free.
Two calendars that share a shape without sharing a purpose: a player's
weekly availability blocks, and a coach's bookable slots for one-on-one
sessions.
"""
from flask import flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import form_payload
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
from app.routes.users.blueprint import users_bp
from app.timeslots import day_name, slot_end
from app.validators import TimeSlotSchema
def _load_slots(payload_slots):
"""Validate a batch of posted slots, keeping the rejects.
Both bulk endpoints used to `continue` past anything malformed and then
answer `{'success': True}`. The client had no way to learn that a slot
had been dropped and for coach availability that is destructive, since
the route deletes every existing slot before re-adding the ones it
accepted. A payload the browser mangled could therefore wipe a coach's
bookable hours and report success (MNT-12).
Args:
payload_slots: Whatever arrived under the `slots` key.
Returns:
tuple[list[dict], list[str]]: Accepted slots, and one message per
rejected one.
"""
schema = TimeSlotSchema()
accepted, rejected = [], []
for index, raw in enumerate(payload_slots or []):
if not isinstance(raw, dict):
rejected.append(f'slot {index}: expected an object')
continue
try:
accepted.append(schema.load(raw))
except ValidationError as err:
details = '; '.join(
f'{field}: {" ".join(str(m) for m in messages)}'
for field, messages in err.messages.items()
)
rejected.append(f'slot {index}: {details}')
return accepted, rejected
@users_bp.route('/disponibilities')
@json_endpoint
@login_required
def get_disponibilities():
"""API endpoint to get all player disponibilities for scheduling."""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403
players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
result = {}
for player in players:
disponibilities = list(player.disponibilities)
result[player.id] = {
'username': player.username,
'disponibilities': [
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
for d in disponibilities
],
}
return jsonify(result)
@users_bp.route('/disponibilities/my')
@json_endpoint
@login_required
def get_my_disponibilities():
"""API endpoint for players to get their own disponibilities."""
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
result = {}
for d in disponibilities:
day = d.day_of_week
if day not in result:
result[day] = []
result[day].append(
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
)
return jsonify(result)
@users_bp.route('/disponibilities/add', methods=['POST'])
@json_endpoint
@login_required
def add_disponibility():
"""Add a disponibility block for the current player."""
try:
slot = TimeSlotSchema().load(form_payload())
except ValidationError as err:
return jsonify({'error': 'Invalid slot', 'details': err.messages}), 400
day_of_week = slot['day_of_week']
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=day_of_week,
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.commit()
return jsonify(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
'end_time': disponibility.end_time.strftime('%H:%M'),
}
)
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Replace the current player's disponibility blocks atomically."""
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = []
for slot in accepted:
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
db.session.commit()
return jsonify({'success': True, 'created': created, 'rejected': []})
@users_bp.route('/disponibilities/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_disponibilities():
"""Clear all disponibilities for the current player."""
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
@json_endpoint
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = db.get_or_404(PlayerDisponibility, disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
@json_endpoint
@login_required
def manage_coach_availability():
"""Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage availability.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
# Validate everything before deleting anything.
#
# This route replaces the coach's availability: it deleted every
# existing slot and then re-added the ones it could parse, skipping
# the rest in silence and answering `{'success': true}`. A payload
# the browser mangled therefore wiped a coach's bookable hours and
# reported success — and one-on-one requests are refused against
# exactly this table, so the coach became unbookable with nothing to
# show for it. Refusing the whole batch is the only safe answer when
# the operation is a replacement (MNT-12).
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
for slot in accepted:
start_time = slot['start_time']
db.session.add(
CoachAvailability(
coach_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
)
db.session.commit()
return jsonify({'success': True, 'saved': len(accepted)})
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id,
).all()
return render_template(
'pages/coach_availability.html', existing_availability=existing_availability
)
@users_bp.route('/coach-availability/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_coach_availability():
"""Clear all coach availability slots."""
if not isinstance(current_user, Coach):
return jsonify({'error': 'Unauthorized'}), 403
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
+16
View File
@@ -0,0 +1,16 @@
"""The `users` blueprint object, on its own.
Every route module in this package imports it from here rather than from
the package __init__, so there is no import cycle to reason about and no
ordering constraint between the modules.
The blueprint stays a *single* blueprint even though the package holds six
route modules. Splitting it into `users_accounts`, `users_contracts` and so
on would rename 137 endpoints, and every one of them is spelled out in a
`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a
file you can read, not a URL map you have to relearn.
"""
from flask import Blueprint
users_bp = Blueprint('users', __name__, url_prefix='/users')
+211
View File
@@ -0,0 +1,211 @@
"""Player contracts: upload, sign, download."""
import os
import uuid
from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from werkzeug.utils import secure_filename
from app.extensions import db
from app.models import Admin, Coach, Contract, Manager, Player, User
from app.permissions import can_manage_player_contract, coach_player_ids
from app.routes.users._shared import (
ALLOWED_CONTRACT_EXTENSIONS,
ALLOWED_SIGNED_EXTENSIONS,
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
from app.storage import CONTRACTS_DIR, document_path
from app.time_utils import utc_now_naive
from app.validators import UploadContractSchema
def manageable_players():
"""Players the current user may attach a contract to.
A coach used to see the squad of one team the first row matching the
legacy coach_id column so a coach of two teams could file a contract
for half of their players and no more, and a coach attached only by the
many-to-many relationship for none at all.
"""
if isinstance(current_user, Coach):
player_ids = coach_player_ids(current_user)
return (
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
if player_ids
else []
)
# is_active_account: a contract select that still lists people who have
# left the club invites filing paperwork against them (SEC-16).
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
@users_bp.route('/contracts')
@login_required
def list_contracts():
"""View contracts for the current user or players they manage."""
contracts = None
players = None
if isinstance(current_user, Player):
contracts = (
Contract.query.filter_by(
player_id=current_user.id,
)
.order_by(Contract.uploaded_at.desc())
.all()
)
elif isinstance(current_user, (Admin, Manager, Coach)):
players = manageable_players()
if players:
player_ids = [p.id for p in players]
contracts = (
Contract.query.filter(
Contract.player_id.in_(player_ids),
)
.order_by(Contract.uploaded_at.desc())
.all()
)
return render_template(
'pages/contracts.html',
contracts=contracts,
players=players if isinstance(current_user, (Admin, Manager, Coach)) else None,
)
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
@login_required
def upload_contract():
"""Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)):
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts'))
players = manageable_players()
if request.method == 'POST':
contract_schema = UploadContractSchema()
try:
validated = contract_schema.load(request.form)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return render_template('pages/upload_contract.html', players=players)
player_id = validated['player_id']
notes = validated.get('notes')
if not can_manage_player_contract(current_user, player_id):
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
return redirect(url_for('users.upload_contract'))
file = request.files.get('contract_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error:
flash(error, 'danger')
return redirect(url_for('users.upload_contract'))
player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None
original_filename = secure_filename(file.filename)
stored_filename = f"{uuid.uuid4()}.pdf"
# Kept relative to the document root, not absolute (see app/storage.py):
# an absolute path pins the file to the directory the process was
# started from, which is the one thing a release-directory deploy
# changes.
relative_path = os.path.join(CONTRACTS_DIR, stored_filename)
if team:
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file.save(absolute_path)
contract = Contract(
player_id=player_id,
team_id=team.id if team else None,
uploaded_by_id=current_user.id,
original_filename=original_filename,
stored_filename=stored_filename,
file_path=relative_path,
notes=notes if notes else None,
)
db.session.add(contract)
db.session.commit()
flash(
_('Contract uploaded successfully for %(username)s!', username=player.username),
'success',
)
return redirect(url_for('users.list_contracts'))
return render_template('pages/upload_contract.html', players=players)
@users_bp.route('/contracts/<int:contract_id>/upload_signed', methods=['POST'])
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = db.get_or_404(Contract, contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
flash(error, 'danger')
return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
file.save(document_path(signed_path))
contract.signed_filename = signed_filename
contract.signed_file_path = signed_path
contract.status = 'signed'
contract.signed_at = utc_now_naive()
db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
@users_bp.route('/contracts/<int:contract_id>/download')
@login_required
def download_contract(contract_id):
"""Download a contract file."""
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.file_path),
as_attachment=True,
download_name=contract.original_filename,
)
@users_bp.route('/contracts/<int:contract_id>/download_signed')
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.signed_file_path),
as_attachment=True,
download_name=contract.signed_filename,
)
+437
View File
@@ -0,0 +1,437 @@
"""Notes a coach keeps: about a team, and about individual players.
The player-facing view of the same notes lives here too my_notes since
it reads exactly what the coach routes write.
"""
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
PersonalNote,
Player,
Team,
TeamMember,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import (
coach_can_access_player,
coach_org_teams,
coach_player_ids,
coach_tryouts,
)
from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@users_bp.route('/my-notes')
@login_required
def my_notes():
"""View personal and team notes for the current player."""
if not isinstance(current_user, Player):
flash(_('This page is for players only.'), 'info')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
personal_notes = (
PersonalNote.query.filter_by(
player_id=current_user.id,
)
.order_by(PersonalNote.created_at.desc())
.all()
)
team_notes = []
if org_team:
team_notes = (
TeamNote.query.filter_by(
org_team_id=org_team.id,
)
.order_by(TeamNote.created_at.desc())
.all()
)
return render_template(
'pages/player_personal_notes.html',
org_team=org_team,
personal_notes=personal_notes,
team_notes=team_notes,
)
@users_bp.route('/notes-dashboard')
@login_required
def notes_dashboard():
"""Notes and One on One dashboard for coaches."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can access the notes dashboard.'), 'danger')
return redirect(url_for('main.dashboard'))
# The team-notes panel is still written against a single team; the
# player list is not, and used to be narrowed to one team's squad while
# the POST routes accepted every player the coach works with. The form
# offered fewer players than the handler would take.
org_teams = coach_org_teams(current_user)
org_team = org_teams[0] if org_teams else None
player_ids = coach_player_ids(current_user)
players = (
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
if player_ids
else []
)
team_notes = []
latest_team_note = None
if org_team:
team_notes = (
TeamNote.query.filter_by(
org_team_id=org_team.id,
)
.order_by(TeamNote.created_at.desc())
.all()
)
latest_team_note = team_notes[0] if team_notes else None
# A coach's own notes belong to them whether or not they hold a team;
# this list was gated on org_team and came back empty without one.
personal_notes = (
PersonalNote.query.filter_by(
coach_id=current_user.id,
)
.order_by(PersonalNote.created_at.desc())
.all()
)
one_on_one_requests = []
if player_ids:
one_on_one_requests = (
OneOnOneRequest.query.filter(OneOnOneRequest.player_id.in_(player_ids))
.order_by(OneOnOneRequest.created_at.desc())
.all()
)
# For context selectors in the form
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
# previous selector mixed the two namespaces and could either attach the
# note to an unrelated team with the same integer id or fail its FK.
# Every context list now comes from the tryouts this coach may manage.
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
tryout_ids = [tryout.id for tryout in tryouts]
matches = (
Match.query.filter(Match.tryout_id.in_(tryout_ids))
.order_by(Match.date.desc())
.limit(20)
.all()
if tryout_ids
else []
)
teams = (
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
if tryout_ids
else []
)
return render_template(
'pages/notes.html',
org_team=org_team,
players=players,
team_notes=team_notes,
latest_team_note=latest_team_note,
personal_notes=personal_notes,
one_on_one_requests=one_on_one_requests,
matches=matches,
tryouts=tryouts,
teams=teams,
)
# ---------------------------------------------------------------------------
# Manage Team Notes (POST)
# ---------------------------------------------------------------------------
@users_bp.route('/team-notes/manage', methods=['POST'])
@login_required
def manage_team_notes():
"""Create or update team notes for the coach's org team."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage team notes.'), 'danger')
return redirect(url_for('main.dashboard'))
# Same team the dashboard displays notes for, resolved the same way.
org_teams = coach_org_teams(current_user)
if not org_teams:
flash(_('You are not assigned to a team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
org_team = org_teams[0]
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
# Manage Personal Notes (POST, simple form)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/manage', methods=['POST'])
@login_required
def manage_personal_notes():
"""Create a personal note for a player (coach only, simple form)."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
# Add Personal Note (POST, full form with context)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/add', methods=['POST'])
@login_required
def add_personal_note():
"""Create a personal note for a player with optional context (coach only)."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['match_id']:
match = db.get_or_404(Match, data['match_id'])
if not current_user.can_manage_this_tryout(match.tryout):
flash(_('You cannot use that match as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
flash(_('That player did not participate in the selected match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['tryout_id']:
tryout = db.get_or_404(Tryout, data['tryout_id'])
if not current_user.can_manage_this_tryout(tryout):
flash(_('You cannot use that tryout as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
flash(_('That player is not registered for the selected tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['team_id']:
team = db.get_or_404(Team, data['team_id'])
if not current_user.can_manage_this_tryout(team.tryout):
flash(_('You cannot use that team as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
flash(_('That player is not on the selected team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=data['content'],
match_id=data['match_id'],
tryout_id=data['tryout_id'],
team_id=data['team_id'],
)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
# Add Note from Tryout context (GET + POST)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/tryout/<int:tryout_id>', methods=['GET', 'POST'])
@login_required
def add_note_from_tryout(tryout_id):
"""Add a personal note for a player in the context of a tryout."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = [r.player for r in registrations if r.player]
if request.method == 'POST':
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
player_id = data['player_id']
if data['tryout_id'] not in (None, tryout_id):
flash(_('Invalid tryout context.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=data['content'],
tryout_id=tryout_id,
)
db.session.add(note)
db.session.commit()
flash(_('Note added successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template(
'pages/add_note.html',
context_type='tryout',
tryout=tryout,
players=players,
preselected_player_id=preselected_player_id,
team_notes=[],
)
# ---------------------------------------------------------------------------
# Add Note from Match context (GET + POST)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/match/<int:match_id>', methods=['GET', 'POST'])
@login_required
def add_note_from_match(match_id):
"""Add a personal note for a player in the context of a match."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
match_obj = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
# Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
players = [p.player for p in participants if p.player]
preselected_player_id = request.args.get('player_id', type=int)
if request.method == 'POST':
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_match', match_id=match_id))
player_id = data['player_id']
if data['match_id'] not in (None, match_id):
flash(_('Invalid match context.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
flash(_('That player did not participate in this match.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=data['content'],
match_id=match_id,
)
db.session.add(note)
db.session.commit()
flash(_('Note added successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=match_obj.tryout_id))
return render_template(
'pages/add_note.html',
context_type='match',
tryout=match_obj,
match=match_obj,
players=players,
preselected_player_id=preselected_player_id,
team_notes=[],
)
+267
View File
@@ -0,0 +1,267 @@
"""One-on-one sessions between a player and their coach."""
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification
from app.time_utils import utc_now_naive
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@login_required
def one_on_one():
"""One on One request page for players."""
if not isinstance(current_user, Player):
flash(_('Only players can request One on One sessions.'), 'danger')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
# Reading org_team.coach_id directly told every player whose team lists
# its coaches through the many-to-many relationship — the newer of the
# two ways — that they had no coach, and closed the page to them.
# get_coaches() falls back to the legacy column when the list is empty.
team_coaches = org_team.get_coaches() if org_team else []
coach = team_coaches[0] if team_coaches else None
if not coach:
flash(_('You do not have a coach assigned to your team.'), 'info')
team_notes = []
if org_team:
team_notes = (
TeamNote.query.filter_by(org_team_id=org_team.id)
.order_by(TeamNote.created_at.desc())
.all()
)
personal_notes = (
PersonalNote.query.filter_by(player_id=current_user.id)
.order_by(PersonalNote.created_at.desc())
.all()
)
# Kept as model objects for the availability check below, and serialised
# separately for the page. They used to be the same list of strings,
# which is what made the check compare '9:00' with '10:00' as text.
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all() if coach else []
coach_availability = [
{
'day_of_week': av.day_of_week,
'start_time': av.start_time.strftime('%H:%M'),
'end_time': av.end_time.strftime('%H:%M'),
}
for av in availabilities
]
if request.method == 'POST':
if not coach:
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one'))
try:
data = OneOnOneRequestSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.one_on_one'))
date_obj = data['date']
start_time = data['start_time']
end_time = data['end_time']
points = data['points'] or ''
# Compared as times, not as strings. The old code parsed the three
# form fields into objects and then compared the *original strings*
# against the serialised availability — which worked only because
# both sides happened to be zero-padded HH:MM.
is_available = any(
av.day_of_week == date_obj.weekday()
and av.start_time <= start_time
and av.end_time >= end_time
for av in availabilities
)
if not is_available:
flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one'))
request_obj = OneOnOneRequest(
player_id=current_user.id,
coach_id=coach.id,
org_team_id=org_team.id if org_team else None,
date=date_obj,
start_time=start_time,
end_time=end_time,
points=points if points else None,
)
db.session.add(request_obj)
db.session.commit()
send_discord_notification(
player_name=current_user.full_name,
points=points,
date_str=date_obj.strftime('%Y-%m-%d'),
start_time_str=start_time.strftime('%H:%M'),
end_time_str=end_time.strftime('%H:%M'),
team_name=org_team.name if org_team else 'Unknown Team',
coach_name=coach.full_name,
coach_discord=coach.discord_username or '',
coach_discord_id=coach.discord_user_id or '',
request_id=request_obj.id,
)
flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
from datetime import date as date_cls
from datetime import timedelta as td
today = date_cls.today()
available_days = {av['day_of_week'] for av in coach_availability}
dates = []
for i in range(14): # Next 14 days
d = today + td(days=i)
if d.weekday() in available_days:
dates.append(
{
'value': d.strftime('%Y-%m-%d'),
'day_of_week': d.weekday(),
'display': d.strftime('%B %d, %Y (%A)'),
}
)
# Player's own One on One request history
my_requests = (
OneOnOneRequest.query.filter_by(player_id=current_user.id)
.order_by(OneOnOneRequest.created_at.desc())
.all()
)
return render_template(
'pages/one_on_one.html',
org_team=org_team,
coach=coach,
team_notes=team_notes,
personal_notes=personal_notes,
coach_availability=coach_availability,
dates=dates,
my_requests=my_requests,
)
@users_bp.route('/one-on-one/<int:request_id>/accept', methods=['POST'])
@login_required
def accept_one_on_one(request_id):
"""Coach accepts a One on One request."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = utc_now_naive()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p')
if request_obj.start_time
else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=True,
)
flash(
_(
'One on One request from %(player)s has been approved!',
player=player.username if player else 'Unknown',
),
'success',
)
return redirect(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@login_required
def reject_one_on_one(request_id):
"""Coach rejects a One on One request."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
try:
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason']
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = utc_now_naive()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
# Notify player via Discord (same message as if rejected through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p')
if request_obj.start_time
else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=False,
refusal_note=rejection_reason or None,
)
flash(
_(
'One on One request from %(player)s has been rejected.',
player=player.username if player else 'Unknown',
),
'info',
)
return redirect(url_for('users.notes_dashboard'))
+140
View File
@@ -0,0 +1,140 @@
"""The signed-in user's own profile."""
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db, hash_password
from app.logging_config import log_auth_event
from app.models import (
ESPORT_GAMES,
GAME_PLATFORMS,
Coach,
CoachAvailability,
Contract,
Player,
User,
)
from app.routes.users._shared import (
flash_validation_errors,
form_payload,
update_user_gamertags,
)
from app.routes.users.blueprint import users_bp
from app.validators import EditProfileSchema
@users_bp.route('/profile')
@login_required
def profile():
"""View the current user's profile."""
contracts = None
if isinstance(current_user, Player):
contracts = (
Contract.query.filter_by(
player_id=current_user.id,
)
.order_by(Contract.uploaded_at.desc())
.all()
)
existing_availability = None
if isinstance(current_user, Coach):
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id,
).all()
return render_template(
'pages/profile.html',
user=current_user,
contracts=contracts,
existing_availability=existing_availability,
)
@users_bp.route('/profile/edit', methods=['GET', 'POST'])
@login_required
def edit_profile():
"""Edit the current user's profile."""
if request.method == 'POST':
try:
validated = EditProfileSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
username = validated['username']
full_name = validated['full_name']
email = validated['email']
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
league_os_profile = validated.get('league_os_profile')
if username != current_user.username and User.query.filter_by(username=username).first():
flash(_('Username already taken.'), 'danger')
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
if email != current_user.email and User.query.filter_by(email=email).first():
flash(_('Email already in use.'), 'danger')
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
try:
update_user_gamertags(current_user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
current_user.username = username
current_user.full_name = full_name
current_user.email = email
current_user.phone = phone
current_user.games = ','.join(selected_games) if selected_games else None
current_user.discord_username = discord_username or None
current_user.league_os_profile = league_os_profile or None
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
if password:
current_user.password_hash = hash_password(password)
log_auth_event(
'account.password_changed', username=current_user.username, user_id=current_user.id
)
db.session.commit()
flash(_('Profile updated successfully!'), 'success')
return redirect(url_for('users.profile'))
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)