Files
team-tryouts/app/routes/users/one_on_one.py
T
GGThedandClaude Opus 5 9166d8abeb fix(data): une saisie d heure refusee n efface plus la disponibilite
MNT-12. Le meme bloc de parsing date/heure vivait dans quatre modules avec
trois reponses differentes a la meme saisie invalide : signaler et rediriger,
mettre la valeur a None et annoncer la reussite, ou passer au suivant en
silence. Les vagues E et G ont ferme le cote matchs avec des schemas ; il
restait la disponibilite, les creneaux de coach et les demandes individuelles.

Deux choses trouvees en appliquant, aucune dans le constat.

OneOnOneRequestSchema et DisponibilityAddSchema etaient definis dans
validators.py et appeles NULLE PART : aucun import, aucun test. C'est le
motif SEC-AUTHZ-001 -- une politique de validation ecrite et non appliquee --
qui survivait dans un coin que personne n'avait rouvert. Les deux passaient
d'ailleurs par des fields.String + Regexp, qui verifient la forme et laissent
l'appelant convertir ; fields.Date et fields.Time font les deux.

Et manage_coach_availability supprimait tous les creneaux existants avant de
reajouter ceux qu'il savait lire, ignorant les autres en silence et repondant
{'success': true}. Un envoi malforme effacait donc les heures reservables
d'un coach en annoncant la reussite -- et les demandes individuelles sont
refusees contre exactement cette table, donc le coach devenait injoignable
sans que rien ne le dise. Une operation de remplacement doit tout valider
avant de rien supprimer : le lot est refuse en entier.

Trouve aussi : le controle de disponibilite comparait les chaines du
formulaire aux chaines serialisees, ce qui ne marchait que parce que les deux
cotes etaient en HH:MM a zero non significatif. La comparaison porte
desormais sur des objets time.

Et le lint a rattrape une regression que la relecture avait manquee --
sixieme fois : datetime retire de one_on_one.py alors que deux fonctions non
touchees l'utilisaient encore.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:20:25 -04:00

264 lines
9.7 KiB
Python

"""One-on-one sessions between a player and their coach."""
from datetime import datetime
from flask import flash, 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 Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification
from app.validators import OneOnOneRequestSchema
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@login_required
def one_on_one():
"""One on One request page for players."""
if not isinstance(current_user, Player):
flash(_('Only players can request One on One sessions.'), 'danger')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
# Reading org_team.coach_id directly told every player whose team lists
# its coaches through the many-to-many relationship — the newer of the
# two ways — that they had no coach, and closed the page to them.
# get_coaches() falls back to the legacy column when the list is empty.
team_coaches = org_team.get_coaches() if org_team else []
coach = team_coaches[0] if team_coaches else None
if not coach:
flash(_('You do not have a coach assigned to your team.'), 'info')
team_notes = []
if org_team:
team_notes = (
TeamNote.query.filter_by(org_team_id=org_team.id)
.order_by(TeamNote.created_at.desc())
.all()
)
personal_notes = (
PersonalNote.query.filter_by(player_id=current_user.id)
.order_by(PersonalNote.created_at.desc())
.all()
)
# Kept as model objects for the availability check below, and serialised
# separately for the page. They used to be the same list of strings,
# which is what made the check compare '9:00' with '10:00' as text.
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all() if coach else []
coach_availability = [
{
'day_of_week': av.day_of_week,
'start_time': av.start_time.strftime('%H:%M'),
'end_time': av.end_time.strftime('%H:%M'),
}
for av in availabilities
]
if request.method == 'POST':
if not coach:
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one'))
try:
data = OneOnOneRequestSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.one_on_one'))
date_obj = data['date']
start_time = data['start_time']
end_time = data['end_time']
points = data['points'] or ''
# Compared as times, not as strings. The old code parsed the three
# form fields into objects and then compared the *original strings*
# against the serialised availability — which worked only because
# both sides happened to be zero-padded HH:MM.
is_available = any(
av.day_of_week == date_obj.weekday()
and av.start_time <= start_time
and av.end_time >= end_time
for av in availabilities
)
if not is_available:
flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one'))
request_obj = OneOnOneRequest(
player_id=current_user.id,
coach_id=coach.id,
org_team_id=org_team.id if org_team else None,
date=date_obj,
start_time=start_time,
end_time=end_time,
points=points if points else None,
)
db.session.add(request_obj)
db.session.commit()
send_discord_notification(
player_name=current_user.full_name,
points=points,
date_str=date_obj.strftime('%Y-%m-%d'),
start_time_str=start_time.strftime('%H:%M'),
end_time_str=end_time.strftime('%H:%M'),
team_name=org_team.name if org_team else 'Unknown Team',
coach_name=coach.full_name,
coach_discord=coach.discord_username or '',
coach_discord_id=coach.discord_user_id or '',
request_id=request_obj.id,
)
flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
from datetime import date as date_cls
from datetime import timedelta as td
today = date_cls.today()
available_days = {av['day_of_week'] for av in coach_availability}
dates = []
for i in range(14): # Next 14 days
d = today + td(days=i)
if d.weekday() in available_days:
dates.append(
{
'value': d.strftime('%Y-%m-%d'),
'day_of_week': d.weekday(),
'display': d.strftime('%B %d, %Y (%A)'),
}
)
# Player's own One on One request history
my_requests = (
OneOnOneRequest.query.filter_by(player_id=current_user.id)
.order_by(OneOnOneRequest.created_at.desc())
.all()
)
return render_template(
'pages/one_on_one.html',
org_team=org_team,
coach=coach,
team_notes=team_notes,
personal_notes=personal_notes,
coach_availability=coach_availability,
dates=dates,
my_requests=my_requests,
)
@users_bp.route('/one-on-one/<int:request_id>/accept', methods=['POST'])
@login_required
def accept_one_on_one(request_id):
"""Coach accepts a One on One request."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p')
if request_obj.start_time
else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=True,
)
flash(
_(
'One on One request from %(player)s has been approved!',
player=player.username if player else 'Unknown',
),
'success',
)
return redirect(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@login_required
def reject_one_on_one(request_id):
"""Coach rejects a One on One request."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
rejection_reason = request.form.get('rejection_reason', '').strip()
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
# Notify player via Discord (same message as if rejected through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p')
if request_obj.start_time
else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=False,
refusal_note=rejection_reason or None,
)
flash(
_(
'One on One request from %(player)s has been rejected.',
player=player.username if player else 'Unknown',
),
'info',
)
return redirect(url_for('users.notes_dashboard'))