diff --git a/app/routes/main.py b/app/routes/main.py index 7109451..8b85347 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -116,14 +116,27 @@ def dashboard(): 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)) + + # 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()) diff --git a/app/routes/matches.py b/app/routes/matches.py index 9d7c8fa..1488f5a 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -25,6 +25,8 @@ from app.models import ( PersonalNote, ) from datetime import datetime, timedelta + +from sqlalchemy.orm import joinedload from app.services.scheduling import notify_participants, zip_participants matches_bp = Blueprint('matches', __name__, url_prefix='/matches') @@ -50,15 +52,76 @@ 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') @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' # 'description' used to be participants_str + '
' + description. # Building presentation markup inside a JSON field is what carried @@ -75,18 +138,16 @@ def api_events(): teams.append(match.team2.name) 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' 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( { @@ -625,7 +686,20 @@ def delete_match(match_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: parsed_date = datetime.strptime(date_str, '%Y-%m-%d') time_obj = datetime.strptime(time_str, '%H:%M').time() @@ -633,22 +707,30 @@ def get_players_available_at_time(date_str, time_str): return [] 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//