"""Evaluation routes for assessing player performance during tryouts. Uses polymorphic isinstance checks instead of role-string comparisons. """ import math 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 sqlalchemy import false, func, select from sqlalchemy.orm import aliased from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( EVALUATION_CRITERIA, GAME_POSITIONS, Admin, Coach, Evaluation, Manager, Player, Scout, Tryout, TryoutRegistration, User, ) from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE from app.responders import respond_after_change from app.time_utils import utc_now_naive from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') def _users_by_id(user_ids): """Load a set of users once for aggregate/list views.""" wanted = {user_id for user_id in user_ids if user_id} if not wanted: return {} return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()} def validate_score(score_value): """Turn a raw form value into a criterion score (1..10), or None. A blank value means "not assessed". Anything else outside 1..10 is a validation error, matching the single-player form's ``EvaluationSchema`` -- a coach must not be able to quietly turn 11 or 0 into "no score" (QUA-003). """ if score_value is None or str(score_value).strip() == '': return None try: score = int(score_value) except (TypeError, ValueError) as exc: raise ValidationError('A score must be a whole number from 1 to 10.') from exc if not 1 <= score <= 10: raise ValidationError('Scores run from 1 to 10.') return score def _apply_evaluation(evaluation, scores, comments, position): """Write validated scores, comments and position onto an Evaluation.""" evaluation.apply_scores(scores) evaluation.comments = comments evaluation.position_recommendation = position def _visible_evaluations_query(user): """Evaluations the acting user is allowed to see, as a query. Admins and scouts see everything. Managers and coaches see the evaluations authored by managers and coaches inside the tryouts they manage; players never reach the evaluation pages. """ if isinstance(user, (Admin, Scout)): return Evaluation.query tryout_ids = [t.id for t in user.get_visible_tryouts()] if not tryout_ids: return Evaluation.query.filter(false()) evaluator_alias = aliased(User, name='visible_evaluator') return ( Evaluation.query .join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) .filter(evaluator_alias.role.in_(['manager', 'coach'])) .filter(Evaluation.tryout_id.in_(tryout_ids)) ) def _visible_evaluation_ids(user): """A ``select`` of the ids of evaluations the acting user may see.""" ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery() return select(ids.c.id) def _average(values): """Mean of the non-None values, rounded to one decimal (or None).""" values = [value for value in values if value is not None] if not values: return None return round(sum(values) / len(values), 1) def _aggregate(evaluations): """Per-criterion averages plus a combined overall average.""" criterion_averages = [ (label, _average([getattr(e, field) for e in evaluations])) for field, label in EVALUATION_CRITERIA ] return { 'criterion_averages': criterion_averages, 'combined_average': _average([e.overall_score for e in evaluations]), 'count': len(evaluations), } def _evaluation_view(evaluation, evaluator, tryout): """Shape an Evaluation for the templates (scores in criterion order).""" return { 'evaluation': evaluation, 'evaluator': evaluator, 'tryout': tryout, 'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA], 'overall': evaluation.overall_score, 'position': evaluation.position_recommendation, 'comment': evaluation.comments, 'date': evaluation.created_at, } def _players_by_average(user): """Players with visible evaluations, best combined average first.""" visible_ids = _visible_evaluation_ids(user) rows = ( db.session.query( Evaluation.player_id, func.avg(Evaluation.overall_score).label('avg_score'), ) .filter(Evaluation.id.in_(visible_ids)) .group_by(Evaluation.player_id) .all() ) players_by_id = _users_by_id(row.player_id for row in rows) ordered = [ (players_by_id[row.player_id], row.avg_score) for row in rows if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player) ] ordered.sort( key=lambda pair: ( pair[1] is None, -(pair[1] if pair[1] is not None else 0), pair[0].username.lower(), ) ) return ordered @evaluations_bp.route('') @login_required def list_evaluations(): """List player evaluations grouped by player, with a per-player view.""" user = current_user if isinstance(user, Player): flash(_('You do not have permission to view evaluations.'), 'danger') return redirect(url_for('main.dashboard')) sort_column = request.args.get('sort', 'overall_score') sort_order = request.args.get('order', 'desc') if sort_order not in ('asc', 'desc'): sort_order = 'desc' visible_ids = _visible_evaluation_ids(user) aggregate = ( db.session.query( Evaluation.player_id, func.count(Evaluation.id).label('eval_count'), func.avg(Evaluation.overall_score).label('avg_score'), func.max(Evaluation.created_at).label('last_eval'), ) .filter(Evaluation.id.in_(visible_ids)) .group_by(Evaluation.player_id) .all() ) players_by_id = _users_by_id(row.player_id for row in aggregate) grouped = [] for row in aggregate: player = players_by_id.get(row.player_id) if not player or not isinstance(player, Player): continue grouped.append( { 'player': player, 'avg': round(row.avg_score, 1) if row.avg_score is not None else None, 'count': row.eval_count, 'last_eval': row.last_eval, 'evaluations': [], } ) def sort_value(entry): if sort_column == 'player': return entry['player'].username.lower() if sort_column == 'count': return entry['count'] if sort_column == 'created_at': return entry['last_eval'] or utc_now_naive() return entry['avg'] if entry['avg'] is not None else float('-inf') grouped.sort(key=sort_value, reverse=(sort_order == 'desc')) page = request.args.get('page', 1, type=int) or 1 page = max(1, page) per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE per_page = max(1, min(per_page, MAX_PER_PAGE)) total = len(grouped) pages = math.ceil(total / per_page) if total else 1 page = min(page, pages) start = (page - 1) * per_page page_items = grouped[start:start + per_page] players_on_page = {entry['player'].id: entry for entry in page_items} if players_on_page: evaluations = ( Evaluation.query .filter(Evaluation.player_id.in_(players_on_page)) .filter(Evaluation.id.in_(visible_ids)) .order_by(Evaluation.created_at.desc(), Evaluation.id.desc()) .all() ) evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations) tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id] tryouts_by_id = ( {t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()} if tryout_ids else {} ) for evaluation in evaluations: players_on_page[evaluation.player_id]['evaluations'].append( _evaluation_view( evaluation, evaluators_by_id.get(evaluation.evaluator_id), tryouts_by_id.get(evaluation.tryout_id), ) ) pagination = { 'page': page, 'pages': pages, 'total': total, 'has_prev': page > 1, 'has_next': page < pages, 'prev_num': page - 1, 'next_num': page + 1, } return render_template( 'pages/evaluations.html', players=page_items, pagination=pagination, sort_column=sort_column, sort_order=sort_order, evaluation_criteria=EVALUATION_CRITERIA, first_player_id=grouped[0]['player'].id if grouped else None, ) @evaluations_bp.route('/player/') @login_required def player_evaluations(player_id): """One player's evaluations, with prev/next navigation between players.""" user = current_user if not user.can_evaluate(): flash(_('You do not have permission to view evaluations.'), 'danger') return redirect(url_for('main.dashboard')) player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only view evaluations for players.'), 'danger') return redirect(url_for('evaluations.list_evaluations')) ordered = _players_by_average(user) index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None) prev_player = ordered[index - 1][0] if index is not None and index > 0 else None next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None visible_ids = _visible_evaluation_ids(user) evaluations = ( Evaluation.query .filter(Evaluation.player_id == player.id) .filter(Evaluation.id.in_(visible_ids)) .order_by(Evaluation.created_at.desc(), Evaluation.id.desc()) .all() ) evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations) tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id] tryouts_by_id = ( {t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()} if tryout_ids else {} ) evaluation_views = [ _evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id)) for e in evaluations ] aggregates = _aggregate(evaluations) return render_template( 'pages/player_evaluations.html', player=player, evaluations=evaluation_views, prev_player=prev_player, next_player=next_player, criterion_averages=aggregates['criterion_averages'], combined_average=aggregates['combined_average'], evaluation_count=aggregates['count'], evaluation_criteria=EVALUATION_CRITERIA, ) @evaluations_bp.route('//', 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 respond_after_change(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 respond_after_change(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 respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return render_evaluation_form() @evaluations_bp.route('//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('//batch', methods=['GET', 'POST']) @login_required def batch_evaluate(tryout_id): """Evaluate multiple players at once in a tryout. GET renders a single form listing every selected player with their evaluation criteria. POST saves (creates or updates) all of them. """ if not current_user.can_evaluate(): flash('You do not have permission to evaluate players.', 'danger') return redirect(url_for('main.dashboard')) tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to evaluate players in this tryout.', 'danger') return redirect(url_for('tryouts.list_tryouts')) # Resolve selected player ids (query string on GET, hidden fields on POST). player_ids = [] for raw in request.values.getlist('player_ids'): try: pid = int(raw) except (ValueError, TypeError): continue if pid not in player_ids: player_ids.append(pid) if not player_ids: flash('Please select at least one player to evaluate.', 'warning') return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id)) players = [] for pid in player_ids: player = User.query.get(pid) if not player or not isinstance(player, Player): continue is_registered = ( TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=pid, ).first() is not None ) if not is_registered: continue existing = Evaluation.query.filter_by( tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id, ).first() existing_scores = { field_name: getattr(existing, field_name) if existing else None for field_name, _ in EVALUATION_CRITERIA } players.append( { 'player': player, 'existing': existing, 'existing_scores': existing_scores, } ) if not players: flash('No valid players selected for evaluation.', 'danger') return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id)) if request.method == 'POST': saved = 0 try: for entry in players: pid = entry['player'].id scores = { field_name: validate_score(request.form.get(f'{field_name}_{pid}')) for field_name, _ in EVALUATION_CRITERIA } comments = request.form.get(f'comments_{pid}') position = request.form.get(f'position_recommendation_{pid}') existing = entry['existing'] if existing: _apply_evaluation(existing, scores, comments, position) else: evaluation = Evaluation( tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id, ) _apply_evaluation(evaluation, scores, comments, position) db.session.add(evaluation) saved += 1 except ValidationError: db.session.rollback() flash(_('Scores must be whole numbers from 1 to 10.'), 'danger') return render_template( 'pages/batch_evaluate.html', tryout=tryout, players=players, evaluation_criteria=EVALUATION_CRITERIA, game_positions=GAME_POSITIONS, ) db.session.commit() flash(f'Saved evaluations for {saved} player(s).', 'success') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return render_template( 'pages/batch_evaluate.html', tryout=tryout, players=players, evaluation_criteria=EVALUATION_CRITERIA, game_positions=GAME_POSITIONS, )