Files
team-tryouts/app/routes/users/availability.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

271 lines
9.5 KiB
Python

"""When people are free.
Two calendars that share a shape without sharing a purpose: a player's
weekly availability blocks, and a coach's bookable slots for one-on-one
sessions.
"""
from flask import 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.api import json_endpoint
from app.extensions import db
from app.forms import form_payload
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
from app.routes.users.blueprint import users_bp
from app.timeslots import day_name, slot_end
from app.validators import TimeSlotSchema
def _load_slots(payload_slots):
"""Validate a batch of posted slots, keeping the rejects.
Both bulk endpoints used to `continue` past anything malformed and then
answer `{'success': True}`. The client had no way to learn that a slot
had been dropped — and for coach availability that is destructive, since
the route deletes every existing slot before re-adding the ones it
accepted. A payload the browser mangled could therefore wipe a coach's
bookable hours and report success (MNT-12).
Args:
payload_slots: Whatever arrived under the `slots` key.
Returns:
tuple[list[dict], list[str]]: Accepted slots, and one message per
rejected one.
"""
schema = TimeSlotSchema()
accepted, rejected = [], []
for index, raw in enumerate(payload_slots or []):
if not isinstance(raw, dict):
rejected.append(f'slot {index}: expected an object')
continue
try:
accepted.append(schema.load(raw))
except ValidationError as err:
details = '; '.join(
f'{field}: {" ".join(str(m) for m in messages)}'
for field, messages in err.messages.items()
)
rejected.append(f'slot {index}: {details}')
return accepted, rejected
@users_bp.route('/disponibilities')
@json_endpoint
@login_required
def get_disponibilities():
"""API endpoint to get all player disponibilities for scheduling."""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403
players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
result = {}
for player in players:
disponibilities = list(player.disponibilities)
result[player.id] = {
'username': player.username,
'disponibilities': [
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
for d in disponibilities
],
}
return jsonify(result)
@users_bp.route('/disponibilities/my')
@json_endpoint
@login_required
def get_my_disponibilities():
"""API endpoint for players to get their own disponibilities."""
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
result = {}
for d in disponibilities:
day = d.day_of_week
if day not in result:
result[day] = []
result[day].append(
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
)
return jsonify(result)
@users_bp.route('/disponibilities/add', methods=['POST'])
@json_endpoint
@login_required
def add_disponibility():
"""Add a disponibility block for the current player."""
try:
slot = TimeSlotSchema().load(form_payload())
except ValidationError as err:
return jsonify({'error': 'Invalid slot', 'details': err.messages}), 400
day_of_week = slot['day_of_week']
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=day_of_week,
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.commit()
return jsonify(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
'end_time': disponibility.end_time.strftime('%H:%M'),
}
)
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Add multiple disponibility blocks at once."""
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
created = []
for slot in accepted:
start_time = slot['start_time']
existing = PlayerDisponibility.query.filter_by(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
).first()
if not existing:
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
db.session.commit()
# `rejected` is reported rather than swallowed. What was accepted is
# still saved — dropping a whole batch because one cell was malformed
# would be its own kind of surprise — but the client can now tell the
# difference between "nine slots saved" and "ten sent, nine saved".
return jsonify({'success': True, 'created': created, 'rejected': rejected})
@users_bp.route('/disponibilities/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_disponibilities():
"""Clear all disponibilities for the current player."""
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
@json_endpoint
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
@json_endpoint
@login_required
def manage_coach_availability():
"""Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage availability.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
# Validate everything before deleting anything.
#
# This route replaces the coach's availability: it deleted every
# existing slot and then re-added the ones it could parse, skipping
# the rest in silence and answering `{'success': true}`. A payload
# the browser mangled therefore wiped a coach's bookable hours and
# reported success — and one-on-one requests are refused against
# exactly this table, so the coach became unbookable with nothing to
# show for it. Refusing the whole batch is the only safe answer when
# the operation is a replacement (MNT-12).
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
for slot in accepted:
start_time = slot['start_time']
db.session.add(
CoachAvailability(
coach_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
)
db.session.commit()
return jsonify({'success': True, 'saved': len(accepted)})
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id,
).all()
return render_template(
'pages/coach_availability.html', existing_availability=existing_availability
)
@users_bp.route('/coach-availability/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_coach_availability():
"""Clear all coach availability slots."""
if not isinstance(current_user, Coach):
return jsonify({'error': 'Unauthorized'}), 403
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})