271 lines
9.2 KiB
Python
271 lines
9.2 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():
|
|
"""Replace the current player's disponibility blocks atomically."""
|
|
data = request.get_json(silent=True) or {}
|
|
accepted, rejected = _load_slots(data.get('slots'))
|
|
|
|
if rejected:
|
|
return jsonify(
|
|
{
|
|
'error': 'Invalid slots; nothing was changed.',
|
|
'rejected': rejected,
|
|
}
|
|
), 400
|
|
|
|
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
|
|
|
|
created = []
|
|
for slot in accepted:
|
|
start_time = slot['start_time']
|
|
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()
|
|
return jsonify({'success': True, 'created': created, '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 = db.get_or_404(PlayerDisponibility, 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})
|