refactor(routes): decouper users.py en paquet, extraire les notifications
ARCH-004 et la moitie NotificationService d ARCH-003. app/routes/users.py faisait 1 699 lignes et couvrait six sujets qui ne partageaient rien d autre qu un prefixe d URL. Il devient un paquet : blueprint.py l objet Blueprint, seul _shared.py helpers de formulaire, gamertags, validation de PDF accounts.py 348 l. liste, creation, edition, suppression, fiche availability.py 233 l. disponibilites joueur et creneaux coach contracts.py 206 l. depot, signature, telechargement notes.py 376 l. notes d equipe et notes nominatives one_on_one.py 259 l. demandes de seance individuelle profile.py 132 l. profil de la personne connectee Aucun fichier ne depasse 400 lignes -- le critere d acceptation de l audit. **Un seul blueprint, pas six.** Les endpoints restent `users.*`. Les renommer aurait touche 137 appels `url_for` dans les gabarits, pour un benefice nul : l objectif est un fichier qu on peut lire, pas une carte d URL a reapprendre. Les 30 endpoints sont identiques avant et apres, verifie sur url_map. send_discord_notification part dans app/services/notifications.py. Elle tirait `requests`, `logging` et le bot Discord dans un module dont le sujet est le traitement HTTP, et se trouvait coincee entre deux definitions de route. Son `except Exception` est conserve et documente : une notification qui n arrive pas ne doit pas annuler la transaction qu elle annoncait. tests/test_route_map.py, nouveau : il parcourt gabarits et code, releve tout endpoint nomme litteralement dans un url_for, et verifie qu il existe dans la carte. C est le mode de defaillance de ce genre de decoupage -- pas une erreur a l import, mais un BuildError chez la premiere personne qui ouvre la page concernee. Un test de garde verifie aussi que le scan trouve quelque chose, sinon le reste serait vide de sens. Piege rencontre, et corrige : test_role_change patchait `app.routes.users.update_user_gamertags`. Apres le decoupage ce nom est un reexport, pas celui qu accounts.py resout -- le patch aurait pu laisser la route appeler la vraie fonction et le test passer sans rien verifier. Ici monkeypatch a echoue bruyamment, mais la cible est desormais explicite et le test enregistre que la doublure a bien ete appelee. 347 tests passent (263 + 84, dont 82 parametres par la carte des routes). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"""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 app.extensions import db
|
||||
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
|
||||
|
||||
|
||||
@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()
|
||||
)
|
||||
|
||||
coach_availability = []
|
||||
if coach:
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
|
||||
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':
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
points = request.form.get('points', '').strip()
|
||||
|
||||
if not coach:
|
||||
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid date or time format.'), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
check_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
day_of_week = check_date.weekday()
|
||||
|
||||
is_available = any(
|
||||
av['day_of_week'] == day_of_week
|
||||
and av['start_time'] <= start_time_str
|
||||
and av['end_time'] >= end_time_str
|
||||
for av in coach_availability
|
||||
)
|
||||
|
||||
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_str,
|
||||
start_time_str=start_time_str,
|
||||
end_time_str=end_time_str,
|
||||
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, 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'))
|
||||
Reference in New Issue
Block a user