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
|
||||
+143
-241
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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