Compare commits
3
Commits
cleanup
...
32c112ceea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32c112ceea | ||
|
|
30069d782f | ||
|
|
d994d88c12 |
+269
-61
@@ -3,11 +3,13 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from sqlalchemy import func
|
from sqlalchemy import false, func, select
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -16,14 +18,18 @@ from app.models import (
|
|||||||
EVALUATION_CRITERIA,
|
EVALUATION_CRITERIA,
|
||||||
GAME_POSITIONS,
|
GAME_POSITIONS,
|
||||||
Admin,
|
Admin,
|
||||||
|
Coach,
|
||||||
Evaluation,
|
Evaluation,
|
||||||
|
Manager,
|
||||||
Player,
|
Player,
|
||||||
|
Scout,
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE
|
||||||
from app.responders import respond_after_change
|
from app.responders import respond_after_change
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import EvaluationSchema
|
from app.validators import EvaluationSchema
|
||||||
|
|
||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||||
@@ -37,95 +43,286 @@ def _users_by_id(user_ids):
|
|||||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
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('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
"""List all evaluations accessible to the current user."""
|
"""List player evaluations grouped by player, with a per-player view."""
|
||||||
user = current_user
|
user = current_user
|
||||||
|
|
||||||
if isinstance(user, Player):
|
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'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
sort_column = request.args.get('sort', 'created_at')
|
sort_column = request.args.get('sort', 'overall_score')
|
||||||
sort_order = request.args.get('order', 'desc')
|
sort_order = request.args.get('order', 'desc')
|
||||||
if sort_order not in ('asc', 'desc'):
|
if sort_order not in ('asc', 'desc'):
|
||||||
sort_order = 'desc'
|
sort_order = 'desc'
|
||||||
|
|
||||||
player_alias = aliased(User, name='eval_player')
|
visible_ids = _visible_evaluation_ids(user)
|
||||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
|
||||||
|
|
||||||
sort_map = {
|
aggregate = (
|
||||||
'tryout': Tryout.title,
|
|
||||||
'player': player_alias.username,
|
|
||||||
'evaluator': evaluator_alias.username,
|
|
||||||
'mecanics_score': Evaluation.mecanics_score,
|
|
||||||
'cohesion_score': Evaluation.cohesion_score,
|
|
||||||
'communication_score': Evaluation.communication_score,
|
|
||||||
'gamesense_score': Evaluation.gamesense_score,
|
|
||||||
'versatility_score': Evaluation.versatility_score,
|
|
||||||
'discipline_score': Evaluation.discipline_score,
|
|
||||||
'analysis_score': Evaluation.analysis_score,
|
|
||||||
'sport_ethics_score': Evaluation.sport_ethics_score,
|
|
||||||
'mental_score': Evaluation.mental_score,
|
|
||||||
'overall_score': Evaluation.overall_score,
|
|
||||||
'position_recommendation': Evaluation.position_recommendation,
|
|
||||||
'created_at': Evaluation.created_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
|
|
||||||
if sort_order == 'asc':
|
|
||||||
sort_expr = sort_expr.asc()
|
|
||||||
else:
|
|
||||||
sort_expr = sort_expr.desc()
|
|
||||||
|
|
||||||
if isinstance(user, Admin):
|
|
||||||
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(
|
db.session.query(
|
||||||
Evaluation.player_id,
|
Evaluation.player_id,
|
||||||
func.count(Evaluation.id).label('eval_count'),
|
func.count(Evaluation.id).label('eval_count'),
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
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)
|
.group_by(Evaluation.player_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
|
||||||
player_scores = {}
|
players_by_id = _users_by_id(row.player_id for row in aggregate)
|
||||||
for row in avg_scores:
|
grouped = []
|
||||||
p = players_by_id.get(row.player_id)
|
for row in aggregate:
|
||||||
if p:
|
player = players_by_id.get(row.player_id)
|
||||||
player_scores[p.id] = {
|
if not player or not isinstance(player, Player):
|
||||||
'player': p,
|
continue
|
||||||
|
grouped.append(
|
||||||
|
{
|
||||||
|
'player': player,
|
||||||
|
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
|
||||||
'count': row.eval_count,
|
'count': row.eval_count,
|
||||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
'last_eval': row.last_eval,
|
||||||
|
'evaluations': [],
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
# 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 = {}
|
|
||||||
|
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(
|
return render_template(
|
||||||
'pages/evaluations.html',
|
'pages/evaluations.html',
|
||||||
evaluations=evaluations_page.items,
|
players=page_items,
|
||||||
pagination=evaluations_page,
|
pagination=pagination,
|
||||||
player_scores=player_scores,
|
|
||||||
sort_column=sort_column,
|
sort_column=sort_column,
|
||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
first_player_id=grouped[0]['player'].id if grouped else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@evaluations_bp.route('/player/<int:player_id>')
|
||||||
|
@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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -317,6 +514,7 @@ def batch_evaluate(tryout_id):
|
|||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
saved = 0
|
saved = 0
|
||||||
|
try:
|
||||||
for entry in players:
|
for entry in players:
|
||||||
pid = entry['player'].id
|
pid = entry['player'].id
|
||||||
scores = {
|
scores = {
|
||||||
@@ -338,6 +536,16 @@ def batch_evaluate(tryout_id):
|
|||||||
_apply_evaluation(evaluation, scores, comments, position)
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
db.session.add(evaluation)
|
db.session.add(evaluation)
|
||||||
saved += 1
|
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()
|
db.session.commit()
|
||||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
|
|||||||
@@ -2122,3 +2122,73 @@ a:hover { color: var(--primary-dark); }
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Evaluation comments - separate grid so long notes stay readable */
|
||||||
|
.eval-comments {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comments-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment-text {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment-author strong {
|
||||||
|
color: var(--gray-700);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment-text {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment-author strong {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comments-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comments-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set sort_column = sort_column | default('created_at') %}
|
{% set sort_column = sort_column | default('overall_score') %}
|
||||||
{% set sort_order = sort_order | default('desc') %}
|
{% set sort_order = sort_order | default('desc') %}
|
||||||
|
|
||||||
{% macro sort_link(column, label) %}
|
{% macro sort_link(column, label) %}
|
||||||
@@ -22,76 +22,111 @@
|
|||||||
</th>
|
</th>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% if current_user.role == 'admin' and player_scores %}
|
<div class="view-toggle mb-3">
|
||||||
<div class="stats-grid mb-4">
|
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-primary">
|
||||||
{% for pid, data in player_scores.items() %}
|
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||||
<div class="stat-card stat-card-sm">
|
</a>
|
||||||
<div class="stat-icon bg-info">
|
{% if first_player_id %}
|
||||||
<i class="fas fa-user"></i>
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=first_player_id) }}" class="btn btn-sm btn-outline">
|
||||||
</div>
|
<i class="fas fa-user"></i> {{ _('By player') }}
|
||||||
<div class="stat-info">
|
</a>
|
||||||
<h3>{{ data.player.username[:12] }}</h3>
|
{% endif %}
|
||||||
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-clipboard-list"></i>
|
<h3><i class="fas fa-clipboard-list"></i> {{ _('Player Evaluations') }}</h3>
|
||||||
{% if current_user.role == 'player' %}
|
|
||||||
My Evaluations
|
|
||||||
{% else %}
|
|
||||||
All Evaluations
|
|
||||||
{% endif %}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{{ sort_link('tryout', 'Tryout') }}
|
<th></th>
|
||||||
{{ sort_link('player', 'Player') }}
|
{{ sort_link('player', 'Player') }}
|
||||||
{{ sort_link('evaluator', 'Evaluator') }}
|
{{ sort_link('overall_score', 'Average') }}
|
||||||
{{ sort_link('mecanics_score', 'Mecanics') }}
|
{{ sort_link('count', 'Evaluations') }}
|
||||||
{{ sort_link('cohesion_score', 'Cohesion') }}
|
<th>{{ _('Open') }}</th>
|
||||||
{{ sort_link('communication_score', 'Communication') }}
|
|
||||||
{{ sort_link('gamesense_score', 'Gamesense') }}
|
|
||||||
{{ sort_link('versatility_score', 'Versatility') }}
|
|
||||||
{{ sort_link('discipline_score', 'Discipline') }}
|
|
||||||
{{ sort_link('analysis_score', 'Analysis') }}
|
|
||||||
{{ sort_link('sport_ethics_score', 'Sport Ethics') }}
|
|
||||||
{{ sort_link('mental_score', 'Mental') }}
|
|
||||||
{{ sort_link('overall_score', 'Overall') }}
|
|
||||||
{{ sort_link('position_recommendation', 'Position') }}
|
|
||||||
{{ sort_link('created_at', 'Date') }}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for eval in evaluations %}
|
{% for entry in players %}
|
||||||
|
<tr class="eval-row" data-action="toggle-player-evals" data-target="evals-{{ entry.player.id }}">
|
||||||
|
<td><i class="fas fa-chevron-right eval-chevron"></i></td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="player-link">
|
||||||
|
{{ entry.player.username }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><span class="score">{{ entry.avg if entry.avg is not none else '-' }}</span></td>
|
||||||
|
<td>{{ entry.count }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="eval-detail hidden" id="evals-{{ entry.player.id }}">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="eval-detail-inner">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td>
|
<th>{{ _('Evaluator') }}</th>
|
||||||
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td>
|
<th>{{ _('Tryout') }}</th>
|
||||||
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td>
|
{% for field, label in evaluation_criteria %}
|
||||||
<td>{{ eval.mecanics_score or '-' }}</td>
|
<th>{{ label }}</th>
|
||||||
<td>{{ eval.cohesion_score or '-' }}</td>
|
{% endfor %}
|
||||||
<td>{{ eval.communication_score or '-' }}</td>
|
<th>{{ _('Overall') }}</th>
|
||||||
<td>{{ eval.gamesense_score or '-' }}</td>
|
<th>{{ _('Position') }}</th>
|
||||||
<td>{{ eval.versatility_score or '-' }}</td>
|
<th>{{ _('Date') }}</th>
|
||||||
<td>{{ eval.discipline_score or '-' }}</td>
|
</tr>
|
||||||
<td>{{ eval.analysis_score or '-' }}</td>
|
</thead>
|
||||||
<td>{{ eval.sport_ethics_score or '-' }}</td>
|
<tbody>
|
||||||
<td>{{ eval.mental_score or '-' }}</td>
|
{% for ev in entry.evaluations %}
|
||||||
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
|
<tr>
|
||||||
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td>
|
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
||||||
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td>
|
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
||||||
|
{% for score in ev.scores %}
|
||||||
|
<td>{{ score if score is not none else '-' }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td><span class="score">{{ ev.overall if ev.overall is not none else '-' }}</span></td>
|
||||||
|
<td>{{ ev.position or '-' }}</td>
|
||||||
|
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="15" class="text-center">
|
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if entry.evaluations %}
|
||||||
|
{% set comment_evals = entry.evaluations | selectattr('comment') | list %}
|
||||||
|
<div class="eval-comments">
|
||||||
|
<h4 class="eval-comments-title"><i class="fas fa-comments"></i> {{ _('Comments') }}</h4>
|
||||||
|
{% if comment_evals %}
|
||||||
|
{% for ev in comment_evals %}
|
||||||
|
<article class="eval-comment">
|
||||||
|
<p class="eval-comment-text">{{ ev.comment }}</p>
|
||||||
|
<footer class="eval-comment-meta">
|
||||||
|
<span class="eval-comment-author">{{ _('Evaluator') }} : <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
|
||||||
|
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} · {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="eval-comments-empty">{{ _('No comments') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-clipboard"></i>
|
<i class="fas fa-clipboard"></i>
|
||||||
<h3>{{ _('No evaluations yet') }}</h3>
|
<h3>{{ _('No evaluations yet') }}</h3>
|
||||||
@@ -106,3 +141,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<style>
|
||||||
|
.view-toggle { display: flex; gap: 8px; }
|
||||||
|
.eval-row { cursor: pointer; }
|
||||||
|
.eval-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||||
|
.eval-detail-inner { padding: 8px 4px; }
|
||||||
|
.eval-detail-inner .table { margin-bottom: 0; }
|
||||||
|
.player-nav { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.is-disabled { opacity: 0.5; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
function togglePlayerEvals(element, event) {
|
||||||
|
if (event.target.closest('a')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var detail = document.getElementById(element.getAttribute('data-target'));
|
||||||
|
if (!detail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var chevron = element.querySelector('.eval-chevron');
|
||||||
|
var isHidden = detail.classList.contains('hidden');
|
||||||
|
if (isHidden) {
|
||||||
|
detail.classList.remove('hidden');
|
||||||
|
if (chevron) chevron.classList.add('fa-rotate-90');
|
||||||
|
} else {
|
||||||
|
detail.classList.add('hidden');
|
||||||
|
if (chevron) chevron.classList.remove('fa-rotate-90');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerActions({ 'toggle-player-evals': togglePlayerEvals });
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ player.username }} - {{ _('Evaluations') }} - UdeS team manager{% endblock %}
|
||||||
|
{% block page_title %}{{ _('Player Evaluations') }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('evaluations.list_evaluations') }}">{{ _('Evaluations') }}</a> / {{ player.username }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="view-toggle mb-3">
|
||||||
|
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-outline">
|
||||||
|
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=player.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-user"></i> {{ _('By player') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-user"></i> {{ player.username }}</h3>
|
||||||
|
<div class="player-nav">
|
||||||
|
{% if prev_player %}
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=prev_player.id) }}" class="btn btn-sm btn-secondary" title="{{ prev_player.username }}">
|
||||||
|
<i class="fas fa-chevron-left"></i> {{ prev_player.username }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-left"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_player %}
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=next_player.id) }}" class="btn btn-sm btn-secondary" title="{{ next_player.username }}">
|
||||||
|
{{ next_player.username }} <i class="fas fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-right"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stats-grid mb-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-info"><i class="fas fa-star"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ combined_average if combined_average is not none else '-' }}</h3>
|
||||||
|
<p>{{ _('Combined average') }} · {{ evaluation_count }} {{ _('evaluations') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for label, avg in criterion_averages %}
|
||||||
|
<div class="stat-card stat-card-sm">
|
||||||
|
<div class="stat-icon bg-info"><i class="fas fa-chart-bar"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ avg if avg is not none else '-' }}</h3>
|
||||||
|
<p>{{ label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Evaluator') }}</th>
|
||||||
|
<th>{{ _('Tryout') }}</th>
|
||||||
|
{% for field, label in evaluation_criteria %}
|
||||||
|
<th>{{ label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>{{ _('Overall') }}</th>
|
||||||
|
<th>{{ _('Position') }}</th>
|
||||||
|
<th>{{ _('Date') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ev in evaluations %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
||||||
|
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
||||||
|
{% for score in ev.scores %}
|
||||||
|
<td>{{ score if score is not none else '-' }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td><span class="score">{{ ev.overall if ev.overall is not none else '-' }}</span></td>
|
||||||
|
<td>{{ ev.position or '-' }}</td>
|
||||||
|
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-clipboard"></i>
|
||||||
|
<h3>{{ _('No evaluations for this player yet') }}</h3>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if evaluations %}
|
||||||
|
{% set comment_evals = evaluations | selectattr('comment') | list %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if comment_evals %}
|
||||||
|
<div class="eval-comments">
|
||||||
|
{% for ev in comment_evals %}
|
||||||
|
<article class="eval-comment">
|
||||||
|
<p class="eval-comment-text">{{ ev.comment }}</p>
|
||||||
|
<footer class="eval-comment-meta">
|
||||||
|
<span class="eval-comment-author">{{ _('Evaluator') }} : <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
|
||||||
|
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} · {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
<h3>{{ _('No comments') }}</h3>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<style>
|
||||||
|
.view-toggle { display: flex; gap: 8px; }
|
||||||
|
.player-nav { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.is-disabled { opacity: 0.5; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
Binary file not shown.
@@ -1826,9 +1826,16 @@ msgid "Enter position (optional)"
|
|||||||
msgstr "Enter position (optional)"
|
msgstr "Enter position (optional)"
|
||||||
|
|
||||||
#: app/templates/pages/evaluate_player.html:121
|
#: app/templates/pages/evaluate_player.html:121
|
||||||
|
#: app/templates/pages/player_evaluations.html
|
||||||
|
#: app/templates/pages/evaluations.html
|
||||||
msgid "Comments"
|
msgid "Comments"
|
||||||
msgstr "Comments"
|
msgstr "Comments"
|
||||||
|
|
||||||
|
#: app/templates/pages/player_evaluations.html
|
||||||
|
#: app/templates/pages/evaluations.html
|
||||||
|
msgid "No comments"
|
||||||
|
msgstr "No comments"
|
||||||
|
|
||||||
#: app/templates/pages/evaluate_player.html:122
|
#: app/templates/pages/evaluate_player.html:122
|
||||||
msgid "Enter your evaluation notes..."
|
msgid "Enter your evaluation notes..."
|
||||||
msgstr "Enter your evaluation notes..."
|
msgstr "Enter your evaluation notes..."
|
||||||
|
|||||||
Binary file not shown.
@@ -1836,9 +1836,16 @@ msgid "Enter position (optional)"
|
|||||||
msgstr "Saisir un poste (facultatif)"
|
msgstr "Saisir un poste (facultatif)"
|
||||||
|
|
||||||
#: app/templates/pages/evaluate_player.html:121
|
#: app/templates/pages/evaluate_player.html:121
|
||||||
|
#: app/templates/pages/player_evaluations.html
|
||||||
|
#: app/templates/pages/evaluations.html
|
||||||
msgid "Comments"
|
msgid "Comments"
|
||||||
msgstr "Commentaires"
|
msgstr "Commentaires"
|
||||||
|
|
||||||
|
#: app/templates/pages/player_evaluations.html
|
||||||
|
#: app/templates/pages/evaluations.html
|
||||||
|
msgid "No comments"
|
||||||
|
msgstr "Aucun commentaire"
|
||||||
|
|
||||||
#: app/templates/pages/evaluate_player.html:122
|
#: app/templates/pages/evaluate_player.html:122
|
||||||
msgid "Enter your evaluation notes..."
|
msgid "Enter your evaluation notes..."
|
||||||
msgstr "Vos notes d’évaluation..."
|
msgstr "Vos notes d’évaluation..."
|
||||||
|
|||||||
@@ -179,3 +179,61 @@ class TestThroughTheForm:
|
|||||||
evaluation = Evaluation.query.one()
|
evaluation = Evaluation.query.one()
|
||||||
assert evaluation.mecanics_score is None
|
assert evaluation.mecanics_score is None
|
||||||
assert evaluation.overall_score == 4
|
assert evaluation.overall_score == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchEvaluate:
|
||||||
|
"""The batch page drives the same two helpers the single-player form does,
|
||||||
|
through ``{field}_{player_id}`` names. The helpers were lost once, in a
|
||||||
|
refactor, and the page 500'd on every POST until someone noticed."""
|
||||||
|
|
||||||
|
def _submit(self, client, setup, **fields):
|
||||||
|
data = {'player_ids': [str(setup['player_id'])]}
|
||||||
|
data.update(fields)
|
||||||
|
return client.post(
|
||||||
|
f'/evaluations/{setup["tryout_id"]}/batch',
|
||||||
|
data=data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_valid_batch_evaluation_is_stored(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client,
|
||||||
|
evaluation_setup,
|
||||||
|
**{
|
||||||
|
f'mecanics_score_{pid}': '8',
|
||||||
|
f'mental_score_{pid}': '6',
|
||||||
|
f'comments_{pid}': 'Solid.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
evaluation = Evaluation.query.one()
|
||||||
|
assert evaluation.mecanics_score == 8
|
||||||
|
assert evaluation.overall_score == 7
|
||||||
|
assert evaluation.comments == 'Solid.'
|
||||||
|
assert evaluation.position_recommendation is None
|
||||||
|
|
||||||
|
def test_an_out_of_range_batch_score_stores_nothing(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client,
|
||||||
|
evaluation_setup,
|
||||||
|
**{f'mecanics_score_{pid}': '11', f'mental_score_{pid}': '6'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
assert Evaluation.query.count() == 0
|
||||||
|
|
||||||
|
def test_a_second_batch_submission_replaces_the_first(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client, evaluation_setup, **{f'mecanics_score_{pid}': '8', f'mental_score_{pid}': '4'}
|
||||||
|
)
|
||||||
|
self._submit(
|
||||||
|
client, evaluation_setup, **{f'mecanics_score_{pid}': '4', f'mental_score_{pid}': '4'}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
evaluation = Evaluation.query.one()
|
||||||
|
assert evaluation.overall_score == 4
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Player-specific evaluation views (the "By player" toggle and its page).
|
||||||
|
|
||||||
|
These pages regroup the flat evaluation table by player: one row per player in
|
||||||
|
the table view, and a dedicated page per player with left/right navigation and
|
||||||
|
per-criterion averages. Visibility follows the viewer's role — managers and
|
||||||
|
coaches see staff-authored evaluations inside their own tryouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Evaluation, Tryout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def staff_setup(app, as_role, make_user):
|
||||||
|
"""A coach who manages one tryout, plus staff, a player, and a foreign tryout."""
|
||||||
|
coach_id = as_role('coach', username='viewer_coach')
|
||||||
|
manager_id = make_user('manager', username='other_manager')
|
||||||
|
admin_id = make_user('admin', username='the_admin')
|
||||||
|
player_id = make_user('player', username='the_player')
|
||||||
|
outsider_id = make_user('coach', username='outsider_coach')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Spring',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 4, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
other_tryout = Tryout(
|
||||||
|
title='Other',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 5, 1),
|
||||||
|
created_by=outsider_id,
|
||||||
|
)
|
||||||
|
db.session.add(other_tryout)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'coach_id': coach_id,
|
||||||
|
'manager_id': manager_id,
|
||||||
|
'admin_id': admin_id,
|
||||||
|
'player_id': player_id,
|
||||||
|
'tryout_id': tryout.id,
|
||||||
|
'other_tryout_id': other_tryout.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisibility:
|
||||||
|
def test_coach_sees_staff_evaluations_only_in_their_tryout(self, app, client, staff_setup):
|
||||||
|
with app.app_context():
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=staff_setup['tryout_id'],
|
||||||
|
player_id=staff_setup['player_id'],
|
||||||
|
evaluator_id=staff_setup['manager_id'],
|
||||||
|
overall_score=7,
|
||||||
|
comments='manager in own tryout',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=staff_setup['tryout_id'],
|
||||||
|
player_id=staff_setup['player_id'],
|
||||||
|
evaluator_id=staff_setup['admin_id'],
|
||||||
|
overall_score=5,
|
||||||
|
comments='admin authored',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=staff_setup['other_tryout_id'],
|
||||||
|
player_id=staff_setup['player_id'],
|
||||||
|
evaluator_id=staff_setup['manager_id'],
|
||||||
|
overall_score=9,
|
||||||
|
comments='manager other tryout',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get(f"/evaluations/player/{staff_setup['player_id']}").get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'manager in own tryout' in body
|
||||||
|
assert 'admin authored' not in body
|
||||||
|
assert 'manager other tryout' not in body
|
||||||
|
|
||||||
|
def test_admin_sees_every_evaluation(self, app, client, as_role, make_user):
|
||||||
|
admin_id = as_role('admin', username='boss')
|
||||||
|
coach_id = make_user('coach', username='some_coach')
|
||||||
|
player_id = make_user('player', username='star')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='T',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=admin_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=coach_id,
|
||||||
|
overall_score=6,
|
||||||
|
comments='coach comment',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=admin_id,
|
||||||
|
overall_score=4,
|
||||||
|
comments='admin comment',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'coach comment' in body
|
||||||
|
assert 'admin comment' in body
|
||||||
|
|
||||||
|
def test_a_player_cannot_open_the_player_page(self, client, as_role):
|
||||||
|
player_id = as_role('player')
|
||||||
|
response = client.get(f'/evaluations/player/{player_id}', follow_redirects=False)
|
||||||
|
assert response.status_code in (301, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAggregates:
|
||||||
|
def test_combined_and_per_criterion_averages(self, app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach', username='agg_coach')
|
||||||
|
coach2_id = make_user('coach', username='agg_coach2')
|
||||||
|
player_id = make_user('player', username='agg_player')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Agg',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
first = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach_id)
|
||||||
|
first.apply_scores({'mecanics_score': 8, 'mental_score': 6})
|
||||||
|
second = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach2_id)
|
||||||
|
second.apply_scores({'mecanics_score': 4, 'mental_score': 8})
|
||||||
|
db.session.add_all([first, second])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
# overall: (7 + 6) / 2 = 6.5; mecanics: 6.0; mental: 7.0.
|
||||||
|
assert '6.5' in body
|
||||||
|
assert '6.0' in body
|
||||||
|
assert '7.0' in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestNavigation:
|
||||||
|
def test_players_are_ordered_best_first_with_arrows(self, app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach', username='order_coach')
|
||||||
|
high = make_user('player', username='zzz_high')
|
||||||
|
mid = make_user('player', username='aaa_mid')
|
||||||
|
low = make_user('player', username='mmm_low')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Order',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
for player_id, score in [(high, 8), (mid, 5), (low, 3)]:
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=coach_id,
|
||||||
|
overall_score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
best = client.get(f'/evaluations/player/{high}').get_data(as_text=True)
|
||||||
|
middle = client.get(f'/evaluations/player/{mid}').get_data(as_text=True)
|
||||||
|
worst = client.get(f'/evaluations/player/{low}').get_data(as_text=True)
|
||||||
|
|
||||||
|
# Best: no previous (left arrow disabled), next is the middle player.
|
||||||
|
assert 'aria-disabled="true"' in best
|
||||||
|
assert f'/evaluations/player/{mid}' in best
|
||||||
|
assert f'/evaluations/player/{low}' not in best
|
||||||
|
|
||||||
|
# Middle: both arrows active, pointing at the neighbours.
|
||||||
|
assert f'/evaluations/player/{high}' in middle
|
||||||
|
assert f'/evaluations/player/{low}' in middle
|
||||||
|
assert 'aria-disabled="true"' not in middle
|
||||||
|
|
||||||
|
# Worst: no next (right arrow disabled), previous is the middle player.
|
||||||
|
assert 'aria-disabled="true"' in worst
|
||||||
|
assert f'/evaluations/player/{mid}' in worst
|
||||||
|
assert f'/evaluations/player/{high}' not in worst
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableView:
|
||||||
|
def test_table_groups_by_player_and_keeps_the_comment(self, app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach', username='table_coach')
|
||||||
|
player_id = make_user('player', username='table_player')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='T',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=coach_id,
|
||||||
|
overall_score=7,
|
||||||
|
comments='a distinctive comment',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get('/evaluations').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'table_player' in body
|
||||||
|
assert f'/evaluations/player/{player_id}' in body
|
||||||
|
assert 'a distinctive comment' in body
|
||||||
Reference in New Issue
Block a user