"""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//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})