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
+63
View File
@@ -0,0 +1,63 @@
"""The boundary between an HTTP form and a validated payload (ARCH-005).
Every POST in this application arrives as a `werkzeug.MultiDict` of strings.
Turning that into typed, checked values was done inline, differently, in each
route: `int(x) if x else None` here, `datetime.strptime` inside a bare `try`
there, and in several places not at all. The failures that produced were not
loud ones — a bad time silently became `None` and the page said the match had
been updated.
Two functions here, one schema module next to them (`app.validators`):
payload = form_payload(list_fields=('player_ids',))
try:
data = MatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
Both were originally inside `app/routes/users/_shared.py`, which is where
they were first needed. They are re-exported from there so that nothing had
to be renamed when the match and tryout routes started using them too.
"""
from flask import flash, request
from flask_babel import gettext as _
def flash_validation_errors(err):
"""Surface marshmallow errors, one flash per problem.
The uniform reporting half of ARCH-005: before this, a bad date flashed
'Invalid date format.' from one route, redirected from another, and was
silently dropped by a third.
"""
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)):
"""Turn the multi-valued request form into a plain dict for marshmallow.
request.form.to_dict() keeps only the first value of a repeated key, so
list fields have to be re-read with getlist(). Unchecked HTML checkboxes
are simply absent from the submission, which is not the same as a schema
default, so they are injected explicitly. Blank optional fields are
dropped rather than sent as '' — an empty password means "leave the
current one alone", not "set the password to the empty string".
Args:
checkboxes: Names to report as True/False on presence.
list_fields: Names to read with getlist(), always producing a list.
optional_blank: Names to drop entirely when submitted empty.
"""
payload = request.form.to_dict()
for name in list_fields:
payload[name] = request.form.getlist(name)
for name in checkboxes:
payload[name] = name in request.form
for name in optional_blank:
if not payload.get(name):
payload.pop(name, None)
return payload
+143 -241
View File
@@ -8,9 +8,11 @@ from datetime import datetime, timedelta
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 sqlalchemy.orm import joinedload
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin,
Coach,
@@ -29,10 +31,62 @@ from app.models import (
User,
)
from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
def match_form_payload():
"""The match form, shaped for marshmallow.
`player_ids` is a repeated checkbox, so it needs getlist(); `games` — the
default list field — has nothing to do with this form.
"""
return form_payload(list_fields=('player_ids',), optional_blank=())
#: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30
def default_end_time(date, start_time):
"""End time for a match whose form left it blank."""
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
def create_participants(match, data):
"""Attach participants to a match, per its type.
Was written out twice, in create_match and in edit_match, and had already
drifted: the copy in edit_match kept its player ids as strings and called
int() on them one line later, the one in create_match did not (ARCH-005).
Returns:
tuple: (player ids to notify, the participant rows created).
"""
sides = []
if match.match_type == 'team_vs_team':
for side, team_id in ((1, match.team1_id), (2, match.team2_id)):
if team_id:
members = TeamMember.query.filter_by(team_id=team_id).all()
sides.append((side, [m.player_id for m in members]))
elif match.match_type == 'player_vs_player':
sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])]
elif match.match_type == 'player_scrim':
sides = [(None, data['player_ids'])]
player_ids = []
participant_ids = []
for side, ids in sides:
for player_id in ids:
participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side)
db.session.add(participant)
db.session.flush()
participant_ids.append(participant.id)
player_ids.append(player_id)
return player_ids, participant_ids
def can_schedule_match():
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
return isinstance(current_user, (Admin, Manager, Coach, Scout))
@@ -319,129 +373,53 @@ def create_match(tryout_id):
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
prefill_date = request.args.get('date', '')
def rerender():
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
if request.method == 'POST':
title = request.form.get('title')
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')
match_type = request.form.get('match_type')
if not start_time_str:
flash(_('Start time is required. Please select a time slot.'), 'danger')
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
payload = match_form_payload()
# A tryout match with no date of its own happens on the tryout's day.
payload.setdefault('date', tryout.date.isoformat())
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
start_time = None
end_time = None
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/match_form.html', tryout=tryout, teams=teams, all_players=all_players
)
data = MatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return rerender()
match = Match(
tryout_id=tryout_id,
title=title,
description=description,
date=date_obj,
start_time=start_time,
end_time=end_time,
location=location,
match_type=match_type,
title=data['title'],
description=data['description'],
date=data['date'],
start_time=data['start_time'],
end_time=data['end_time'] or default_end_time(data['date'], data['start_time']),
location=data['location'],
match_type=data['match_type'],
created_by=current_user.id,
)
db.session.add(match)
db.session.flush()
notified_player_ids = []
notified_participant_ids = []
if data['match_type'] == 'team_vs_team':
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
if match_type == 'team_vs_team':
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
match.team1_id = int(team1_id) if team1_id else None
match.team2_id = int(team2_id) if team2_id else None
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=1
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=2
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
elif match_type == 'player_vs_player':
team1_player_ids = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.get('team2_player_ids', '')
team1_ids = (
[int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
)
team2_ids = (
[int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
)
for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in team2_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = team1_ids + team2_ids
elif match_type == 'player_scrim':
player_ids = request.form.getlist('player_ids')
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in player_ids]
notified_player_ids, notified_participant_ids = create_participants(match, data)
db.session.commit()
notify_participants(
title=match.title,
date=date_obj,
start_time=start_time,
end_time=end_time,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=zip_participants(notified_player_ids, notified_participant_ids),
fallback_id=match.id,
)
@@ -449,13 +427,7 @@ def create_match(tryout_id):
flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
return rerender()
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@@ -481,126 +453,74 @@ def edit_match(match_id):
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
def rerender():
"""The form, with everything the template needs.
One context, used by the GET and by a rejected POST alike. The
rejection paths used to pass a shorter list, and match_form.html
serialises participants_map into a <script> block — so a rejected
edit died in `tojson` on an Undefined, turning a validation message
into a 500.
"""
participants_map = {
p.player_id: {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side,
}
for p in match.participants.all()
}
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map,
)
if request.method == 'POST':
match.title = request.form.get('title')
match.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')
status = request.form.get('status')
try:
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
)
data = MatchEditSchema().load(match_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
if not start_time_str:
flash(_('Start time is required.'), 'danger')
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
)
try:
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(match.date, match.start_time)
end_dt = start_dt + timedelta(minutes=30)
match.end_time = end_dt.time()
except ValueError:
match.start_time = None
match.location = location
if status in ['scheduled', 'completed', 'cancelled']:
match.status = status
# Assigned only once the whole form has been accepted. Assigning as
# each field was read meant a form rejected halfway had already
# changed the record in the session.
match.title = data['title']
match.description = data['description']
match.date = data['date']
match.start_time = data['start_time']
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
match.location = data['location']
match.status = data['status']
notified_player_ids = []
notified_participant_ids = []
if match.match_type == 'team_vs_team':
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
new_team1_id = int(team1_id) if team1_id else None
new_team2_id = int(team2_id) if team2_id else None
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
if teams_changed:
MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = new_team1_id
match.team2_id = new_team2_id
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=1
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=2
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data)
else:
if match.team1_id:
notified_player_ids.extend(
[
m.player_id
for m in TeamMember.query.filter_by(team_id=match.team1_id).all()
]
)
if match.team2_id:
notified_player_ids.extend(
[
m.player_id
for m in TeamMember.query.filter_by(team_id=match.team2_id).all()
]
)
elif match.match_type == 'player_vs_player':
# Same teams: the roster stands, but everyone is told again,
# because the date or the time may have moved.
for team_id in (match.team1_id, match.team2_id):
if team_id:
notified_player_ids.extend(
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
)
else:
MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_str = request.form.get('team1_player_ids', '')
team2_str = request.form.get('team2_player_ids', '')
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
for pid in t1_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in t2_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
elif match.match_type == 'player_scrim':
MatchParticipant.query.filter_by(match_id=match.id).delete()
player_ids = request.form.getlist('player_ids')
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in player_ids]
notified_player_ids, notified_participant_ids = create_participants(match, data)
db.session.commit()
@@ -616,25 +536,7 @@ def edit_match(match_id):
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
participants_map = {}
for p in match.participants.all():
participants_map[p.player_id] = {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side,
}
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map,
)
return rerender()
@matches_bp.route('/api/manageable-tryouts')
+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')
+6 -29
View File
@@ -4,10 +4,15 @@ Nothing here touches the blueprint: these are plain functions, so a test
can call them with a request context and nothing else.
"""
from flask import flash, request
from flask import request
from flask_babel import gettext as _
from app.extensions import db
# Re-exported: these two moved to app/forms.py once the match and tryout
# routes needed them as well (ARCH-005). Importing them from here still
# works, so the thirty call sites in this package did not have to move.
from app.forms import flash_validation_errors, form_payload # noqa: F401
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
@@ -57,34 +62,6 @@ def pdf_upload_error(file, allowed_extensions):
return None
def flash_validation_errors(err):
"""Surface marshmallow errors the same way auth.py already does."""
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)):
"""Turn the multi-valued request form into a plain dict for marshmallow.
request.form.to_dict() keeps only the first value of a repeated key, so
list fields have to be re-read with getlist(). Unchecked HTML checkboxes
are simply absent from the submission, which is not the same as a schema
default, so they are injected explicitly. Blank optional fields are
dropped rather than sent as '' — an empty password means "leave the
current one alone", not "set the password to the empty string".
"""
payload = request.form.to_dict()
for name in list_fields:
payload[name] = request.form.getlist(name)
for name in checkboxes:
payload[name] = name in request.form
for name in optional_blank:
if not payload.get(name):
payload.pop(name, None)
return payload
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
existing_gamertags = {gt.game: gt for gt in user.gamertags}
+188 -6
View File
@@ -137,18 +137,55 @@ class StripMixin(Schema):
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
@pre_load
def strip_strings(self, data, **kwargs):
"""Strip whitespace from all string values in the input data.
def normalise_form_values(self, data, **kwargs):
"""Strip whitespace, and read an empty input as an absent one.
Both halves in one hook rather than two: marshmallow gives no
ordering guarantee between several pre_load hooks on the same schema,
and these two have to happen in this order.
The second half is what let the match and tryout forms be validated
at all (ARCH-005). An HTML form submits every field it renders, so an
untouched optional date arrives as '' rather than not arriving —
and '' is not a date, so a schema written the obvious way rejected
every form with a blank optional field. Dropping the key instead lets
`load_default` do its job.
Only fields the schema declares as optional are dropped. A blank
*required* field still has to fail, and say so.
Args:
data: The input dictionary.
Returns:
dict: Data with stripped strings.
dict: Data with stripped strings and blank optionals removed.
"""
if isinstance(data, dict):
return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
return data
if not isinstance(data, dict):
return data
stripped = {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
return {
key: value
for key, value in stripped.items()
if value != '' or self._is_required(key) or self._accepts_blank(key)
}
def _is_required(self, key):
field = self.fields.get(key)
return field is not None and field.required
def _accepts_blank(self, key):
"""True when '' is a value this field means to receive.
A plain String is one: `description=''` on an edit form means "clear
the description", not "leave it alone". An Email is not — there is no
such thing as an empty address, so a blank one is an absent one.
Typed fields (Integer, Date, Time, List) are not Strings and so are
never kept blank, which is the whole point of the hook.
"""
field = self.fields.get(key)
return isinstance(field, fields.String) and not isinstance(field, fields.Email)
class LoginSchema(StripMixin):
@@ -470,3 +507,148 @@ class DisponibilityAddSchema(StripMixin):
required=True,
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
)
# =============================================================================
# Scheduling — matches and tryouts (ARCH-005)
# =============================================================================
#: The three shapes a tryout match can take. Read by the route to decide how
#: participants are drawn; a value outside this set produced a match with no
#: participants at all and no complaint.
MATCH_TYPES = ('team_vs_team', 'player_vs_player', 'player_scrim')
#: Match lifecycle. Was checked with an inline `if status in [...]` that
#: silently kept the old value on anything else.
MATCH_STATUSES = ('scheduled', 'completed', 'cancelled')
class CommaSeparatedIds(fields.Field):
"""A hidden input holding '3,7,12' — ids picked in the page.
The match form posts its player selections this way. Every route parsed
it by hand, and each did it slightly differently: one dropped empty
segments, another did not, a third called int() on whatever came out and
would have raised a 500 on 'a,b'.
"""
default_error_messages = {'invalid': _l('Player selection is malformed.')}
def _deserialize(self, value, attr, data, **kwargs):
if value is None or value == '':
return []
if isinstance(value, (list, tuple)):
parts = value
else:
parts = str(value).split(',')
try:
return [int(part) for part in (str(p).strip() for p in parts) if part]
except (TypeError, ValueError) as exc:
raise self.make_error('invalid') from exc
class ScheduledEventSchema(StripMixin):
"""What every scheduled thing has: a title, a day, and a window on it.
Shared by matches and team matches, which is also what BaseMatch says at
the model level. Times are real time objects here rather than strings —
the point of validating at the boundary is that a route never handles a
'18:00' again.
"""
title = fields.String(
required=True,
validate=validate.Length(min=1, max=200, error=_l('A title is required.')),
)
description = fields.String(
validate=validate.Length(max=5000),
allow_none=True,
load_default=None,
)
date = fields.Date(
required=True,
error_messages={'invalid': _l('Invalid date format.')},
)
start_time = fields.Time(
required=True,
error_messages={
'invalid': _l('Invalid time format.'),
'required': _l('Start time is required. Please select a time slot.'),
},
)
end_time = fields.Time(
allow_none=True,
load_default=None,
error_messages={'invalid': _l('Invalid time format.')},
)
location = fields.String(
validate=validate.Length(max=200),
allow_none=True,
load_default=None,
)
status = fields.String(
validate=validate.OneOf(MATCH_STATUSES, error=_l('Unknown match status.')),
load_default='scheduled',
)
@validates_schema
def validate_window(self, data, **kwargs):
"""An event cannot end before it starts.
Nothing checked this. A match from 20:00 to 18:00 was accepted, shown
on the calendar as a negative-length block, and announced by Discord
as exactly that.
"""
start = data.get('start_time')
end = data.get('end_time')
if start and end and end <= start:
raise ValidationError(
_l('The end time must come after the start time.'), field_name='end_time'
)
class MatchSchema(ScheduledEventSchema):
"""A match inside a tryout.
match_type is required on creation and immutable afterwards — the edit
form posts it as a hidden field and the route reads it off the record,
not off the form.
"""
match_type = fields.String(
required=True,
validate=validate.OneOf(MATCH_TYPES, error=_l('Unknown match type.')),
)
team1_id = fields.Integer(allow_none=True, load_default=None)
team2_id = fields.Integer(allow_none=True, load_default=None)
team1_player_ids = CommaSeparatedIds(load_default=list)
team2_player_ids = CommaSeparatedIds(load_default=list)
player_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema
def validate_sides(self, data, **kwargs):
"""A team cannot play itself."""
if data.get('team1_id') and data.get('team1_id') == data.get('team2_id'):
raise ValidationError(_l('A team cannot play against itself.'), field_name='team2_id')
class MatchEditSchema(MatchSchema):
"""The same match, being edited.
match_type is not accepted here at all: it decides how participants are
drawn, and changing it on an existing match would leave the old ones
behind. The route takes it from the record.
"""
match_type = fields.String(load_default=None)
class TeamMatchSchema(ScheduledEventSchema):
"""A regular-season match for an organisation team."""
opponent = fields.String(
validate=validate.Length(max=200),
allow_none=True,
load_default=None,
)
is_practice = fields.Boolean(load_default=False)
+322
View File
@@ -0,0 +1,322 @@
"""Scheduling a match, through the form (ARCH-005).
Before this, `matches.py` read fifteen fields off `request.form` by hand and
believed all of them. What that produced was not loud:
- `edit_match` caught a bad time and set `start_time = None`, then said the
match had been updated. The match lost its time and the calendar showed
it at midnight;
- `match_type` was accepted as any string. An unknown one created a match
with no participants and no complaint;
- an end time before the start was stored as given;
- `title` is NOT NULL in the model and unvalidated in the route, so an
empty one was a 500.
The route now loads a schema and reports every rejection the same way.
"""
from datetime import date, time
import pytest
from app.extensions import db
from app.models import (
Match,
MatchParticipant,
OrgTeam,
Team,
TeamMatch,
TeamMember,
Tryout,
)
@pytest.fixture
def tryout_setup(app, as_role, make_user):
"""A tryout run by the logged-in admin, with two teams and four players."""
admin_id = as_role('admin')
player_ids = [make_user('player') for _ in range(4)]
with app.app_context():
org_team = OrgTeam(name='Varsity', created_by=admin_id)
db.session.add(org_team)
db.session.flush()
tryout = Tryout(
title='Spring',
game='Valorant',
date=date(2030, 4, 1),
created_by=admin_id,
target_org_team_id=org_team.id,
)
db.session.add(tryout)
db.session.flush()
alpha = Team(tryout_id=tryout.id, name='Alpha', created_by=admin_id)
beta = Team(tryout_id=tryout.id, name='Beta', created_by=admin_id)
db.session.add_all([alpha, beta])
db.session.flush()
db.session.add_all(
[
TeamMember(team_id=alpha.id, player_id=player_ids[0]),
TeamMember(team_id=alpha.id, player_id=player_ids[1]),
TeamMember(team_id=beta.id, player_id=player_ids[2]),
TeamMember(team_id=beta.id, player_id=player_ids[3]),
]
)
db.session.commit()
return {
'tryout_id': tryout.id,
'alpha_id': alpha.id,
'beta_id': beta.id,
'players': player_ids,
}
VALID = {
'title': 'Scrim night',
'date': '2030-04-01',
'start_time': '18:00',
'end_time': '20:00',
'location': 'Arena',
'match_type': 'player_scrim',
}
def _create(client, setup, **overrides):
payload = dict(VALID)
payload.update(overrides)
return client.post(f'/matches/create/{setup["tryout_id"]}', data=payload, follow_redirects=True)
def _only_match(app):
with app.app_context():
return Match.query.one_or_none()
class TestCreating:
def test_a_valid_scrim_is_stored_with_typed_values(self, app, client, tryout_setup):
_create(client, tryout_setup, player_ids=[str(p) for p in tryout_setup['players'][:2]])
match = _only_match(app)
assert match is not None
assert match.title == 'Scrim night'
assert match.date == date(2030, 4, 1)
assert match.start_time == time(18, 0)
assert match.end_time == time(20, 0)
def test_a_missing_end_time_defaults_to_thirty_minutes(self, app, client, tryout_setup):
_create(client, tryout_setup, end_time='')
assert _only_match(app).end_time == time(18, 30)
def test_a_team_match_draws_its_players_from_the_teams(self, app, client, tryout_setup):
_create(
client,
tryout_setup,
match_type='team_vs_team',
team1_id=str(tryout_setup['alpha_id']),
team2_id=str(tryout_setup['beta_id']),
)
with app.app_context():
participants = MatchParticipant.query.all()
assert len(participants) == 4
assert {p.team_side for p in participants} == {1, 2}
def test_a_player_vs_player_match_reads_the_comma_separated_ids(
self, app, client, tryout_setup
):
players = tryout_setup['players']
_create(
client,
tryout_setup,
match_type='player_vs_player',
team1_player_ids=f'{players[0]},{players[1]}',
team2_player_ids=f'{players[2]},{players[3]}',
)
with app.app_context():
sides = {p.player_id: p.team_side for p in MatchParticipant.query.all()}
assert sides[players[0]] == 1
assert sides[players[3]] == 2
class TestRefusals:
"""Every one of these used to be accepted."""
def test_an_unknown_match_type_is_refused(self, app, client, tryout_setup):
"""It used to create a match that no player was ever attached to."""
_create(client, tryout_setup, match_type='battle_royale')
assert _only_match(app) is None
def test_an_empty_title_is_refused(self, app, client, tryout_setup):
"""title is NOT NULL: unvalidated, this was an IntegrityError."""
_create(client, tryout_setup, title='')
assert _only_match(app) is None
def test_an_end_before_the_start_is_refused(self, app, client, tryout_setup):
_create(client, tryout_setup, start_time='20:00', end_time='18:00')
assert _only_match(app) is None
def test_a_malformed_time_is_refused(self, app, client, tryout_setup):
_create(client, tryout_setup, start_time='six o clock')
assert _only_match(app) is None
def test_a_malformed_date_is_refused(self, app, client, tryout_setup):
_create(client, tryout_setup, date='next tuesday')
assert _only_match(app) is None
def test_a_team_cannot_play_itself(self, app, client, tryout_setup):
_create(
client,
tryout_setup,
match_type='team_vs_team',
team1_id=str(tryout_setup['alpha_id']),
team2_id=str(tryout_setup['alpha_id']),
)
assert _only_match(app) is None
def test_a_malformed_player_selection_is_refused_not_crashed(self, app, client, tryout_setup):
"""'a,b' reached int() unguarded and was a 500."""
response = _create(
client, tryout_setup, match_type='player_vs_player', team1_player_ids='a,b'
)
assert response.status_code == 200
assert _only_match(app) is None
class TestEditing:
def _edit(self, client, app, tryout_setup, **overrides):
_create(client, tryout_setup)
match = _only_match(app)
payload = dict(VALID, title='Renamed', status='scheduled')
payload.update(overrides)
return client.post(
f'/matches/{match.id}/edit', data=payload, follow_redirects=True
), match.id
def test_a_valid_edit_is_applied(self, app, client, tryout_setup):
_, match_id = self._edit(client, app, tryout_setup)
with app.app_context():
assert db.session.get(Match, match_id).title == 'Renamed'
def test_a_bad_time_no_longer_wipes_the_one_that_was_there(self, app, client, tryout_setup):
"""The defect this whole change exists for.
`except ValueError: match.start_time = None` — the form was accepted,
the time was destroyed, and the page said the match had been updated.
"""
_, match_id = self._edit(client, app, tryout_setup, start_time='25:99')
with app.app_context():
match = db.session.get(Match, match_id)
assert match.start_time == time(18, 0), 'the stored time must survive a bad edit'
assert match.title == 'Scrim night', 'and so must everything else on the form'
def test_an_unknown_status_is_refused_rather_than_ignored(self, app, client, tryout_setup):
_, match_id = self._edit(client, app, tryout_setup, status='postponed')
with app.app_context():
assert db.session.get(Match, match_id).title == 'Scrim night'
def test_the_match_type_cannot_be_changed_by_the_form(self, app, client, tryout_setup):
"""It decides how participants are drawn; changing it would strand
the ones already attached."""
_, match_id = self._edit(client, app, tryout_setup, match_type='team_vs_team')
with app.app_context():
assert db.session.get(Match, match_id).match_type == 'player_scrim'
class TestTeamMatchEditing:
"""Regular-season matches went through the same treatment.
Their edit path checked date, start time and end time one at a time,
each flashing and redirecting on its own — so a mistyped end time threw
away everything the person had typed into the other ten fields.
"""
@pytest.fixture
def team_match(self, app, as_role):
admin_id = as_role('admin')
with app.app_context():
org_team = OrgTeam(name='Varsity', created_by=admin_id)
db.session.add(org_team)
db.session.flush()
match = TeamMatch(
org_team_id=org_team.id,
title='League night',
date=date(2030, 5, 1),
start_time=time(19, 0),
end_time=time(21, 0),
created_by=admin_id,
)
db.session.add(match)
db.session.commit()
return match.id
VALID = {
'title': 'League night',
'date': '2030-05-01',
'start_time': '19:00',
'end_time': '21:00',
'status': 'scheduled',
}
def _edit(self, client, match_id, **overrides):
return client.post(
f'/team-matches/{match_id}/edit',
data=dict(self.VALID, **overrides),
follow_redirects=True,
)
def test_a_valid_edit_is_applied(self, app, client, team_match):
self._edit(client, team_match, title='Renamed', location='Arena')
with app.app_context():
match = db.session.get(TeamMatch, team_match)
assert match.title == 'Renamed'
assert match.location == 'Arena'
def test_a_bad_end_time_changes_nothing_and_says_so_in_place(self, app, client, team_match):
"""It used to redirect, one redirect per bad field.
Known limit, and the reason this asserts on the message rather than
on the inputs: the form is re-rendered from the stored record, so the
submitted values are not echoed back. Every problem is now reported
at once and in place, which is the part that changed.
"""
response = self._edit(client, team_match, title='Renamed', end_time='half past nine')
with app.app_context():
match = db.session.get(TeamMatch, team_match)
assert match.title == 'League night'
assert match.end_time == time(21, 0)
assert 'alert-danger' in response.get_data(as_text=True)
def test_an_end_before_the_start_is_refused(self, app, client, team_match):
self._edit(client, team_match, start_time='21:00', end_time='19:00')
with app.app_context():
assert db.session.get(TeamMatch, team_match).start_time == time(19, 0)
def test_an_empty_title_is_refused(self, app, client, team_match):
"""`request.form.get('title', team_match.title)` only defaulted on an
absent key, and a form always sends the key — so a cleared title was
stored as an empty string in a NOT NULL column."""
self._edit(client, team_match, title='')
with app.app_context():
assert db.session.get(TeamMatch, team_match).title == 'League night'