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:
+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}
|
||||
|
||||
Reference in New Issue
Block a user