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]>
This commit is contained in:
@@ -5,22 +5,52 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
|
||||
sessions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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
|
||||
|
||||
DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
from app.timeslots import day_name, slot_end
|
||||
from app.validators import TimeSlotSchema
|
||||
|
||||
|
||||
def add_30_minutes(t):
|
||||
return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time()
|
||||
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')
|
||||
@@ -43,7 +73,7 @@ def get_disponibilities():
|
||||
{
|
||||
'id': d.id,
|
||||
'day_of_week': d.day_of_week,
|
||||
'day_name': DAY_NAMES[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'),
|
||||
}
|
||||
@@ -68,7 +98,7 @@ def get_my_disponibilities():
|
||||
{
|
||||
'id': d.id,
|
||||
'day_of_week': d.day_of_week,
|
||||
'day_name': DAY_NAMES[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'),
|
||||
}
|
||||
@@ -81,21 +111,18 @@ def get_my_disponibilities():
|
||||
@login_required
|
||||
def add_disponibility():
|
||||
"""Add a disponibility block for the current player."""
|
||||
day_of_week = request.form.get('day_of_week', type=int)
|
||||
start_time_str = request.form.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
return jsonify({'error': 'Invalid day of week'}), 400
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Invalid time format'}), 400
|
||||
slot = TimeSlotSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
return jsonify({'error': 'Invalid slot', 'details': err.messages}), 400
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
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=end_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.commit()
|
||||
@@ -103,7 +130,7 @@ def add_disponibility():
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': DAY_NAMES[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'),
|
||||
}
|
||||
@@ -115,31 +142,23 @@ def add_disponibility():
|
||||
@login_required
|
||||
def add_disponibilities_bulk():
|
||||
"""Add multiple disponibility blocks at once."""
|
||||
data = request.get_json()
|
||||
slots = data.get('slots', [])
|
||||
created = []
|
||||
for slot in slots:
|
||||
day_of_week = slot.get('day_of_week')
|
||||
start_time_str = slot.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
continue
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
created = []
|
||||
for slot in accepted:
|
||||
start_time = slot['start_time']
|
||||
existing = PlayerDisponibility.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
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=day_of_week,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.flush()
|
||||
@@ -147,12 +166,16 @@ def add_disponibilities_bulk():
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': DAY_NAMES[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` 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'])
|
||||
@@ -188,36 +211,42 @@ def manage_coach_availability():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json()
|
||||
slots = data.get('slots', []) if data else []
|
||||
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
|
||||
|
||||
# Clear existing availability
|
||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||
|
||||
# Add new slots
|
||||
for slot in slots:
|
||||
day_of_week = slot.get('day_of_week')
|
||||
start_time_str = slot.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
continue
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
end_time = (
|
||||
datetime.combine(datetime.today(), start_time) + timedelta(minutes=30)
|
||||
).time()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
availability = CoachAvailability(
|
||||
coach_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
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.add(availability)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': True, 'saved': len(accepted)})
|
||||
|
||||
existing_availability = CoachAvailability.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
|
||||
@@ -5,11 +5,14 @@ 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'])
|
||||
@@ -46,44 +49,44 @@ def one_on_one():
|
||||
.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
|
||||
]
|
||||
# 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':
|
||||
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')
|
||||
data = OneOnOneRequestSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
check_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
day_of_week = check_date.weekday()
|
||||
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'] == day_of_week
|
||||
and av['start_time'] <= start_time_str
|
||||
and av['end_time'] >= end_time_str
|
||||
for av in coach_availability
|
||||
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:
|
||||
@@ -105,9 +108,9 @@ def one_on_one():
|
||||
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,
|
||||
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 '',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""The half-hour slot, in one place (MNT-12).
|
||||
|
||||
Availability, coach bookings and matches are all built from the same rule:
|
||||
*a slot given a start and no end lasts thirty minutes*. It was written five
|
||||
times, in four modules, and the copies had drifted in the way that matters —
|
||||
not in the arithmetic, but in what each did when the input was wrong:
|
||||
|
||||
matches.py flashed an error and redirected
|
||||
matches.py, later set start_time to None and reported success
|
||||
team_matches.py `except ValueError: pass`, silently
|
||||
availability.py `continue`, dropping the slot without a word
|
||||
|
||||
Waves E and G closed the match side by putting a marshmallow schema at the
|
||||
form boundary. This module is the other half: the constant and the two
|
||||
functions the remaining callers need, so that the availability routes can
|
||||
use the same schema treatment without each one re-deciding what a slot is.
|
||||
|
||||
`DEFAULT_SLOT_MINUTES` is deliberately not configurable. It is a product
|
||||
decision written into the UI — the availability grid draws half-hour cells —
|
||||
and a setting would let the two disagree.
|
||||
"""
|
||||
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
#: How long a slot lasts when only its start is given.
|
||||
DEFAULT_SLOT_MINUTES = 30
|
||||
|
||||
#: Monday-first, matching `datetime.weekday()` and the availability grid.
|
||||
DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')
|
||||
|
||||
|
||||
def slot_end(start_time, minutes=DEFAULT_SLOT_MINUTES, on=None):
|
||||
"""The end of a slot starting at `start_time`.
|
||||
|
||||
Args:
|
||||
start_time: A `datetime.time`.
|
||||
minutes: Slot length; defaults to DEFAULT_SLOT_MINUTES.
|
||||
on: The date the slot falls on. Only matters for a slot that would
|
||||
cross midnight, where the arbitrary date the old helpers used
|
||||
(`datetime.today()`) made the result depend on when the code ran.
|
||||
|
||||
Returns:
|
||||
datetime.time: The end of the slot, wrapping past midnight like the
|
||||
previous implementations did.
|
||||
"""
|
||||
anchor = on or date_cls(2000, 1, 1)
|
||||
return (datetime.combine(anchor, start_time) + timedelta(minutes=minutes)).time()
|
||||
|
||||
|
||||
def day_name(day_of_week):
|
||||
"""Name of a weekday index, or a readable fallback.
|
||||
|
||||
Every caller indexed a module-level list directly, so an out-of-range
|
||||
day — which nothing prevented before the schemas — was an IndexError
|
||||
inside a JSON route, i.e. a 500 with an HTML body.
|
||||
"""
|
||||
if 0 <= day_of_week < len(DAY_NAMES):
|
||||
return DAY_NAMES[day_of_week]
|
||||
return f'Day {day_of_week}'
|
||||
+69
-21
@@ -461,28 +461,41 @@ class UploadContractSchema(StripMixin):
|
||||
|
||||
|
||||
class OneOnOneRequestSchema(StripMixin):
|
||||
"""Validate One on One session request form input.
|
||||
"""A player asking their coach for a session (MNT-12).
|
||||
|
||||
Fields:
|
||||
date: Date string (YYYY-MM-DD), required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
end_time: Time string (HH:MM), required.
|
||||
points: Optional text.
|
||||
This schema existed before and **was never called**: not imported by any
|
||||
route, not exercised by any test. `one_on_one.py` parsed the same three
|
||||
fields by hand, three lines apart, and parsed the date a second time to
|
||||
get its weekday. That is the pattern the audit named as SEC-AUTHZ-001 —
|
||||
a validation policy that is written down and not applied — surviving in
|
||||
a corner nobody had looked at since.
|
||||
|
||||
The fields were `String` + `Regexp`, which checked the shape and left the
|
||||
caller to convert. `Date` and `Time` do both, so the route receives the
|
||||
objects it is going to store and the "is this a date" question is asked
|
||||
once, in one place.
|
||||
"""
|
||||
|
||||
date = fields.String(
|
||||
date = fields.Date(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{4}-\d{2}-\d{2}$', error=_l('Date must be in YYYY-MM-DD format.')
|
||||
),
|
||||
error_messages={
|
||||
'invalid': _l('Date must be in YYYY-MM-DD format.'),
|
||||
'required': _l('A date is required.'),
|
||||
},
|
||||
)
|
||||
start_time = fields.String(
|
||||
start_time = fields.Time(
|
||||
required=True,
|
||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
|
||||
error_messages={
|
||||
'invalid': _l('Start time must be in HH:MM format.'),
|
||||
'required': _l('A start time is required.'),
|
||||
},
|
||||
)
|
||||
end_time = fields.String(
|
||||
end_time = fields.Time(
|
||||
required=True,
|
||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('End time must be in HH:MM format.')),
|
||||
error_messages={
|
||||
'invalid': _l('End time must be in HH:MM format.'),
|
||||
'required': _l('An end time is required.'),
|
||||
},
|
||||
)
|
||||
points = fields.String(
|
||||
validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')),
|
||||
@@ -490,22 +503,57 @@ class OneOnOneRequestSchema(StripMixin):
|
||||
load_default=None,
|
||||
)
|
||||
|
||||
@validates_schema
|
||||
def validate_window(self, data, **kwargs):
|
||||
"""A session cannot end before it starts.
|
||||
|
||||
class DisponibilityAddSchema(StripMixin):
|
||||
"""Validate disponibility block addition.
|
||||
Nothing checked this. The form's own `<select>`s made it awkward to
|
||||
do by accident, which is not the same as impossible — and the request
|
||||
went to the coach's Discord either way.
|
||||
"""
|
||||
start, end = data.get('start_time'), data.get('end_time')
|
||||
if start and end and end <= start:
|
||||
raise ValidationError(_l('End time must be after start time.'), field_name='end_time')
|
||||
|
||||
Fields:
|
||||
day_of_week: Integer 0-6, required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
|
||||
class TimeSlotSchema(Schema):
|
||||
"""One half-hour slot on a weekday: an availability or a coach booking.
|
||||
|
||||
Replaces `DisponibilityAddSchema`, which was also defined and never
|
||||
called. Used for both the form-encoded single add and the JSON bulk
|
||||
posts, which is why it is a plain Schema — `StripMixin` reads a blank
|
||||
string as an absent field, and these payloads arrive as JSON where a
|
||||
missing key is already absent.
|
||||
|
||||
`day_of_week` was checked inline as `day is None or day < 0 or day > 6`,
|
||||
which raises TypeError rather than rejecting when the JSON holds a
|
||||
string. Here it is an Integer field with a Range.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Marshmallow Meta options.
|
||||
|
||||
EXCLUDE, like StripMixin, because the single-slot endpoint is posted
|
||||
as a form and therefore carries `csrf_token`. Without this the schema
|
||||
rejected every valid submission — found by the test, not by reading.
|
||||
"""
|
||||
|
||||
unknown = EXCLUDE
|
||||
|
||||
day_of_week = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(min=0, max=6, error=_l('Day must be 0 (Monday) to 6 (Sunday).')),
|
||||
error_messages={
|
||||
'invalid': _l('Day must be 0 (Monday) to 6 (Sunday).'),
|
||||
'required': _l('A day is required.'),
|
||||
},
|
||||
)
|
||||
start_time = fields.String(
|
||||
start_time = fields.Time(
|
||||
required=True,
|
||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
|
||||
error_messages={
|
||||
'invalid': _l('Start time must be in HH:MM format.'),
|
||||
'required': _l('A start time is required.'),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""The date/time boundary, and what a bad slot does now.
|
||||
|
||||
MNT-12. The same parsing block lived in four modules with three different
|
||||
answers to the same bad input: flash and redirect, set the value to None and
|
||||
report success, or `continue` in silence. Waves E and G closed the match side
|
||||
with schemas; this covers what was left — availability, coach bookings and
|
||||
one-on-one requests.
|
||||
|
||||
Two of the findings here were not in the constat.
|
||||
|
||||
`OneOnOneRequestSchema` and `DisponibilityAddSchema` were defined in
|
||||
validators.py and **called from nowhere**: no route imported them, no test
|
||||
exercised them. That is the SEC-AUTHZ-001 pattern — a validation policy
|
||||
written down and not applied — surviving in a corner nobody had revisited.
|
||||
|
||||
And `manage_coach_availability` deleted every existing slot before re-adding
|
||||
the ones it could parse, skipping the rest silently and answering
|
||||
`{'success': true}`. A payload the browser mangled therefore wiped a coach's
|
||||
bookable hours and reported success. One-on-one requests are refused against
|
||||
exactly that table, so the coach became unbookable with nothing to show.
|
||||
"""
|
||||
|
||||
from datetime import date, time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.timeslots import DEFAULT_SLOT_MINUTES, day_name, slot_end
|
||||
|
||||
|
||||
class TestTheSlotRule:
|
||||
def test_a_slot_lasts_the_default(self):
|
||||
assert slot_end(time(9, 0)) == time(9, 30)
|
||||
|
||||
def test_the_default_is_the_one_the_ui_draws(self):
|
||||
assert DEFAULT_SLOT_MINUTES == 30
|
||||
|
||||
def test_it_does_not_depend_on_the_day_it_is_computed(self):
|
||||
"""The old helpers anchored on `datetime.today()`, so the result of a
|
||||
pure arithmetic function depended on when it ran."""
|
||||
assert slot_end(time(23, 45)) == slot_end(time(23, 45), on=date(2031, 2, 28))
|
||||
|
||||
def test_a_slot_may_wrap_past_midnight(self):
|
||||
"""Preserved from the previous implementations rather than changed:
|
||||
a coach may plausibly be free at 23:45, and refusing it here would be
|
||||
a new rule smuggled in with a refactor."""
|
||||
assert slot_end(time(23, 45)) == time(0, 15)
|
||||
|
||||
def test_an_out_of_range_day_is_named_not_raised(self):
|
||||
"""Indexing the list directly made an unexpected day an IndexError
|
||||
inside a JSON route — a 500 with an HTML body."""
|
||||
assert day_name(0) == 'Monday'
|
||||
assert day_name(6) == 'Sunday'
|
||||
assert 'Day' in day_name(9)
|
||||
|
||||
|
||||
class TestPlayerAvailability:
|
||||
def test_a_valid_slot_is_stored_with_its_computed_end(self, app, client, as_role):
|
||||
from app.models import PlayerDisponibility
|
||||
|
||||
player_id = as_role('player')
|
||||
|
||||
response = client.post(
|
||||
'/users/disponibilities/add', data={'day_of_week': '2', 'start_time': '14:00'}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
with app.app_context():
|
||||
slot = PlayerDisponibility.query.filter_by(player_id=player_id).one()
|
||||
assert slot.start_time == time(14, 0)
|
||||
assert slot.end_time == time(14, 30)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'payload',
|
||||
[
|
||||
{'day_of_week': '9', 'start_time': '14:00'},
|
||||
{'day_of_week': 'monday', 'start_time': '14:00'},
|
||||
{'day_of_week': '2', 'start_time': 'lunchtime'},
|
||||
{'day_of_week': '2'},
|
||||
],
|
||||
)
|
||||
def test_a_bad_slot_is_refused_in_json(self, app, client, as_role, payload):
|
||||
from app.models import PlayerDisponibility
|
||||
|
||||
as_role('player')
|
||||
|
||||
response = client.post('/users/disponibilities/add', data=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.is_json, 'this is a fetch() endpoint (STD-09)'
|
||||
with app.app_context():
|
||||
assert PlayerDisponibility.query.count() == 0
|
||||
|
||||
|
||||
class TestBulkAvailabilityReportsWhatItDropped:
|
||||
def test_valid_slots_are_saved(self, app, client, as_role):
|
||||
from app.models import PlayerDisponibility
|
||||
|
||||
as_role('player')
|
||||
|
||||
response = client.post(
|
||||
'/users/disponibilities/add_bulk',
|
||||
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
|
||||
)
|
||||
|
||||
assert response.get_json()['rejected'] == []
|
||||
with app.app_context():
|
||||
assert PlayerDisponibility.query.count() == 1
|
||||
|
||||
def test_a_dropped_slot_is_named(self, app, client, as_role):
|
||||
"""It used to `continue` and answer success, so the client could not
|
||||
tell nine saved from ten sent."""
|
||||
from app.models import PlayerDisponibility
|
||||
|
||||
as_role('player')
|
||||
|
||||
response = client.post(
|
||||
'/users/disponibilities/add_bulk',
|
||||
json={
|
||||
'slots': [
|
||||
{'day_of_week': 1, 'start_time': '09:00'},
|
||||
{'day_of_week': 1, 'start_time': 'nope'},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
body = response.get_json()
|
||||
assert len(body['created']) == 1
|
||||
assert len(body['rejected']) == 1, 'the caller must learn a slot was dropped'
|
||||
with app.app_context():
|
||||
assert PlayerDisponibility.query.count() == 1
|
||||
|
||||
|
||||
class TestCoachAvailabilityIsNotWipedByABadPayload:
|
||||
"""The destructive case. This route replaces the whole table for a coach."""
|
||||
|
||||
@pytest.fixture
|
||||
def coach_with_slots(self, app, client, as_role):
|
||||
from app.models import CoachAvailability
|
||||
|
||||
coach_id = as_role('coach')
|
||||
response = client.post(
|
||||
'/users/coach-availability',
|
||||
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
with app.app_context():
|
||||
assert CoachAvailability.query.filter_by(coach_id=coach_id).count() == 1
|
||||
return coach_id
|
||||
|
||||
def test_a_good_payload_replaces_the_slots(self, app, client, coach_with_slots):
|
||||
from app.models import CoachAvailability
|
||||
|
||||
client.post(
|
||||
'/users/coach-availability',
|
||||
json={'slots': [{'day_of_week': 3, 'start_time': '18:00'}]},
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
slots = CoachAvailability.query.filter_by(coach_id=coach_with_slots).all()
|
||||
assert [(s.day_of_week, s.start_time) for s in slots] == [(3, time(18, 0))]
|
||||
|
||||
def test_a_malformed_slot_changes_nothing(self, app, client, coach_with_slots):
|
||||
"""Before: the delete had already run, the bad slot was skipped, and
|
||||
the answer was `{'success': true}` over an emptied table."""
|
||||
from app.models import CoachAvailability
|
||||
|
||||
response = client.post(
|
||||
'/users/coach-availability',
|
||||
json={'slots': [{'day_of_week': 3, 'start_time': 'quarter past'}]},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
with app.app_context():
|
||||
slots = CoachAvailability.query.filter_by(coach_id=coach_with_slots).all()
|
||||
assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))], (
|
||||
'the coach lost their bookable hours to a payload that was refused'
|
||||
)
|
||||
|
||||
def test_one_bad_slot_among_good_ones_still_changes_nothing(
|
||||
self, app, client, coach_with_slots
|
||||
):
|
||||
from app.models import CoachAvailability
|
||||
|
||||
response = client.post(
|
||||
'/users/coach-availability',
|
||||
json={
|
||||
'slots': [
|
||||
{'day_of_week': 3, 'start_time': '18:00'},
|
||||
{'day_of_week': 99, 'start_time': '19:00'},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
with app.app_context():
|
||||
slots = CoachAvailability.query.filter_by(coach_id=coach_with_slots).all()
|
||||
assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))]
|
||||
|
||||
|
||||
class TestOneOnOneRequestGoesThroughItsSchema:
|
||||
@pytest.fixture
|
||||
def bookable(self, app, make_user):
|
||||
"""A player on a team whose coach is free Monday 09:00–10:00."""
|
||||
from app.models import CoachAvailability, OrgTeam, TeamPlayer
|
||||
|
||||
admin_id = make_user('admin')
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
team = OrgTeam(name='Varsity', created_by=admin_id, coach_id=coach_id)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
db.session.add(TeamPlayer(player_id=player_id, org_team_id=team.id))
|
||||
db.session.add(
|
||||
CoachAvailability(
|
||||
coach_id=coach_id,
|
||||
day_of_week=0,
|
||||
start_time=time(9, 0),
|
||||
end_time=time(10, 0),
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
return player_id
|
||||
|
||||
def _login(self, app, client, login, user_id):
|
||||
from app.models import User
|
||||
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
assert login(username).status_code in (301, 302)
|
||||
|
||||
def test_a_request_inside_the_window_is_recorded(self, app, client, login, bookable):
|
||||
from app.models import OneOnOneRequest
|
||||
|
||||
self._login(app, client, login, bookable)
|
||||
|
||||
client.post(
|
||||
'/users/one-on-one',
|
||||
data={
|
||||
'date': '2030-01-07', # a Monday
|
||||
'start_time': '09:00',
|
||||
'end_time': '09:30',
|
||||
'points': 'aim',
|
||||
},
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
booked = OneOnOneRequest.query.one()
|
||||
assert booked.date == date(2030, 1, 7)
|
||||
assert booked.start_time == time(9, 0)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'form',
|
||||
[
|
||||
{'date': 'tomorrow', 'start_time': '09:00', 'end_time': '09:30'},
|
||||
{'date': '2030-01-07', 'start_time': '9am', 'end_time': '09:30'},
|
||||
{'date': '2030-01-07', 'start_time': '09:00'},
|
||||
# End before start: nothing checked this, and the request still
|
||||
# reached the coach's Discord.
|
||||
{'date': '2030-01-07', 'start_time': '09:30', 'end_time': '09:00'},
|
||||
],
|
||||
)
|
||||
def test_a_malformed_request_is_refused(self, app, client, login, bookable, form):
|
||||
from app.models import OneOnOneRequest
|
||||
|
||||
self._login(app, client, login, bookable)
|
||||
|
||||
response = client.post('/users/one-on-one', data=form, follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500
|
||||
with app.app_context():
|
||||
assert OneOnOneRequest.query.count() == 0
|
||||
|
||||
def test_a_request_outside_the_window_is_still_refused(self, app, client, login, bookable):
|
||||
"""The availability check survived the rewrite: it now compares times
|
||||
rather than the original strings."""
|
||||
from app.models import OneOnOneRequest
|
||||
|
||||
self._login(app, client, login, bookable)
|
||||
|
||||
client.post(
|
||||
'/users/one-on-one',
|
||||
data={'date': '2030-01-07', 'start_time': '14:00', 'end_time': '14:30'},
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert OneOnOneRequest.query.count() == 0
|
||||
Reference in New Issue
Block a user