refactor(validation): un schema aux frontieres tryout et evaluation

ARCH-005, seconde moitie. Meme forme que pour les matchs : des champs lus
a la main sur request.form, deux verifies et le reste cru sur parole.

Cote tryout :
- game pilote la liste des postes et les champs de gamertag montres au
  joueur qui s inscrit. Il etait accepte tel quel : une faute de frappe
  produisait une selection pour laquelle personne ne pouvait etre evalue ;
- max_players etait int(x) if x else None — un 500 sur « twelve », et un
  -3 accepte sans broncher ;
- coach_ids etait charge par User.id.in_(...) sans filtre de role. Une
  soumission fabriquee a la main pouvait donc nommer un joueur coach d une
  selection, ce qui est une attribution de droits : gerer la selection et
  evaluer ses joueurs. Ce n est pas un formulaire que l interface propose,
  et ca marchait.

Cote evaluation, validate_score transformait tout ce qui sortait de 1..10
— 11, 0, « bien » — en None. Le critere disparaissait de la moyenne et la
page annoncait l evaluation enregistree. Rien ne distinguait « non
evalue » de « evalue, refuse, et oublie ».

Le calcul de la moyenne remonte sur le modele, en Evaluation.overall_from
et apply_scores. Il vivait dans la route, additionnant neuf variables
locales, et ne pouvait pas etre exerce sans requete HTTP, session
authentifiee et base — c est TEST-002, et c est pourquoi le calcul des
scores n avait aucun test. Il en a maintenant six, sans rien monter.

Une precision qui compte : aucun critere rempli donne None, pas 0. La
grille commence a 1, donc un zero serait une note qu aucun joueur ne peut
recevoir, et qui le classerait sous tout le monde dans la liste.

Les neuf criteres sont ecrits en toutes lettres dans le schema plutot que
generes depuis le modele — un schema se lit — et un test verifie que les
deux listes coincident. C est la garde qui empeche la derive, pas
l astuce.

Douze chaines traduites, dont trois que pybabel avait devinees en fuzzy :
une entree fuzzy est ignoree a l execution, le piege est consigne dans
docs/translations.md.

30 tests neufs. 477 au total.
This commit is contained in:
GGThed
2026-08-11 13:35:05 -04:00
parent 0308eb9eef
commit c5e5cfa014
10 changed files with 866 additions and 454 deletions
+71 -139
View File
@@ -9,8 +9,10 @@ from datetime import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
ESPORT_GAMES,
GAME_POSITIONS,
@@ -30,6 +32,7 @@ from app.models import (
TryoutRegistration,
User,
)
from app.validators import TryoutSchema
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -39,6 +42,24 @@ def can_manage():
return isinstance(current_user, (Admin, Manager))
def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids',), optional_blank=())
def coaches_from_ids(coach_ids):
"""The coach accounts behind these ids.
Filtered by role, which the previous `User.id.in_(...)` was not: the form
posts a list of ids and nothing stopped a hand-made submission from
naming a player, who then appeared as a coach of the tryout and inherited
every permission that comes with it.
"""
if not coach_ids:
return []
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def _users_by_id(user_ids):
"""Load these users in one query, keyed by id.
@@ -85,89 +106,46 @@ def create_tryout():
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
def rerender():
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid start date format.'), 'danger')
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash(_('End date cannot be before start date.'), 'danger')
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
except (ValueError, TypeError):
flash(_('Invalid end date format.'), 'danger')
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
data = TryoutSchema().load(tryout_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
tryout = Tryout(
title=title,
description=description,
game=game,
date=date_obj,
end_date=end_date_obj,
location=location,
max_players=int(max_players) if max_players else None,
title=data['title'],
description=data['description'],
game=data['game'],
date=data['date'],
end_date=data['end_date'],
location=data['location'],
max_players=data['max_players'],
created_by=current_user.id,
status='upcoming',
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
manager_id=int(manager_id) if manager_id else None,
target_org_team_id=data['target_org_team_id'],
manager_id=data['manager_id'],
)
db.session.add(tryout)
db.session.flush()
# Assign coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
tryout.coaches = coaches_from_ids(data['coach_ids'])
db.session.commit()
flash(_('Tryout created successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
return rerender()
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@@ -192,85 +170,39 @@ def edit_tryout(tryout_id):
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
)
def rerender():
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid start date format.'), 'danger')
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
data = TryoutSchema().load(tryout_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash(_('End date cannot be before start date.'), 'danger')
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
except (ValueError, TypeError):
flash(_('Invalid end date format.'), 'danger')
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
tryout.title = title
tryout.description = description
tryout.game = game
tryout.date = date_obj
tryout.end_date = end_date_obj
tryout.location = location
tryout.max_players = int(max_players) if max_players else None
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
tryout.manager_id = int(manager_id) if manager_id else None
# Update coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
else:
tryout.coaches = []
tryout.title = data['title']
tryout.description = data['description']
tryout.game = data['game']
tryout.date = data['date']
tryout.end_date = data['end_date']
tryout.location = data['location']
tryout.max_players = data['max_players']
tryout.target_org_team_id = data['target_org_team_id']
tryout.manager_id = data['manager_id']
tryout.coaches = coaches_from_ids(data['coach_ids'])
db.session.commit()
flash(_('Tryout updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
return rerender()
@tryouts_bp.route('/<int:tryout_id>')