Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into dev
This commit is contained in:
+67
-45
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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})
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""User-facing routes, split by subject.
|
||||
|
||||
Was a single 1 699-line module covering account administration, profiles,
|
||||
availability calendars, contracts, one-on-one sessions and coach notes —
|
||||
six subjects that shared nothing but a URL prefix (ARCH-004).
|
||||
|
||||
Importing this package registers every route on `users_bp`, so app.py
|
||||
keeps its single `from app.routes.users import users_bp`. The blueprint
|
||||
itself lives in blueprint.py to keep that import one-directional.
|
||||
"""
|
||||
|
||||
# Imported for their side effect: each module attaches its routes to
|
||||
# users_bp. Order does not matter; none of them import each other.
|
||||
from app.routes.users import (
|
||||
accounts, # noqa: F401,E402
|
||||
availability, # noqa: F401,E402
|
||||
contracts, # noqa: F401,E402
|
||||
notes, # noqa: F401,E402
|
||||
one_on_one, # noqa: F401,E402
|
||||
profile, # noqa: F401,E402
|
||||
)
|
||||
|
||||
# Re-exported because tests and other modules reach for them by name.
|
||||
from app.routes.users._shared import ( # noqa: F401,E402
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
pdf_upload_error,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
|
||||
__all__ = [
|
||||
'ALLOWED_CONTRACT_EXTENSIONS',
|
||||
'ALLOWED_SIGNED_EXTENSIONS',
|
||||
'pdf_upload_error',
|
||||
'users_bp',
|
||||
]
|
||||
@@ -0,0 +1,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])
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -0,0 +1,16 @@
|
||||
"""The `users` blueprint object, on its own.
|
||||
|
||||
Every route module in this package imports it from here rather than from
|
||||
the package __init__, so there is no import cycle to reason about and no
|
||||
ordering constraint between the modules.
|
||||
|
||||
The blueprint stays a *single* blueprint even though the package holds six
|
||||
route modules. Splitting it into `users_accounts`, `users_contracts` and so
|
||||
on would rename 137 endpoints, and every one of them is spelled out in a
|
||||
`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a
|
||||
file you can read, not a URL map you have to relearn.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
users_bp = Blueprint('users', __name__, url_prefix='/users')
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Player contracts: upload, sign, download."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from 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,
|
||||
)
|
||||
@@ -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=[],
|
||||
)
|
||||
@@ -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'))
|
||||
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user