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:
@@ -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
|
||||||
+141
-239
@@ -8,9 +8,11 @@ from datetime import datetime, timedelta
|
|||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, jsonify, 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 sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin,
|
Admin,
|
||||||
Coach,
|
Coach,
|
||||||
@@ -29,10 +31,62 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.services.scheduling import notify_participants, zip_participants
|
from app.services.scheduling import notify_participants, zip_participants
|
||||||
|
from app.validators import MatchEditSchema, MatchSchema
|
||||||
|
|
||||||
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
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():
|
def can_schedule_match():
|
||||||
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
|
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
|
||||||
return isinstance(current_user, (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)
|
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||||
prefill_date = request.args.get('date', '')
|
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':
|
if request.method == 'POST':
|
||||||
title = request.form.get('title')
|
payload = match_form_payload()
|
||||||
description = request.form.get('description')
|
# A tryout match with no date of its own happens on the tryout's day.
|
||||||
date_str = request.form.get('date')
|
payload.setdefault('date', tryout.date.isoformat())
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
|
data = MatchSchema().load(payload)
|
||||||
except (ValueError, TypeError):
|
except ValidationError as err:
|
||||||
flash(_('Invalid date format.'), 'danger')
|
flash_validation_errors(err)
|
||||||
return render_template(
|
return rerender()
|
||||||
'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
|
|
||||||
)
|
|
||||||
|
|
||||||
match = Match(
|
match = Match(
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
title=title,
|
title=data['title'],
|
||||||
description=description,
|
description=data['description'],
|
||||||
date=date_obj,
|
date=data['date'],
|
||||||
start_time=start_time,
|
start_time=data['start_time'],
|
||||||
end_time=end_time,
|
end_time=data['end_time'] or default_end_time(data['date'], data['start_time']),
|
||||||
location=location,
|
location=data['location'],
|
||||||
match_type=match_type,
|
match_type=data['match_type'],
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
)
|
)
|
||||||
db.session.add(match)
|
db.session.add(match)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
notified_player_ids = []
|
if data['match_type'] == 'team_vs_team':
|
||||||
notified_participant_ids = []
|
match.team1_id = data['team1_id']
|
||||||
|
match.team2_id = data['team2_id']
|
||||||
|
|
||||||
if match_type == 'team_vs_team':
|
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||||
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]
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
notify_participants(
|
notify_participants(
|
||||||
title=match.title,
|
title=match.title,
|
||||||
date=date_obj,
|
date=match.date,
|
||||||
start_time=start_time,
|
start_time=match.start_time,
|
||||||
end_time=end_time,
|
end_time=match.end_time,
|
||||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
||||||
fallback_id=match.id,
|
fallback_id=match.id,
|
||||||
)
|
)
|
||||||
@@ -449,13 +427,7 @@ def create_match(tryout_id):
|
|||||||
flash(_('Match scheduled successfully!'), 'success')
|
flash(_('Match scheduled successfully!'), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_template(
|
return rerender()
|
||||||
'pages/match_form.html',
|
|
||||||
tryout=tryout,
|
|
||||||
teams=teams,
|
|
||||||
all_players=all_players,
|
|
||||||
prefill_date=prefill_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
@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()]
|
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()]
|
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':
|
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:
|
try:
|
||||||
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
data = MatchEditSchema().load(match_form_payload())
|
||||||
except (ValueError, TypeError):
|
except ValidationError as err:
|
||||||
flash(_('Invalid date format.'), 'danger')
|
flash_validation_errors(err)
|
||||||
return render_template(
|
return rerender()
|
||||||
'pages/match_form.html',
|
|
||||||
match=match,
|
|
||||||
tryout=tryout,
|
|
||||||
teams=teams,
|
|
||||||
all_players=all_players,
|
|
||||||
current_player_ids=current_player_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not start_time_str:
|
# Assigned only once the whole form has been accepted. Assigning as
|
||||||
flash(_('Start time is required.'), 'danger')
|
# each field was read meant a form rejected halfway had already
|
||||||
return render_template(
|
# changed the record in the session.
|
||||||
'pages/match_form.html',
|
match.title = data['title']
|
||||||
match=match,
|
match.description = data['description']
|
||||||
tryout=tryout,
|
match.date = data['date']
|
||||||
teams=teams,
|
match.start_time = data['start_time']
|
||||||
all_players=all_players,
|
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||||
current_player_ids=current_player_ids,
|
match.location = data['location']
|
||||||
)
|
match.status = data['status']
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
team1_id = request.form.get('team1_id')
|
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
||||||
team2_id = request.form.get('team2_id')
|
if teams_changed:
|
||||||
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:
|
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
match.team1_id = new_team1_id
|
match.team1_id = data['team1_id']
|
||||||
match.team2_id = new_team2_id
|
match.team2_id = data['team2_id']
|
||||||
if match.team1_id:
|
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
if match.team1_id:
|
# 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(
|
notified_player_ids.extend(
|
||||||
[
|
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
||||||
m.player_id
|
|
||||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all()
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
if match.team2_id:
|
else:
|
||||||
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':
|
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
team1_str = request.form.get('team1_player_ids', '')
|
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||||
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]
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -616,25 +536,7 @@ def edit_match(match_id):
|
|||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
participants_map = {}
|
return rerender()
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/manageable-tryouts')
|
@matches_bp.route('/api/manageable-tryouts')
|
||||||
|
|||||||
+46
-81
@@ -3,13 +3,15 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
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 import Blueprint, flash, jsonify, 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 app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin,
|
Admin,
|
||||||
Coach,
|
Coach,
|
||||||
@@ -21,7 +23,9 @@ from app.models import (
|
|||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
)
|
)
|
||||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
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.services.scheduling import notify_participants, zip_participants
|
||||||
|
from app.validators import TeamMatchSchema
|
||||||
|
|
||||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||||
|
|
||||||
@@ -130,27 +134,16 @@ def create_match(team_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
title = request.form.get('title', default_title)
|
payload = form_payload(list_fields=(), optional_blank=())
|
||||||
opponent = request.form.get('opponent', '').strip() if not is_practice else None
|
# A practice has no opponent, whatever the form sent.
|
||||||
description = request.form.get('description', '')
|
payload.setdefault('title', default_title)
|
||||||
date_str = request.form.get('date')
|
if is_practice:
|
||||||
start_time_str = request.form.get('start_time')
|
payload.pop('opponent', None)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
data = TeamMatchSchema().load(payload)
|
||||||
except (ValueError, TypeError):
|
except ValidationError as err:
|
||||||
flash(_('Invalid date format.'), 'danger')
|
flash_validation_errors(err)
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/team_match_form.html',
|
'pages/team_match_form.html',
|
||||||
team=team,
|
team=team,
|
||||||
@@ -159,36 +152,18 @@ def create_match(team_id):
|
|||||||
is_practice=is_practice,
|
is_practice=is_practice,
|
||||||
)
|
)
|
||||||
|
|
||||||
start_time = None
|
start_time = data['start_time']
|
||||||
end_time = None
|
end_time = data['end_time'] or default_end_time(data['date'], start_time)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
team_match = TeamMatch(
|
team_match = TeamMatch(
|
||||||
org_team_id=team_id,
|
org_team_id=team_id,
|
||||||
title=title,
|
title=data['title'],
|
||||||
description=description or None,
|
description=data['description'],
|
||||||
opponent=opponent or None,
|
opponent=data['opponent'],
|
||||||
date=date_obj,
|
date=data['date'],
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
location=location or None,
|
location=data['location'],
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
)
|
)
|
||||||
db.session.add(team_match)
|
db.session.add(team_match)
|
||||||
@@ -208,7 +183,7 @@ def create_match(team_id):
|
|||||||
|
|
||||||
notify_participants(
|
notify_participants(
|
||||||
title=team_match.title,
|
title=team_match.title,
|
||||||
date=date_obj,
|
date=team_match.date,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
participants=zip_participants(
|
participants=zip_participants(
|
||||||
@@ -217,7 +192,9 @@ def create_match(team_id):
|
|||||||
fallback_id=team_match.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 redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
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'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
team_match.title = request.form.get('title', team_match.title)
|
# The three date and time fields used to be checked one at a time,
|
||||||
team_match.description = request.form.get('description', '') or None
|
# each flashing and redirecting on its own: a form with two mistakes
|
||||||
team_match.opponent = request.form.get('opponent', '').strip() or None
|
# took two round trips to be told about both. One schema now, every
|
||||||
|
# problem reported at once and in place.
|
||||||
date_str = request.form.get('date')
|
#
|
||||||
if date_str:
|
# 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:
|
try:
|
||||||
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
|
||||||
except (ValueError, TypeError):
|
except ValidationError as err:
|
||||||
flash(_('Invalid date format.'), 'danger')
|
flash_validation_errors(err)
|
||||||
return redirect(url_for('team_matches.edit_match', match_id=match_id))
|
return render_template(
|
||||||
|
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||||
|
)
|
||||||
|
|
||||||
# Both used to `except ValueError: pass`, three lines below a date
|
team_match.title = data['title']
|
||||||
# field that flashes and redirects. A mistyped time was therefore
|
team_match.description = data['description']
|
||||||
# accepted by the form, discarded, and the old value kept — with the
|
team_match.opponent = data['opponent']
|
||||||
# page reporting success. Same treatment as the date now.
|
team_match.date = data['date']
|
||||||
start_time_str = request.form.get('start_time')
|
team_match.start_time = data['start_time']
|
||||||
if start_time_str:
|
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
||||||
try:
|
team_match.location = data['location']
|
||||||
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
team_match.status = data['status']
|
||||||
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
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
|
|||||||
@@ -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.
|
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 flask_babel import gettext as _
|
||||||
|
|
||||||
from app.extensions import db
|
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
|
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
|
||||||
|
|
||||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||||
@@ -57,34 +62,6 @@ def pdf_upload_error(file, allowed_extensions):
|
|||||||
return None
|
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):
|
def update_user_gamertags(user, selected_games):
|
||||||
"""Update gamertags for a user based on form input."""
|
"""Update gamertags for a user based on form input."""
|
||||||
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||||
|
|||||||
+187
-5
@@ -137,19 +137,56 @@ class StripMixin(Schema):
|
|||||||
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
|
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
|
||||||
|
|
||||||
@pre_load
|
@pre_load
|
||||||
def strip_strings(self, data, **kwargs):
|
def normalise_form_values(self, data, **kwargs):
|
||||||
"""Strip whitespace from all string values in the input data.
|
"""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:
|
Args:
|
||||||
data: The input dictionary.
|
data: The input dictionary.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Data with stripped strings.
|
dict: Data with stripped strings and blank optionals removed.
|
||||||
"""
|
"""
|
||||||
if isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
|
|
||||||
return data
|
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):
|
class LoginSchema(StripMixin):
|
||||||
"""Validate login form input.
|
"""Validate login form input.
|
||||||
@@ -470,3 +507,148 @@ class DisponibilityAddSchema(StripMixin):
|
|||||||
required=True,
|
required=True,
|
||||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
|
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)
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user