Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards
This commit is contained in:
@@ -20,6 +20,7 @@ from app.models._constants import (
|
|||||||
GAME_PLATFORMS,
|
GAME_PLATFORMS,
|
||||||
PLATFORM_CODES,
|
PLATFORM_CODES,
|
||||||
TRN_URLS,
|
TRN_URLS,
|
||||||
|
EVALUATION_CRITERIA,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
|
|||||||
|
|
||||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||||
|
|
||||||
|
# Ordered list of (field_name, human_label) pairs for the player evaluation
|
||||||
|
# score criteria. Kept in a single place so the evaluation forms, batch
|
||||||
|
# evaluation page, and any future reporting all stay in sync.
|
||||||
|
EVALUATION_CRITERIA = [
|
||||||
|
('mecanics_score', 'Mecanics'),
|
||||||
|
('cohesion_score', 'Cohesion'),
|
||||||
|
('communication_score', 'Communication'),
|
||||||
|
('gamesense_score', 'Gamesense'),
|
||||||
|
('versatility_score', 'Versatility'),
|
||||||
|
('discipline_score', 'Discipline'),
|
||||||
|
('analysis_score', 'Analysis'),
|
||||||
|
('sport_ethics_score', 'Sport Ethics'),
|
||||||
|
('mental_score', 'Mental'),
|
||||||
|
]
|
||||||
|
|
||||||
ESPORT_GAMES = [
|
ESPORT_GAMES = [
|
||||||
'Valorant',
|
'Valorant',
|
||||||
'League of Legends',
|
'League of Legends',
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ 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 flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import (
|
||||||
|
Admin, Coach, Manager, Player,
|
||||||
|
User, Tryout, Evaluation, TryoutRegistration,
|
||||||
|
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
|
||||||
|
)
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
@@ -27,6 +35,34 @@ from app.validators import EvaluationSchema
|
|||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||||
|
|
||||||
|
|
||||||
|
def validate_score(score_value):
|
||||||
|
"""Validate that a score is between 1 and 10."""
|
||||||
|
if score_value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
score = int(score_value)
|
||||||
|
if 1 <= score <= 10:
|
||||||
|
return score
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_overall(scores):
|
||||||
|
"""Average the non-None scores, or return None if there are none."""
|
||||||
|
valid = [s for s in scores if s is not None]
|
||||||
|
return sum(valid) / len(valid) if valid else None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_evaluation(evaluation, scores, comments, position):
|
||||||
|
"""Write validated scores/comments/position onto an Evaluation instance."""
|
||||||
|
for field_name, _ in EVALUATION_CRITERIA:
|
||||||
|
setattr(evaluation, field_name, scores[field_name])
|
||||||
|
evaluation.overall_score = compute_overall(list(scores.values()))
|
||||||
|
evaluation.comments = comments
|
||||||
|
evaluation.position_recommendation = position
|
||||||
|
|
||||||
|
|
||||||
@evaluations_bp.route('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
@@ -228,3 +264,94 @@ def players_to_evaluate(tryout_id):
|
|||||||
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
||||||
|
|
||||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||||
|
|
||||||
|
|
||||||
|
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def batch_evaluate(tryout_id):
|
||||||
|
"""Evaluate multiple players at once in a tryout.
|
||||||
|
|
||||||
|
GET renders a single form listing every selected player with their
|
||||||
|
evaluation criteria. POST saves (creates or updates) all of them.
|
||||||
|
"""
|
||||||
|
if not current_user.can_evaluate():
|
||||||
|
flash('You do not have permission to evaluate players.', 'danger')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
tryout = Tryout.query.get_or_404(tryout_id)
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||||
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
|
# Resolve selected player ids (query string on GET, hidden fields on POST).
|
||||||
|
player_ids = []
|
||||||
|
for raw in request.values.getlist('player_ids'):
|
||||||
|
try:
|
||||||
|
pid = int(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if pid not in player_ids:
|
||||||
|
player_ids.append(pid)
|
||||||
|
|
||||||
|
if not player_ids:
|
||||||
|
flash('Please select at least one player to evaluate.', 'warning')
|
||||||
|
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
players = []
|
||||||
|
for pid in player_ids:
|
||||||
|
player = User.query.get(pid)
|
||||||
|
if not player or not isinstance(player, Player):
|
||||||
|
continue
|
||||||
|
is_registered = TryoutRegistration.query.filter_by(
|
||||||
|
tryout_id=tryout_id, player_id=pid,
|
||||||
|
).first() is not None
|
||||||
|
if not is_registered:
|
||||||
|
continue
|
||||||
|
existing = Evaluation.query.filter_by(
|
||||||
|
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
|
||||||
|
).first()
|
||||||
|
existing_scores = {
|
||||||
|
field_name: getattr(existing, field_name) if existing else None
|
||||||
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
|
}
|
||||||
|
players.append({
|
||||||
|
'player': player,
|
||||||
|
'existing': existing,
|
||||||
|
'existing_scores': existing_scores,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not players:
|
||||||
|
flash('No valid players selected for evaluation.', 'danger')
|
||||||
|
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
saved = 0
|
||||||
|
for entry in players:
|
||||||
|
pid = entry['player'].id
|
||||||
|
scores = {
|
||||||
|
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||||
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
|
}
|
||||||
|
comments = request.form.get(f'comments_{pid}')
|
||||||
|
position = request.form.get(f'position_recommendation_{pid}')
|
||||||
|
|
||||||
|
existing = entry['existing']
|
||||||
|
if existing:
|
||||||
|
_apply_evaluation(existing, scores, comments, position)
|
||||||
|
else:
|
||||||
|
evaluation = Evaluation(
|
||||||
|
tryout_id=tryout_id, player_id=pid,
|
||||||
|
evaluator_id=current_user.id,
|
||||||
|
)
|
||||||
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
|
db.session.add(evaluation)
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
return render_template('pages/batch_evaluate.html',
|
||||||
|
tryout=tryout, players=players,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
game_positions=GAME_POSITIONS)
|
||||||
|
|||||||
+1270
File diff suppressed because it is too large
Load Diff
@@ -2037,3 +2037,30 @@ a:hover { color: var(--primary-dark); }
|
|||||||
.honeypot {
|
.honeypot {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Batch Evaluation - 2 cards wide layout */
|
||||||
|
.batch-eval-form {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-eval-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-eval-grid .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
|
||||||
|
.batch-eval-grid .form-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.batch-eval-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
|
||||||
|
{% block page_title %}Evaluate Players{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
{% for entry in players %}
|
||||||
|
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set positions = game_positions.get(tryout.game, []) %}
|
||||||
|
|
||||||
|
<div class="batch-eval-grid">
|
||||||
|
{% for entry in players %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-user"></i> {{ entry.player.username }}
|
||||||
|
{% if entry.existing %}
|
||||||
|
<span class="badge badge-success">Already Evaluated</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">Not Evaluated</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="eval-player-info mb-4">
|
||||||
|
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
|
||||||
|
<div>
|
||||||
|
<h3>{{ entry.player.username }}</h3>
|
||||||
|
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set pid = entry.player.id %}
|
||||||
|
{% set existing = entry.existing %}
|
||||||
|
{% set existing_scores = entry.existing_scores %}
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
{% for field_name, label in evaluation_criteria %}
|
||||||
|
<div class="form-group col-4">
|
||||||
|
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
|
||||||
|
<div class="score-input">
|
||||||
|
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||||
|
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-12">
|
||||||
|
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
|
||||||
|
{% if positions %}
|
||||||
|
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
|
||||||
|
<option value="">-- Select Position --</option>
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="comments_{{ pid }}">Comments</label>
|
||||||
|
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Save All Evaluations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,23 +6,29 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
|
<h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
|
||||||
|
<form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ _('Player') }}</th>
|
<th><input type="checkbox" id="select-all" onclick="toggleAll(this)"></th>
|
||||||
<th>{{ _('Contact') }}</th>
|
<th>Player</th>
|
||||||
<th>{{ _('Attendance') }}</th>
|
<th>Contact</th>
|
||||||
<th>{{ _('Status') }}</th>
|
<th>Attendance</th>
|
||||||
<th>{{ _('Actions') }}</th>
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in players %}
|
{% for entry in players %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="user-mini">
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||||
@@ -41,19 +47,35 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
|
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
|
||||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
|
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
|
<td colspan="6" class="text-center">No players registered for this tryout.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-clipboard-check"></i> Evaluate Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleAll(master) {
|
||||||
|
var boxes = document.querySelectorAll('.player-check');
|
||||||
|
for (var i = 0; i < boxes.length; i++) {
|
||||||
|
boxes[i].checked = master.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -193,11 +193,8 @@
|
|||||||
<p class="text-muted">{{ _('Loading...') }}</p>
|
<p class="text-muted">{{ _('Loading...') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
|
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
|
||||||
<i class="fas fa-save"></i> {{ _('Save Disponibilities') }}
|
<i class="fas fa-trash"></i> Clear All
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
|
|
||||||
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +366,7 @@ function saveCoachAvailability() {
|
|||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.className = 'alert alert-success';
|
msg.className = 'alert alert-success';
|
||||||
msg.style.marginTop = '10px';
|
msg.style.marginTop = '10px';
|
||||||
msg.innerHTML = '<i class="fas fa-check"></i> Availability saved!';
|
msg.innerHTML = '<span><i class="fas fa-check"></i> Availability saved!</span>';
|
||||||
document.getElementById('availability-grid').appendChild(msg);
|
document.getElementById('availability-grid').appendChild(msg);
|
||||||
setTimeout(() => msg.remove(), 3000);
|
setTimeout(() => msg.remove(), 3000);
|
||||||
}
|
}
|
||||||
@@ -571,7 +568,7 @@ function saveDisponibilities() {
|
|||||||
var msg = document.createElement('div');
|
var msg = document.createElement('div');
|
||||||
msg.className = 'alert alert-success';
|
msg.className = 'alert alert-success';
|
||||||
msg.style.marginTop = '10px';
|
msg.style.marginTop = '10px';
|
||||||
msg.innerHTML = '<i class="fas fa-check"></i> Disponibilities saved successfully!';
|
msg.innerHTML = '<span><i class="fas fa-check"></i> Disponibilities saved successfully!</span>';
|
||||||
document.getElementById('disponibilities-grid').appendChild(msg);
|
document.getElementById('disponibilities-grid').appendChild(msg);
|
||||||
setTimeout(function() { msg.remove(); }, 3000);
|
setTimeout(function() { msg.remove(); }, 3000);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user