Merge branch 'audit/securite-maintenabilite-standards' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards
CI - Security, Lint & Tests / validate (push) Failing after 54s

This commit is contained in:
cedrick2711
2026-08-19 19:11:38 -04:00
64 changed files with 2673 additions and 1027 deletions
+112 -27
View File
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
import os
import secrets
import time
from datetime import datetime, timedelta
from datetime import timedelta
from urllib.parse import urlencode, urlparse
import requests
@@ -18,13 +18,19 @@ from flask_login import current_user, login_required, login_user, logout_user
from marshmallow import ValidationError
from app.extensions import check_password, db, hash_password, limiter
from app.forms import form_gamertags
from app.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User
from app.validators import LoginSchema, RegisterSchema
from app.time_utils import utc_now_naive
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state'
#: Whether the OAuth result should create a registration draft or relink the
#: signed-in account. Kept server-side and covered by the same signed session
#: as the anti-forgery state.
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
# Failed-attempt tracking. The tally is kept for the audit trail and for the
# cool-off marker below; it no longer refuses a correct password (SEC-018).
@@ -289,7 +295,7 @@ def login():
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
log_auth_event(
'account.throttled',
username=username,
@@ -354,6 +360,15 @@ def register():
form_data = dict(request.form)
form_data['games'] = request.form.getlist('games')
# Once Discord has authenticated the identity, neither its display
# name nor its snowflake is input data anymore. Remove any client
# copies before validation as well as before persistence: otherwise a
# forged, malformed hidden value can still make the verified flow fail.
discord_oauth = session.get('discord_oauth') or {}
if discord_oauth.get('id'):
form_data.pop('discord_username', None)
form_data.pop('discord_user_id', None)
refusal = check_registration_challenge(request.form)
if refusal is not None:
# Logged, because this is the only place abuse of the sign-up
@@ -380,8 +395,25 @@ def register():
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
try:
submitted_gamertags = form_gamertags(selected_games)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return _rerender_registration(form_data)
# The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005).
# A manual registration may still provide a display name, but never a
# Discord snowflake: that identifier is an authentication factor for
# bot reactions and must come from Discord itself.
discord_user_id = discord_oauth.get('id')
if discord_user_id:
discord_user_id = str(discord_user_id)
discord_username = (
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
)
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
@@ -392,6 +424,13 @@ def register():
flash(_('Email already registered.'), 'danger')
return _rerender_registration(form_data)
# The database constraint belongs to DB-002, after production has
# been backed up and deduplicated. Refuse new duplicates now instead
# of leaving the critical impersonation path open until then.
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender_registration(form_data)
hashed_password = hash_password(password)
user = Player(
username=username,
@@ -415,16 +454,14 @@ def register():
# Create UserGamertag records for each selected game
from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
if gamertag_value:
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
)
db.session.add(gamertag)
for game, gamertag_data in submitted_gamertags.items():
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
)
db.session.add(gamertag)
db.session.commit()
# Clear Discord OAuth data from session after successful registration
@@ -451,11 +488,15 @@ def discord_login():
Returns:
Response: Redirect to Discord authorization URL.
"""
purpose = 'profile' if current_user.is_authenticated else 'registration'
session[DISCORD_PURPOSE_KEY] = purpose
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
# raise inside the query builder rather than report a configuration error.
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
flash(_('Discord OAuth2 is not configured.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
# attacker could have the victim's browser consume an authorization code
@@ -479,16 +520,24 @@ def discord_login():
def discord_callback():
"""Handle the OAuth2 callback from Discord.
Exchanges the authorization code for an access token, then fetches
the user's profile (/users/@me) and connections (/users/@me/connections).
Results are stored in the session and the user is redirected back to
the registration form where fields will be pre-filled.
Exchanges the authorization code for an access token, then fetches the
user's profile. During registration, connected game accounts are also
loaded into server-side draft state. For a signed-in profile relink, the
verified identity is written directly without passing through a form.
Returns:
Response: Redirect to registration page.
Response: Redirect to the registration form or profile editor.
"""
# The state is consumed whatever happens next: a token is single-use, and
# leaving it in the session would allow a replay.
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
if purpose == 'profile' and current_user.is_authenticated:
return_endpoint = 'users.edit_profile'
elif purpose == 'profile':
return_endpoint = 'auth.login'
else:
return_endpoint = 'auth.register'
expected_state = session.pop(DISCORD_STATE_KEY, None)
received_state = request.args.get('state', '')
@@ -500,12 +549,12 @@ def discord_callback():
),
'danger',
)
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
code = request.args.get('code')
if not code:
flash(_('Discord authorization failed. No code received.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
# Exchange the authorization code for an access token
token_data = {
@@ -529,11 +578,11 @@ def discord_callback():
access_token = token_json.get('access_token')
except requests.RequestException:
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
if not access_token:
flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
auth_headers = {'Authorization': f'Bearer {access_token}'}
@@ -548,7 +597,43 @@ def discord_callback():
user_data = user_response.json()
except requests.RequestException:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
discord_user_id = user_data.get('id')
try:
if not discord_user_id:
raise ValidationError('missing Discord user id')
discord_user_id = str(discord_user_id)
validate_discord_user_id(discord_user_id)
except ValidationError:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
if purpose == 'profile':
# If the session expired while Discord was open, do not turn a profile
# relink into registration state for an anonymous browser.
if not current_user.is_authenticated:
flash(_('Please log in to connect your Discord account.'), 'danger')
return redirect(url_for('auth.login'))
clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != current_user.id,
).first()
if clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return redirect(url_for('users.edit_profile'))
current_user.discord_user_id = discord_user_id
current_user.discord_username = user_data.get('username') or None
db.session.commit()
log_auth_event(
'account.discord_linked',
username=current_user.username,
user_id=current_user.id,
)
flash(_('Discord account connected!'), 'success')
return redirect(url_for('users.edit_profile'))
# Fetch the user's connected gaming accounts
connections = []
@@ -587,7 +672,7 @@ def discord_callback():
# Store in session for the registration form to use
session['discord_oauth'] = {
'id': user_data.get('id'),
'id': discord_user_id,
'username': user_data.get('username'),
'avatar': user_data.get('avatar'),
'gamertag_suggestions': gamertag_suggestions,
+30 -41
View File
@@ -35,32 +35,12 @@ 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 compute_overall(scores):
"""Average the non-None scores, or return None if there are none."""
valid = [s for s in scores if s is not None]
return sum(valid) / len(valid) if valid else None
def _apply_evaluation(evaluation, scores, comments, position):
"""Write validated scores/comments/position onto an Evaluation instance."""
for field_name, _ in EVALUATION_CRITERIA:
setattr(evaluation, field_name, scores[field_name])
evaluation.overall_score = compute_overall(list(scores.values()))
evaluation.comments = comments
evaluation.position_recommendation = position
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('')
@@ -121,9 +101,10 @@ def list_evaluations():
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in avg_scores)
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
p = players_by_id.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p,
@@ -162,7 +143,7 @@ def evaluate_player(tryout_id, player_id):
flash(_('You do not have permission to evaluate players.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -178,7 +159,7 @@ def evaluate_player(tryout_id, player_id):
flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -196,8 +177,10 @@ def evaluate_player(tryout_id, player_id):
tryout_id=tryout_id,
player_id=player_id,
).all()
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
evaluators = [
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
{'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e}
for e in all_evaluations
]
return render_template(
@@ -246,22 +229,28 @@ def players_to_evaluate(tryout_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=p.id,
evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
players_by_id = _users_by_id(reg.player_id for reg in registrations)
evaluated_player_ids = {
player_id
for (player_id,) in db.session.query(Evaluation.player_id)
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
.all()
}
players = [
{
'player': player,
'evaluated': player.id in evaluated_player_ids,
'registration': registration,
}
for registration in registrations
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
]
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
+7 -9
View File
@@ -223,20 +223,18 @@ def dashboard():
elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = (
top_rows = (
db.session.query(
Evaluation.player_id,
User,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.order_by(func.avg(Evaluation.overall_score).desc())
.join(Evaluation, Evaluation.player_id == User.id)
.filter(User.role == 'player')
.group_by(User.id)
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
.limit(5)
.all()
)
stats['top_players'] = []
for row in stats['avg_scores']:
p = User.query.get(row.player_id)
if p:
stats['top_players'].append((p, round(row.avg_score, 1)))
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
return render_template('pages/dashboard.html', user=user, stats=stats)
+27 -14
View File
@@ -46,6 +46,25 @@ def match_form_payload():
return form_payload(list_fields=('player_ids',), optional_blank=())
def registered_players(tryout_id):
"""Players registered for one tryout, loaded in a single query.
The create form previously called ``User.query.get`` twice per
registration (once in the filter and once in the result expression),
and the edit form called it once per row. Besides scaling linearly, both
paths could return duplicates while DB-006 is still pending. The join is
bounded and ``distinct`` preserves the form's intended one-option-per-
player contract until the database constraint lands.
"""
return (
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
.filter(TryoutRegistration.tryout_id == tryout_id)
.order_by(User.username)
.distinct()
.all()
)
#: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30
@@ -270,7 +289,7 @@ def api_events():
@login_required
def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False
@@ -359,7 +378,7 @@ def api_events_for_tryout(tryout_id):
@login_required
def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -369,11 +388,7 @@ def create_match(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
all_players = [
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)
]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout_id)
prefill_date = request.args.get('date', '')
def rerender():
@@ -437,7 +452,7 @@ def create_match(tryout_id):
@login_required
def edit_match(match_id):
"""Edit an existing match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
@@ -449,9 +464,7 @@ def edit_match(match_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout.id)
current_player_ids = [p.player_id for p in match.participants.all()]
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
@@ -569,7 +582,7 @@ def api_manageable_tryouts():
@login_required
def delete_match(match_id):
"""Delete a match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger')
@@ -656,10 +669,10 @@ def api_available_players(date, time):
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
participant = MatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(MatchParticipant, participant_id)
if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
+7 -8
View File
@@ -3,8 +3,6 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
@@ -27,6 +25,7 @@ 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')
@@ -100,7 +99,7 @@ def list_matches():
teams=teams,
match_data=match_data,
pagination=matches_page,
now=datetime.utcnow(),
now=utc_now_naive(),
)
@@ -108,7 +107,7 @@ def list_matches():
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
@@ -213,7 +212,7 @@ def create_match(team_id):
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if not can_manage_team_match(team):
@@ -259,7 +258,7 @@ def edit_match(match_id):
@login_required
def delete_match(match_id):
"""Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger')
@@ -293,10 +292,10 @@ def api_manageable_teams():
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(TeamMatchParticipant, participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
+37 -30
View File
@@ -3,9 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
@@ -29,7 +27,8 @@ from app.models import (
User,
)
from app.permissions import visible_org_teams
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -82,7 +81,7 @@ def my_teams():
from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams()
now = datetime.utcnow()
now = utc_now_naive()
team_data = []
for org_team in player_teams:
@@ -223,7 +222,7 @@ def create_team():
@login_required
def edit_team(team_id):
"""Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -294,7 +293,7 @@ def delete_team(team_id):
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
deletion narrows with it instead of staying the one way in.
"""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger')
@@ -336,7 +335,7 @@ def delete_team(team_id):
@login_required
def add_coach(team_id):
"""Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -379,7 +378,7 @@ def add_coach(team_id):
@login_required
def add_manager(team_id):
"""Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -422,7 +421,7 @@ def add_manager(team_id):
@login_required
def remove_coach(team_id):
"""Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -450,7 +449,7 @@ def remove_coach(team_id):
@login_required
def remove_manager(team_id):
"""Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -478,7 +477,7 @@ def remove_manager(team_id):
@login_required
def add_player(team_id):
"""Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -515,12 +514,12 @@ def add_player(team_id):
@login_required
def remove_player(team_id, player_id):
"""Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(
@@ -543,7 +542,7 @@ def remove_player(team_id, player_id):
@login_required
def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403
@@ -567,17 +566,21 @@ def toggle_player_status(team_id, player_id):
@login_required
def add_team_note(team_id):
"""Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams'))
@@ -585,12 +588,12 @@ def add_team_note(team_id):
@login_required
def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -603,10 +606,14 @@ def add_player_note(team_id, player_id):
)
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams'))
+77 -38
View File
@@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from sqlalchemy import select
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
@@ -32,7 +31,15 @@ from app.models import (
TryoutRegistration,
User,
)
from app.validators import PlayerSelectionSchema, TryoutSchema
from app.time_utils import utc_now_naive
from app.validators import (
PlayerSelectionSchema,
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -79,6 +86,25 @@ def _users_by_id(user_ids):
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
def registration_lock_statement(tryout_id):
"""The PostgreSQL row lock used by both registration entry points."""
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
def locked_tryout_or_404(tryout_id):
"""Load and row-lock a tryout while a registration slot is decided.
PostgreSQL serializes concurrent registration attempts on this row. The
duplicate check, capacity count and insert that follow therefore form
one decision instead of three independently racing statements. SQLite
ignores ``FOR UPDATE`` in tests, but production does not.
"""
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
if tryout is None:
abort(404)
return tryout
@tryouts_bp.route('')
@login_required
def list_tryouts():
@@ -87,7 +113,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'])
@@ -152,7 +178,7 @@ def create_tryout():
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger')
@@ -209,7 +235,7 @@ def edit_tryout(tryout_id):
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = False
if isinstance(current_user, Admin):
@@ -398,7 +424,7 @@ def view_tryout(tryout_id):
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow(),
now=utc_now_naive(),
)
@@ -406,10 +432,10 @@ def view_tryout(tryout_id):
@login_required
def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player):
flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger')
@@ -439,15 +465,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')
return redirect(url_for('tryouts.list_tryouts'))
new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']:
tryout.status = new_status
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
try:
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout.status = data['status']
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -455,7 +485,7 @@ 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')
return redirect(url_for('tryouts.list_tryouts'))
@@ -463,11 +493,15 @@ def update_registration_status(tryout_id, player_id):
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first_or_404()
new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
db.session.commit()
flash(_('Registration status updated.'), 'success')
try:
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration.status = data['status']
db.session.commit()
flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -475,7 +509,7 @@ 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')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -522,12 +556,12 @@ 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')
return redirect(url_for('tryouts.list_tryouts'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
@@ -558,17 +592,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')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name')
if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -576,8 +614,8 @@ 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')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -588,10 +626,12 @@ def add_to_team(tryout_id, team_id):
if team.tryout_id != tryout_id:
abort(404)
player_id = request.form.get('player_id', type=int)
if not player_id:
flash(_('Please select a player.'), 'danger')
try:
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_id']
# Only players registered for this tryout may be placed on its teams.
is_registered = (
@@ -602,12 +642,11 @@ def add_to_team(tryout_id, team_id):
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
flash(_('Player is already on this team.'), 'info')
else:
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
db.session.add(member)
db.session.commit()
flash(_('Player added to team!'), 'success')
@@ -618,7 +657,7 @@ 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')
return redirect(url_for('tryouts.list_tryouts'))
+15 -11
View File
@@ -4,7 +4,6 @@ Nothing here touches the blueprint: these are plain functions, so a test
can call them with a request context and nothing else.
"""
from flask import request
from flask_babel import gettext as _
from app.extensions import db
@@ -13,7 +12,7 @@ from app.extensions import db
# routes needed them as well (ARCH-005). Importing them from here still
# works, so the thirty call sites in this package did not have to move.
from app.forms import flash_validation_errors, form_payload # noqa: F401
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
@@ -63,20 +62,25 @@ def pdf_upload_error(file, allowed_extensions):
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
"""Update gamertags for a user from validated dynamic form fields."""
from app.forms import form_gamertags
submitted = form_gamertags(selected_games)
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
gamertag = request.form.get(f'gamertag_{game}', '').strip()
platform = (
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
)
payload = submitted.get(game)
existing = existing_gamertags.get(game)
if gamertag:
if payload:
if existing:
existing.gamertag = gamertag
existing.platform = platform
existing.gamertag = payload['gamertag']
existing.platform = payload['platform']
else:
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
gt = UserGamertag(
user_id=user.id,
game=game,
gamertag=payload['gamertag'],
platform=payload['platform'],
)
db.session.add(gt)
elif existing:
db.session.delete(existing)
+19 -5
View File
@@ -70,7 +70,7 @@ def edit_user(user_id):
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
if request.method == 'POST':
actor_name, actor_id = current_user.username, current_user.id
@@ -111,6 +111,22 @@ def edit_user(user_id):
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
discord_clash = None
if discord_user_id:
discord_clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != user.id,
).first()
if discord_clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender()
try:
update_user_gamertags(user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
role_changed = user.role != role
previous_role = user.role
@@ -173,8 +189,6 @@ def edit_user(user_id):
user.discord_user_id = discord_user_id or None
user.league_os_profile = league_os_profile or None
update_user_gamertags(user, selected_games)
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
@@ -239,7 +253,7 @@ def delete_user(user_id):
flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users'))
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
@@ -365,5 +379,5 @@ def create_user():
@login_required
def view_user(user_id):
"""View a public profile for any user."""
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
return render_template('pages/view_user.html', profile_user=user)
+26 -26
View File
@@ -141,41 +141,41 @@ def add_disponibility():
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Add multiple disponibility blocks at once."""
"""Replace the current player's disponibility blocks atomically."""
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = []
for slot in accepted:
start_time = slot['start_time']
existing = PlayerDisponibility.query.filter_by(
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
).first()
if not existing:
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
db.session.commit()
# `rejected` is reported rather than swallowed. What was accepted is
# still saved — dropping a whole batch because one cell was malformed
# would be its own kind of surprise — but the client can now tell the
# difference between "nine slots saved" and "ten sent, nine saved".
return jsonify({'success': True, 'created': created, 'rejected': rejected})
return jsonify({'success': True, 'created': created, 'rejected': []})
@users_bp.route('/disponibilities/clear', methods=['POST'])
@@ -193,7 +193,7 @@ def clear_disponibilities():
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
disponibility = db.get_or_404(PlayerDisponibility, disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
+6 -6
View File
@@ -2,7 +2,6 @@
import os
import uuid
from datetime import datetime
from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
@@ -20,6 +19,7 @@ from app.routes.users._shared import (
)
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
@@ -111,7 +111,7 @@ def upload_contract():
flash(error, 'danger')
return redirect(url_for('users.upload_contract'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None
@@ -154,7 +154,7 @@ def upload_contract():
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
@@ -172,7 +172,7 @@ def upload_signed_contract(contract_id):
contract.signed_filename = signed_filename
contract.signed_file_path = signed_path
contract.status = 'signed'
contract.signed_at = datetime.utcnow()
contract.signed_at = utc_now_naive()
db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
@@ -182,7 +182,7 @@ def upload_signed_contract(contract_id):
@login_required
def download_contract(contract_id):
"""Download a contract file."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
@@ -197,7 +197,7 @@ def download_contract(contract_id):
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
+116 -55
View File
@@ -7,23 +7,32 @@ it reads exactly what the coach routes write.
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
Team,
TeamMember,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
from app.permissions import (
coach_can_access_player,
coach_org_teams,
coach_player_ids,
coach_tryouts,
)
from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@users_bp.route('/my-notes')
@@ -116,23 +125,25 @@ def notes_dashboard():
)
# For context selectors in the form
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
# previous selector mixed the two namespaces and could either attach the
# note to an unrelated team with the same integer id or fail its FK.
# Every context list now comes from the tryouts this coach may manage.
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
tryout_ids = [tryout.id for tryout in tryouts]
matches = (
Match.query.filter(
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
)
Match.query.filter(Match.tryout_id.in_(tryout_ids))
.order_by(Match.date.desc())
.limit(20)
.all()
if tryout_ids
else []
)
tryouts = (
Tryout.query.filter_by(
created_by=current_user.id,
)
.order_by(Tryout.date.desc())
.limit(20)
.all()
teams = (
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
if tryout_ids
else []
)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
return render_template(
'pages/notes.html',
@@ -168,16 +179,20 @@ def manage_team_notes():
return redirect(url_for('users.notes_dashboard'))
org_team = org_teams[0]
content = request.form.get('content', '').strip()
if content:
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=content,
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -195,14 +210,14 @@ def manage_personal_notes():
flash(_('Only coaches can manage personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
@@ -214,7 +229,7 @@ def manage_personal_notes():
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
)
db.session.add(note)
db.session.commit()
@@ -235,17 +250,14 @@ def add_personal_note():
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
match_id = request.form.get('match_id', type=int)
tryout_id = request.form.get('tryout_id', type=int)
team_id_str = request.form.get('team_id')
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
@@ -254,13 +266,40 @@ def add_personal_note():
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['match_id']:
match = db.get_or_404(Match, data['match_id'])
if not current_user.can_manage_this_tryout(match.tryout):
flash(_('You cannot use that match as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
flash(_('That player did not participate in the selected match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['tryout_id']:
tryout = db.get_or_404(Tryout, data['tryout_id'])
if not current_user.can_manage_this_tryout(tryout):
flash(_('You cannot use that tryout as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
flash(_('That player is not registered for the selected tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['team_id']:
team = db.get_or_404(Team, data['team_id'])
if not current_user.can_manage_this_tryout(team.tryout):
flash(_('You cannot use that team as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
flash(_('That player is not on the selected team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
match_id=match_id if match_id else None,
tryout_id=tryout_id if tryout_id else None,
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
content=data['content'],
match_id=data['match_id'],
tryout_id=data['tryout_id'],
team_id=data['team_id'],
)
db.session.add(note)
db.session.commit()
@@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list
@@ -289,21 +331,29 @@ def add_note_from_tryout(tryout_id):
players = [r.player for r in registrations if r.player]
if request.method == 'POST':
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
player_id = data['player_id']
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
if data['tryout_id'] not in (None, tryout_id):
flash(_('Invalid tryout context.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
tryout_id=tryout_id,
)
db.session.add(note)
@@ -334,7 +384,10 @@ def add_note_from_match(match_id):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
match_obj = Match.query.get_or_404(match_id)
match_obj = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
# Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
@@ -343,21 +396,29 @@ def add_note_from_match(match_id):
preselected_player_id = request.args.get('player_id', type=int)
if request.method == 'POST':
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_match', match_id=match_id))
player_id = data['player_id']
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
if data['match_id'] not in (None, match_id):
flash(_('Invalid match context.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
flash(_('That player did not participate in this match.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
match_id=match_id,
)
db.session.add(note)
+12 -8
View File
@@ -1,7 +1,5 @@
"""One-on-one sessions between a player and their coach."""
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
@@ -12,7 +10,8 @@ from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification
from app.validators import OneOnOneRequestSchema
from app.time_utils import utc_now_naive
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@@ -166,7 +165,7 @@ def accept_one_on_one(request_id):
flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
@@ -178,7 +177,7 @@ def accept_one_on_one(request_id):
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow()
request_obj.responded_at = utc_now_naive()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
@@ -216,7 +215,7 @@ def reject_one_on_one(request_id):
flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
@@ -226,11 +225,16 @@ def reject_one_on_one(request_id):
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
rejection_reason = request.form.get('rejection_reason', '').strip()
try:
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason']
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow()
request_obj.responded_at = utc_now_naive()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
+12 -4
View File
@@ -76,7 +76,6 @@ def edit_profile():
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
league_os_profile = validated.get('league_os_profile')
if username != current_user.username and User.query.filter_by(username=username).first():
@@ -99,17 +98,26 @@ def edit_profile():
user_gamertags=current_user.get_gamertags(),
)
try:
update_user_gamertags(current_user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
current_user.username = username
current_user.full_name = full_name
current_user.email = email
current_user.phone = phone
current_user.games = ','.join(selected_games) if selected_games else None
current_user.discord_username = discord_username or None
current_user.discord_user_id = discord_user_id or None
current_user.league_os_profile = league_os_profile or None
update_user_gamertags(current_user, selected_games)
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')