4 Commits
Author SHA1 Message Date
cedrick2711 bd9ed4bdc7 changer le look du calendar légèrement et régler problème avec déconnection des test
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-10 10:43:47 -04:00
cedrick2711 a9b33b30f5 forcer l'affichage des nombre un jusqu'a un chiffre apres la virgule
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 13:54:51 -04:00
cedrick2711 32c112ceea Finit les changement de visualisation des evaluations
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 13:40:35 -04:00
cedrick2711 30069d782f changement visualistation des evalutations
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 12:15:25 -04:00
12 changed files with 1011 additions and 147 deletions
+18 -4
View File
@@ -324,8 +324,9 @@ def _create_database():
def _stop(*_args): def _stop(*_args):
"""Stop the PostgreSQL server with a fast shutdown. """Stop the PostgreSQL server with a fast shutdown.
Registered as ``atexit`` and signal handler so it fires on Ctrl+C, Registered as an ``atexit`` handler so it fires on normal exit. Signal
normal exit, and terminal close. handling goes through ``_handle_stop_signal``, which also stops the
server but then re-raises so the process actually exits.
""" """
global _started global _started
global _stopped_system_service_name global _stopped_system_service_name
@@ -349,6 +350,19 @@ def _stop(*_args):
_start_system_service(srv) _start_system_service(srv)
def _handle_stop_signal(signum, frame):
"""Stop PostgreSQL, then let the interpreter shut down normally.
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
system service, then returns without exiting, leaving the dev server
running against a stopped database and the terminal frozen. Re-raising
``KeyboardInterrupt`` hands control back to the framework, which exits;
the ``atexit`` registration then runs ``_stop`` again (idempotent).
"""
_stop()
raise KeyboardInterrupt
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -420,9 +434,9 @@ def start() -> str:
_started = True _started = True
atexit.register(_stop) atexit.register(_stop)
with contextlib.suppress(ValueError, OSError): with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGINT, _handle_stop_signal)
with contextlib.suppress(AttributeError, ValueError, OSError): with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined] signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL) logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
return DATABASE_URL return DATABASE_URL
+239 -68
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, db.session.query(
'player': player_alias.username, Evaluation.player_id,
'evaluator': evaluator_alias.username, func.count(Evaluation.id).label('eval_count'),
'mecanics_score': Evaluation.mecanics_score, func.avg(Evaluation.overall_score).label('avg_score'),
'cohesion_score': Evaluation.cohesion_score, func.max(Evaluation.created_at).label('last_eval'),
'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 = ( .filter(Evaluation.id.in_(visible_ids))
db.session.query( .group_by(Evaluation.player_id)
Evaluation.player_id, .all()
func.count(Evaluation.id).label('eval_count'), )
func.avg(Evaluation.overall_score).label('avg_score'),
) players_by_id = _users_by_id(row.player_id for row in aggregate)
.group_by(Evaluation.player_id) 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() .all()
) )
players_by_id = _users_by_id(row.player_id for row in avg_scores) evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
player_scores = {} tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
for row in avg_scores: tryouts_by_id = (
p = players_by_id.get(row.player_id) {t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
if p: if tryout_ids
player_scores[p.id] = { else {}
'player': p,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
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 = {} 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,
) )
-4
View File
@@ -224,7 +224,6 @@ def api_events():
for match in matches: for match in matches:
tryout = tryouts_by_id[match.tryout_id] tryout = tryouts_by_id[match.tryout_id]
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# 'description' used to be participants_str + '<br>' + description. # 'description' used to be participants_str + '<br>' + description.
# Building presentation markup inside a JSON field is what carried # Building presentation markup inside a JSON field is what carried
# the stored XSS: the browser dropped it straight into innerHTML, # the stored XSS: the browser dropped it straight into innerHTML,
@@ -255,7 +254,6 @@ def api_events():
'id': f'match_{match.id}', 'id': f'match_{match.id}',
'title': match.title, 'title': match.title,
'type': 'match', 'type': 'match',
'color': match_color,
'extendedProps': { 'extendedProps': {
'location': match.location or tryout.location or 'TBD', 'location': match.location or tryout.location or 'TBD',
'status': match.status, 'status': match.status,
@@ -301,7 +299,6 @@ def api_events():
'id': f'team_match_{team_match.id}', 'id': f'team_match_{team_match.id}',
'title': team_match.title, 'title': team_match.title,
'type': 'team_match', 'type': 'team_match',
'color': '#3b82f6',
'extendedProps': { 'extendedProps': {
'location': team_match.location or 'TBD', 'location': team_match.location or 'TBD',
'status': team_match.status, 'status': team_match.status,
@@ -334,7 +331,6 @@ def api_events():
'id': f'one_on_one_{ooo.id}', 'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'type': 'one_on_one', 'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': { 'extendedProps': {
'location': 'Discord / Voice Chat', 'location': 'Discord / Voice Chat',
'status': 'approved', 'status': 'approved',
+189 -15
View File
@@ -1043,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
.fc { .fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--fc-border-color: var(--gray-200);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: var(--gray-50);
--fc-today-bg-color: var(--info-light);
} }
.fc-toolbar { .fc-toolbar {
@@ -1055,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
color: var(--gray-900); color: var(--gray-900);
} }
.fc-button { .fc .fc-button {
background: var(--primary) !important; background: var(--gray-50) !important;
border: none !important; border: 1px solid var(--gray-200) !important;
color: var(--gray-700) !important;
border-radius: var(--radius-sm) !important; border-radius: var(--radius-sm) !important;
padding: 6px 12px !important; padding: 6px 12px !important;
font-size: 0.85rem !important; font-size: 0.85rem !important;
} }
.fc-button:hover { .fc .fc-button:hover {
background: var(--primary-dark) !important; background: var(--gray-100) !important;
color: var(--gray-900) !important;
} }
.fc-button-primary:not(:disabled).fc-button-active, .fc .fc-button-primary:not(:disabled).fc-button-active,
.fc-button-primary:not(:disabled):active { .fc .fc-button-primary:not(:disabled):active {
background: var(--primary-dark) !important; background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
} }
.fc-daygrid-day { .fc-daygrid-day,
.fc-timegrid-slot {
transition: var(--transition); transition: var(--transition);
} }
@@ -1080,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
background: var(--gray-50); background: var(--gray-50);
} }
.fc-event { /* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
border-radius: var(--radius-sm); its inline event colors) */
padding: 2px 4px; #calendar .fc-event {
font-size: 0.8rem; border: none !important;
border-radius: var(--radius-sm) !important;
padding: 3px 6px !important;
font-size: 0.8rem !important;
background-color: var(--fc-event-bg, var(--primary)) !important;
color: #fff !important;
box-shadow: var(--shadow);
} }
.fc-event-title { #calendar .fc-event-body {
font-weight: 600; display: flex;
align-items: center;
gap: 6px;
min-width: 0;
} }
#calendar .fc-event-body i {
flex: 0 0 auto;
font-size: 0.7rem;
opacity: 0.9;
}
#calendar .fc-event-text {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 4px;
min-width: 0;
}
#calendar .fc-event-time {
font-size: 0.72rem;
font-weight: 700;
}
#calendar .fc-event-title {
font-weight: 600 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Event type colors (semantic, light theme) */
#calendar .fc-event--team { --fc-event-bg: #10b981; }
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
#calendar .fc-now-indicator {
border-color: var(--danger);
}
/* Dark theme: the calendar chrome reacts to the light/dark switch */
[data-theme="dark"] .fc {
--fc-border-color: var(--border-color);
--fc-neutral-bg-color: var(--bg-secondary);
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .fc-toolbar-title {
color: var(--text-primary);
}
[data-theme="dark"] .fc .fc-button {
background: var(--bg-tertiary) !important;
border-color: var(--border-color) !important;
color: var(--text-secondary) !important;
}
[data-theme="dark"] .fc .fc-button:hover {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
[data-theme="dark"] .fc-daygrid-day:hover {
background: var(--bg-tertiary);
}
[data-theme="dark"] .fc-daygrid-day-number,
[data-theme="dark"] .fc-col-header-cell-cushion,
[data-theme="dark"] .fc-timegrid-axis-cushion,
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
color: var(--text-secondary);
}
/* Pastel event chips with dark text keep contrast on a dark background. */
[data-theme="dark"] #calendar .fc-event {
color: var(--gray-900) !important;
}
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
.btn-group { .btn-group {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -2122,3 +2226,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);
}
+47 -2
View File
@@ -137,12 +137,18 @@ document.addEventListener('DOMContentLoaded', function() {
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
center: 'title', center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth' right: ''
}, },
events: '/matches/api/events', events: '/matches/api/events',
eventClick: function(info) { eventClick: function(info) {
showEventModal(info.event); showEventModal(info.event);
}, },
eventClassNames: function(arg) {
return [eventClassName(arg.event)];
},
eventContent: function(arg) {
return renderEventContent(arg);
},
dateClick: function(info) { dateClick: function(info) {
if (canScheduleMatches) { if (canScheduleMatches) {
var dateStr = info.dateStr; var dateStr = info.dateStr;
@@ -158,7 +164,11 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}, },
slotMinTime: '12:00:00', slotMinTime: '12:00:00',
slotMaxTime: '24:00:00' slotMaxTime: '24:00:00',
slotEventOverlap: true,
dayMaxEvents: false,
nowIndicator: true,
height: 'auto'
}); });
calendar.render(); calendar.render();
@@ -177,6 +187,41 @@ function changeView(viewName) {
} }
} }
function eventClassName(event) {
var props = event.extendedProps || {};
if (props.type === 'team_match') { return 'fc-event--team-match'; }
if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; }
return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim';
}
function eventIcon(type, matchType) {
if (type === 'team_match') { return 'fa-trophy'; }
if (type === 'one_on_one') { return 'fa-user-friends'; }
if (matchType === 'team_vs_team') { return 'fa-shield-alt'; }
if (matchType === 'player_vs_player') { return 'fa-users'; }
return 'fa-gamepad';
}
function renderEventContent(arg) {
// Built with DOM nodes + textContent: the title comes from a JSON endpoint
// and has never been HTML-escaped, so it must not reach innerHTML.
var props = arg.event.extendedProps || {};
var wrap = makeEl('div', 'fc-event-body');
var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type));
icon.setAttribute('aria-hidden', 'true');
wrap.appendChild(icon);
var text = makeEl('span', 'fc-event-text');
if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) {
text.appendChild(makeEl('span', 'fc-event-time', arg.timeText));
}
text.appendChild(makeEl('span', 'fc-event-title', arg.event.title));
wrap.appendChild(text);
return { domNodes: [wrap] };
}
// --- Create Event Modal --- // --- Create Event Modal ---
function showCreateEventModal(dateStr) { function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr; document.getElementById('createEventDate').textContent = dateStr;
+122 -54
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,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> <tr class="eval-row" data-action="toggle-player-evals" data-target="evals-{{ entry.player.id }}">
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td> <td><i class="fas fa-chevron-right eval-chevron"></i></td>
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td> <td>
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td> <a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="player-link">
<td>{{ eval.mecanics_score or '-' }}</td> {{ entry.player.username }}
<td>{{ eval.cohesion_score or '-' }}</td> </a>
<td>{{ eval.communication_score or '-' }}</td> </td>
<td>{{ eval.gamesense_score or '-' }}</td> <td><span class="score">{{ entry.avg | round(1) if entry.avg is not none else '-' }}</span></td>
<td>{{ eval.versatility_score or '-' }}</td> <td>{{ entry.count }}</td>
<td>{{ eval.discipline_score or '-' }}</td> <td>
<td>{{ eval.analysis_score or '-' }}</td> <a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
<td>{{ eval.sport_ethics_score or '-' }}</td> <i class="fas fa-external-link-alt"></i>
<td>{{ eval.mental_score or '-' }}</td> </a>
<td><span class="score">{{ eval.overall_score or '-' }}</span></td> </td>
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td> </tr>
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td> <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>
<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 entry.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 | round(1) 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">{{ _('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') }}&nbsp;: <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') }} &middot; {{ 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> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="15" class="text-center"> <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 %}
+134
View File
@@ -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 | round(1) 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 | round(1) 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 | round(1) 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') }}&nbsp;: <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') }} &middot; {{ 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..."
+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