refactor(validation): un schema a la frontiere des matchs

ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une
quarantaine de champs sur request.form a la main et les croyaient tous.

Ce que ca produisait n etait pas bruyant :

- edit_match attrapait une heure invalide et faisait start_time = None,
  puis annoncait que le match etait mis a jour. Le match perdait son
  heure et le calendrier l affichait a minuit ;
- match_type etait accepte tel quel. Une valeur inconnue creait un match
  auquel aucun joueur n etait rattache, sans un mot ;
- une fin avant le debut etait enregistree telle quelle ;
- title est NOT NULL dans le modele et n etait pas verifie dans la route,
  donc un titre vide etait un 500 ;
- 'a,b' dans la selection de joueurs arrivait sur int() sans garde.

app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans
users/_shared.py parce que c est la qu elles avaient d abord servi. Elles
y restent re-exportees, donc aucun des trente appels n a bouge.

Le mixin des schemas lit desormais un champ vide comme un champ absent.
C est ce qui rendait ces formulaires invalidables : un formulaire HTML
envoie tout ce qu il affiche, donc une date optionnelle non remplie
arrive comme '' et non comme rien. Seuls les champs declares optionnels
sont concernes ; un champ requis laisse vide doit toujours echouer.

Deux duplications absorbees au passage, toutes deux nommees par l audit :
la boucle de creation des participants, ecrite deux fois et deja divergee
— la copie de edit_match gardait ses identifiants en chaines et appelait
int() une ligne plus loin — et le contexte de re-affichage du formulaire,
dont les versions courtes faisaient mourir un refus dans tojson sur un
Undefined : un message de validation devenait un 500.

Limite connue et consignee : le formulaire revient rempli avec les
valeurs enregistrees, pas avec la saisie refusee. Reafficher la
soumission demande de toucher aux gabarits, c est un autre changement.

19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total.
This commit is contained in:
GGThed
2026-08-11 13:09:00 -04:00
parent d8541678a6
commit 0308eb9eef
6 changed files with 769 additions and 358 deletions
+47 -82
View File
@@ -3,13 +3,15 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime, timedelta
from datetime import datetime
from flask import Blueprint, flash, jsonify, 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 (
Admin,
Coach,
@@ -21,7 +23,9 @@ from app.models import (
TeamPlayer,
)
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
@@ -130,27 +134,16 @@ def create_match(team_id):
)
if request.method == 'POST':
title = request.form.get('title', default_title)
opponent = request.form.get('opponent', '').strip() if not is_practice else None
description = request.form.get('description', '')
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location', '')
if not date_str:
flash(_('Date is required.'), 'danger')
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
)
payload = form_payload(list_fields=(), optional_blank=())
# A practice has no opponent, whatever the form sent.
payload.setdefault('title', default_title)
if is_practice:
payload.pop('opponent', None)
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
data = TeamMatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html',
team=team,
@@ -159,36 +152,18 @@ def create_match(team_id):
is_practice=is_practice,
)
start_time = None
end_time = None
if start_time_str:
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time()
except ValueError:
flash(_('Invalid time format.'), 'danger')
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
start_time = data['start_time']
end_time = data['end_time'] or default_end_time(data['date'], start_time)
team_match = TeamMatch(
org_team_id=team_id,
title=title,
description=description or None,
opponent=opponent or None,
date=date_obj,
title=data['title'],
description=data['description'],
opponent=data['opponent'],
date=data['date'],
start_time=start_time,
end_time=end_time,
location=location or None,
location=data['location'],
created_by=current_user.id,
)
db.session.add(team_match)
@@ -208,7 +183,7 @@ def create_match(team_id):
notify_participants(
title=team_match.title,
date=date_obj,
date=team_match.date,
start_time=start_time,
end_time=end_time,
participants=zip_participants(
@@ -217,7 +192,9 @@ def create_match(team_id):
fallback_id=team_match.id,
)
flash(_('Team match "%(title)s" scheduled successfully!', title=title), 'success')
flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
)
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -235,42 +212,30 @@ def edit_match(match_id):
return redirect(url_for('team_matches.list_matches'))
if request.method == 'POST':
team_match.title = request.form.get('title', team_match.title)
team_match.description = request.form.get('description', '') or None
team_match.opponent = request.form.get('opponent', '').strip() or None
# The three date and time fields used to be checked one at a time,
# each flashing and redirecting on its own: a form with two mistakes
# took two round trips to be told about both. One schema now, every
# problem reported at once and in place.
#
# Known limit: the re-render reads the stored record, so what was
# typed is not echoed back. Repopulating the form from the
# submission is a separate change to the template.
try:
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
date_str = request.form.get('date')
if date_str:
try:
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
return redirect(url_for('team_matches.edit_match', match_id=match_id))
# Both used to `except ValueError: pass`, three lines below a date
# field that flashes and redirects. A mistyped time was therefore
# accepted by the form, discarded, and the old value kept — with the
# page reporting success. Same treatment as the date now.
start_time_str = request.form.get('start_time')
if start_time_str:
try:
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
except (ValueError, TypeError):
flash(_('Invalid start time format.'), 'danger')
return redirect(url_for('team_matches.edit_match', match_id=match_id))
end_time_str = request.form.get('end_time')
if end_time_str:
try:
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
except (ValueError, TypeError):
flash(_('Invalid end time format.'), 'danger')
return redirect(url_for('team_matches.edit_match', match_id=match_id))
team_match.location = request.form.get('location', '') or None
status = request.form.get('status')
if status in ['scheduled', 'completed', 'cancelled']:
team_match.status = status
team_match.title = data['title']
team_match.description = data['description']
team_match.opponent = data['opponent']
team_match.date = data['date']
team_match.start_time = data['start_time']
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
team_match.location = data['location']
team_match.status = data['status']
db.session.commit()
flash(_('Match updated successfully!'), 'success')