changement visualistation des evalutations
CI - Security, Lint & Tests / validate (push) Canceled after 0s

This commit is contained in:
cedrick2711
2026-09-09 12:15:25 -04:00
parent d994d88c12
commit 30069d782f
4 changed files with 700 additions and 122 deletions
+232 -61
View File
@@ -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')
@@ -63,95 +69,260 @@ def _apply_evaluation(evaluation, scores, comments, position):
evaluation.position_recommendation = position 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,
) )
+104 -53
View File
@@ -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,93 @@
</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>
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
</div>
</div>
{% endfor %}
</div>
{% endif %} {% endif %}
</div>
<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>{{ _('Comment') }}</th>
<td>{{ eval.discipline_score or '-' }}</td> <th>{{ _('Date') }}</th>
<td>{{ eval.analysis_score or '-' }}</td> </tr>
<td>{{ eval.sport_ethics_score or '-' }}</td> </thead>
<td>{{ eval.mental_score or '-' }}</td> <tbody>
<td><span class="score">{{ eval.overall_score or '-' }}</span></td> {% for ev in entry.evaluations %}
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td> <tr>
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td> <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 class="eval-comment">{{ ev.comment 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 + 6 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</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 +123,37 @@
</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; }
.eval-comment { max-width: 320px; white-space: pre-wrap; }
.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 %}
+108
View File
@@ -0,0 +1,108 @@
{% 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') }} &middot; {{ 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>{{ _('Comment') }}</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 class="eval-comment">{{ ev.comment or '-' }}</td>
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="{{ evaluation_criteria | length + 6 }}" 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>
</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; }
.eval-comment { max-width: 320px; white-space: pre-wrap; }
</style>
{% endblock %}
+248
View File
@@ -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