perf: borner le calendrier et remplacer trois boucles par des requetes
PERF-002, PERF-003, PERF-004. Aucun changement de comportement : chaque reecriture est accompagnee de tests qui enoncent la reponse attendue, pas la methode. PERF-002 -- /matches/api/events Le flux parcourait `tryout.matches` pour chaque tryout visible -- pour un president, tout l historique du club -- puis posait une requete MatchParticipant PAR match pour savoir si la personne qui regarde y figure. Le cout du calendrier croissait avec l historique, a chaque navigation. FullCalendar envoie deja `start` et `end` sur une source d evenements de type URL. Personne ne les lisait. La requete est desormais bornee, et les participants de tous les matchs de la fenetre sont charges en une fois, joueur compris. Des bornes illisibles sont ignorees plutot que refusees : un calendrier qui en montre trop est un probleme de performance, un calendrier qui renvoie 400 est une page blanche. PERF-003 -- get_players_available_at_time Chargeait tous les joueurs actifs, puis une requete PlayerDisponibility par joueur, sur une colonne non indexee. Deux requetes desormais, quelle que soit la taille du club. Mesure dans le test : 7 requetes pour 6 joueurs avant, 2 apres. PERF-004 -- decompte des evaluations en attente Chargeait toutes les inscriptions du club et toutes les evaluations du coach, construisait deux ensembles Python et les soustrayait -- deux lectures de table entiere pour produire un entier. Un COUNT DISTINCT avec NOT EXISTS. Les tests couvrent ce que la reecriture aurait pu changer sans bruit : fin de creneau exclusive, compte desactive exclu, joueur a cheval sur deux creneaux compte une fois, evaluation d un autre coach qui ne libere pas la ligne, double inscription comptee une fois (DB-006 n a pas encore atterri, donc le cas existe). PERF-001 (view_tryout) n est pas fait : c est le plus gros des quatre, il touche la page la plus consultee et merite son propre lot. 23 tests ajoutes, 371 au total. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+21
-8
@@ -116,14 +116,27 @@ def dashboard():
|
||||
|
||||
elif isinstance(user, Coach):
|
||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
registrations = TryoutRegistration.query.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended'])
|
||||
).all()
|
||||
registered_player_ids = [r.player_id for r in registrations]
|
||||
evaluated_player_ids = [
|
||||
e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()
|
||||
]
|
||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
||||
|
||||
# A count, computed as a count. This used to load every registration
|
||||
# row in the club and every evaluation this coach had written, build
|
||||
# two Python sets and subtract them — two full table reads to produce
|
||||
# one integer (PERF-004).
|
||||
already_evaluated = (
|
||||
db.session.query(Evaluation.player_id)
|
||||
.filter(
|
||||
Evaluation.evaluator_id == user.id,
|
||||
Evaluation.player_id == TryoutRegistration.player_id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
stats['pending_evaluations'] = (
|
||||
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
|
||||
.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended']),
|
||||
~already_evaluated,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
stats['my_recent_evaluations'] = (
|
||||
Evaluation.query.filter_by(evaluator_id=user.id)
|
||||
.order_by(Evaluation.created_at.desc())
|
||||
|
||||
+108
-26
@@ -25,6 +25,8 @@ from app.models import (
|
||||
PersonalNote,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
|
||||
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||
@@ -50,15 +52,76 @@ def calendar():
|
||||
return render_template('pages/calendar.html')
|
||||
|
||||
|
||||
def calendar_window(args):
|
||||
"""The date range FullCalendar is asking about, if it said.
|
||||
|
||||
A URL event source appends `start` and `end` automatically, in ISO 8601
|
||||
with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
|
||||
needed here, and a value that does not parse is treated as absent
|
||||
rather than as an error: a calendar that shows too much is a
|
||||
performance problem, one that 400s is a broken page.
|
||||
|
||||
Args:
|
||||
args: request.args.
|
||||
|
||||
Returns:
|
||||
tuple[date | None, date | None]: Inclusive bounds.
|
||||
"""
|
||||
|
||||
def _parse(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
return _parse(args.get('start')), _parse(args.get('end'))
|
||||
|
||||
|
||||
@matches_bp.route('/api/events')
|
||||
@login_required
|
||||
def api_events():
|
||||
"""API endpoint returning calendar events for FullCalendar."""
|
||||
"""Calendar events for FullCalendar.
|
||||
|
||||
Bounded and batched (PERF-002). This used to walk `tryout.matches` for
|
||||
every visible tryout — every tryout the club has ever run, for a
|
||||
president — and then issue one MatchParticipant query per match to find
|
||||
out whether the viewer was in it. The calendar's cost grew with the
|
||||
whole history, on every navigation.
|
||||
"""
|
||||
events = []
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||
|
||||
for tryout in tryouts:
|
||||
for match in tryout.matches:
|
||||
if tryouts_by_id:
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||
if window_start:
|
||||
query = query.filter(Match.date >= window_start)
|
||||
if window_end:
|
||||
query = query.filter(Match.date <= window_end)
|
||||
matches = query.all()
|
||||
|
||||
# Participants for every match in the window, in one query rather
|
||||
# than one per match. `participants` is a dynamic relationship, so
|
||||
# eager loading options do not apply to it.
|
||||
match_ids = [match.id for match in matches]
|
||||
participants_by_match = {}
|
||||
mine_by_match = {}
|
||||
if match_ids:
|
||||
rows = (
|
||||
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
|
||||
.options(joinedload(MatchParticipant.player))
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
participants_by_match.setdefault(row.match_id, []).append(row)
|
||||
if row.player_id == current_user.id:
|
||||
mine_by_match[row.match_id] = row
|
||||
|
||||
for match in matches:
|
||||
tryout = tryouts_by_id[match.tryout_id]
|
||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||
# 'description' used to be participants_str + '<br>' + description.
|
||||
# Building presentation markup inside a JSON field is what carried
|
||||
@@ -75,18 +138,16 @@ def api_events():
|
||||
teams.append(match.team2.name)
|
||||
participants_str = ' vs '.join(teams)
|
||||
else:
|
||||
player_names = []
|
||||
for p in match.participants.all():
|
||||
player_names.append(p.player.username if p.player else 'Unknown Player')
|
||||
player_names = [
|
||||
p.player.username if p.player else 'Unknown Player'
|
||||
for p in participants_by_match.get(match.id, [])
|
||||
]
|
||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
user_participant = MatchParticipant.query.filter_by(
|
||||
match_id=match.id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
user_participant = mine_by_match.get(match.id)
|
||||
|
||||
events.append(
|
||||
{
|
||||
@@ -625,7 +686,20 @@ def delete_match(match_id):
|
||||
|
||||
|
||||
def get_players_available_at_time(date_str, time_str):
|
||||
"""Get list of player IDs available at a specific date and time."""
|
||||
"""Player IDs whose weekly availability covers this date and time.
|
||||
|
||||
Two queries, whatever the size of the club. This used to load every
|
||||
active player and then run one PlayerDisponibility query per player, on
|
||||
an unindexed column — sixty players meant sixty-one round trips to
|
||||
answer a question the database can answer in one (PERF-003).
|
||||
|
||||
Args:
|
||||
date_str: 'YYYY-MM-DD'.
|
||||
time_str: 'HH:MM'.
|
||||
|
||||
Returns:
|
||||
list[int]: Player IDs, empty when the input does not parse.
|
||||
"""
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
||||
@@ -633,22 +707,30 @@ def get_players_available_at_time(date_str, time_str):
|
||||
return []
|
||||
|
||||
day_of_week = parsed_date.weekday()
|
||||
active_player_ids = {
|
||||
row.id
|
||||
for row in User.query.with_entities(User.id)
|
||||
.filter_by(role='player', is_active_account=True)
|
||||
.all()
|
||||
}
|
||||
if not active_player_ids:
|
||||
return []
|
||||
|
||||
players = User.query.filter_by(role='player', is_active_account=True).all()
|
||||
available_players = []
|
||||
for player in players:
|
||||
disponibilities = PlayerDisponibility.query.filter_by(
|
||||
player_id=player.id,
|
||||
day_of_week=day_of_week,
|
||||
).all()
|
||||
for disp in disponibilities:
|
||||
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||
match_time = time_obj.hour * 60 + time_obj.minute
|
||||
if disp_start <= match_time < disp_end:
|
||||
available_players.append(player.id)
|
||||
break
|
||||
return available_players
|
||||
# The comparison stays in Python: start_time and end_time are stored as
|
||||
# time columns, and comparing them in SQL across three backends is not
|
||||
# worth the portability risk for a single day's rows.
|
||||
minutes = time_obj.hour * 60 + time_obj.minute
|
||||
available = []
|
||||
seen = set()
|
||||
for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
|
||||
if disp.player_id in seen or disp.player_id not in active_player_ids:
|
||||
continue
|
||||
start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||
if start <= minutes < end:
|
||||
available.append(disp.player_id)
|
||||
seen.add(disp.player_id)
|
||||
return available
|
||||
|
||||
|
||||
@matches_bp.route('/api/available_players/<date>/<time>')
|
||||
|
||||
@@ -147,3 +147,114 @@ class TestLegacyHostileData:
|
||||
# as markup.
|
||||
assert props['description'] == 'Normal text'
|
||||
assert self.PAYLOAD not in props['description']
|
||||
|
||||
|
||||
class TestTheWindow:
|
||||
"""PERF-002 — the feed used to return every match of every visible
|
||||
tryout, which for a president is the club's whole history, and then ask
|
||||
one more question per match to find out whether the viewer was in it.
|
||||
|
||||
FullCalendar sends `start` and `end` with a URL event source, so the
|
||||
bounds were already arriving; nothing read them."""
|
||||
|
||||
def test_a_match_inside_the_window_is_returned(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id)
|
||||
|
||||
payload = client.get(
|
||||
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
|
||||
).get_json()
|
||||
|
||||
assert _match_event(payload)['date'] == '2030-05-02'
|
||||
|
||||
def test_a_match_outside_the_window_is_not(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id)
|
||||
|
||||
payload = client.get('/matches/api/events?start=2031-01-01&end=2031-01-31').get_json()
|
||||
|
||||
assert [e for e in payload if e['id'].startswith('match_')] == []
|
||||
|
||||
def test_no_bounds_still_returns_everything(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
"""Bookmarks, the tryout detail page and anything else calling this
|
||||
without bounds must keep working."""
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id)
|
||||
|
||||
payload = client.get('/matches/api/events').get_json()
|
||||
|
||||
assert _match_event(payload)['date'] == '2030-05-02'
|
||||
|
||||
def test_unparseable_bounds_are_ignored_rather_than_rejected(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
"""A calendar showing too much is a performance problem; one that
|
||||
returns 400 is a blank page."""
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id)
|
||||
|
||||
response = client.get('/matches/api/events?start=whenever&end=')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _match_event(response.get_json())['date'] == '2030-05-02'
|
||||
|
||||
def test_the_viewers_own_participation_still_comes_back(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
"""Batching the participant lookup must not lose the one row the
|
||||
modal uses to offer 'confirm attendance'."""
|
||||
admin_id = make_user('admin')
|
||||
player_id = as_role('player')
|
||||
_tryout_id, match_id = match_factory(admin_id, player_id)
|
||||
|
||||
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
|
||||
|
||||
assert props['user_participant_id'] is not None
|
||||
assert props['user_attendance_confirmed'] is False
|
||||
assert props['match_id'] == match_id
|
||||
|
||||
def test_someone_elses_participation_is_not_reported_as_yours(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
other_player = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, other_player)
|
||||
|
||||
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
|
||||
|
||||
assert props['user_participant_id'] is None
|
||||
|
||||
|
||||
class TestTheWindowParser:
|
||||
def test_it_reads_the_date_out_of_an_iso_timestamp(self, app):
|
||||
from datetime import date as date_type
|
||||
|
||||
from app.routes.matches import calendar_window
|
||||
|
||||
with app.test_request_context(
|
||||
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T23:59:59-04:00'
|
||||
):
|
||||
from flask import request
|
||||
|
||||
assert calendar_window(request.args) == (
|
||||
date_type(2030, 5, 1),
|
||||
date_type(2030, 5, 31),
|
||||
)
|
||||
|
||||
def test_missing_or_broken_bounds_read_as_absent(self, app):
|
||||
from app.routes.matches import calendar_window
|
||||
|
||||
with app.test_request_context('/matches/api/events?start=nope'):
|
||||
from flask import request
|
||||
|
||||
assert calendar_window(request.args) == (None, None)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Two rewrites that had to keep answering the same thing — PERF-003/004.
|
||||
|
||||
Both replaced a Python loop over rows with a query. The risk of that kind
|
||||
of change is not that it breaks loudly; it is that it quietly answers
|
||||
something slightly different — a player counted twice, an inactive account
|
||||
included, a NULL swallowed. So these tests state the answer, not the plan.
|
||||
|
||||
The query counts are asserted too: the whole point was the number of round
|
||||
trips, and nothing else in the suite would notice it creeping back.
|
||||
"""
|
||||
|
||||
from datetime import date, time
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Evaluation, PlayerDisponibility, Tryout, TryoutRegistration, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def count_queries(app):
|
||||
"""Count SELECT statements issued inside the block."""
|
||||
|
||||
class Counter:
|
||||
def __init__(self):
|
||||
self.total = 0
|
||||
|
||||
def _counter():
|
||||
counter = Counter()
|
||||
|
||||
def _before(conn, cursor, statement, parameters, context, executemany):
|
||||
if statement.lstrip().upper().startswith('SELECT'):
|
||||
counter.total += 1
|
||||
|
||||
event.listen(db.engine, 'before_cursor_execute', _before)
|
||||
counter.stop = lambda: event.remove(db.engine, 'before_cursor_execute', _before)
|
||||
return counter
|
||||
|
||||
return _counter
|
||||
|
||||
|
||||
class TestAvailableAtTime:
|
||||
"""get_players_available_at_time ran one query per active player."""
|
||||
|
||||
@staticmethod
|
||||
def _slot(player_id, day, start, end):
|
||||
db.session.add(
|
||||
PlayerDisponibility(
|
||||
player_id=player_id, day_of_week=day, start_time=start, end_time=end
|
||||
)
|
||||
)
|
||||
|
||||
def test_a_player_inside_the_window_is_returned(self, app, make_user):
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
# 2030-04-01 is a Monday, weekday() == 0.
|
||||
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
||||
db.session.commit()
|
||||
|
||||
assert get_players_available_at_time('2030-04-01', '19:00') == [player_id]
|
||||
|
||||
def test_the_end_of_the_window_is_exclusive(self, app, make_user):
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
||||
db.session.commit()
|
||||
|
||||
assert get_players_available_at_time('2030-04-01', '21:00') == []
|
||||
assert get_players_available_at_time('2030-04-01', '18:00') == [player_id]
|
||||
|
||||
def test_another_day_does_not_count(self, app, make_user):
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
self._slot(player_id, 2, time(18, 0), time(21, 0))
|
||||
db.session.commit()
|
||||
|
||||
assert get_players_available_at_time('2030-04-01', '19:00') == []
|
||||
|
||||
def test_a_deactivated_account_is_excluded(self, app, make_user):
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
db.session.get(User, player_id).is_active_account = False
|
||||
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
||||
db.session.commit()
|
||||
|
||||
assert get_players_available_at_time('2030-04-01', '19:00') == []
|
||||
|
||||
def test_two_overlapping_slots_report_the_player_once(self, app, make_user):
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
||||
self._slot(player_id, 0, time(19, 0), time(22, 0))
|
||||
db.session.commit()
|
||||
|
||||
assert get_players_available_at_time('2030-04-01', '19:30') == [player_id]
|
||||
|
||||
def test_bad_input_answers_nothing(self, app):
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
with app.app_context():
|
||||
assert get_players_available_at_time('not-a-date', '19:00') == []
|
||||
assert get_players_available_at_time('2030-04-01', '99:99') == []
|
||||
|
||||
def test_the_cost_does_not_grow_with_the_squad(self, app, make_user, count_queries):
|
||||
"""One query per player is what this replaced."""
|
||||
player_ids = [make_user('player') for _ in range(6)]
|
||||
with app.app_context():
|
||||
for player_id in player_ids:
|
||||
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
||||
db.session.commit()
|
||||
|
||||
from app.routes.matches import get_players_available_at_time
|
||||
|
||||
counter = count_queries()
|
||||
try:
|
||||
result = get_players_available_at_time('2030-04-01', '19:00')
|
||||
finally:
|
||||
counter.stop()
|
||||
|
||||
assert sorted(result) == sorted(player_ids)
|
||||
# The lower bound matters as much as the upper one: a listener
|
||||
# that never fires would make this assertion vacuous.
|
||||
assert 1 <= counter.total <= 2, f'{counter.total} SELECTs for 6 players'
|
||||
|
||||
|
||||
class TestPendingEvaluations:
|
||||
"""The coach dashboard loaded every registration in the club and every
|
||||
evaluation the coach had written, to produce one integer."""
|
||||
|
||||
@staticmethod
|
||||
def _register(player_id, tryout_id, status='registered'):
|
||||
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id, status=status))
|
||||
|
||||
@pytest.fixture
|
||||
def tryout_id(self, app, make_user):
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='Open', game='Valorant', date=date(2030, 3, 1), created_by=make_user('admin')
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.commit()
|
||||
return tryout.id
|
||||
|
||||
def _pending(self, client):
|
||||
"""The number the dashboard renders, read back from the view."""
|
||||
from app.routes import main
|
||||
|
||||
captured = {}
|
||||
original = main.render_template
|
||||
|
||||
def _capture(template, **context):
|
||||
captured.update(context)
|
||||
return original(template, **context)
|
||||
|
||||
main.render_template = _capture
|
||||
try:
|
||||
client.get('/dashboard')
|
||||
finally:
|
||||
main.render_template = original
|
||||
return captured['stats']['pending_evaluations']
|
||||
|
||||
def test_a_registered_player_is_pending(self, app, client, as_role, make_user, tryout_id):
|
||||
player_id = make_user('player')
|
||||
as_role('coach')
|
||||
with app.app_context():
|
||||
self._register(player_id, tryout_id)
|
||||
db.session.commit()
|
||||
|
||||
assert self._pending(client) == 1
|
||||
|
||||
def test_an_evaluated_player_is_not(self, app, client, as_role, make_user, tryout_id):
|
||||
player_id = make_user('player')
|
||||
coach_id = as_role('coach')
|
||||
with app.app_context():
|
||||
self._register(player_id, tryout_id)
|
||||
db.session.add(
|
||||
Evaluation(player_id=player_id, evaluator_id=coach_id, tryout_id=tryout_id)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
assert self._pending(client) == 0
|
||||
|
||||
def test_another_coachs_evaluation_does_not_clear_it(
|
||||
self, app, client, as_role, make_user, tryout_id
|
||||
):
|
||||
"""The tally is per coach: someone else's work is not yours."""
|
||||
player_id = make_user('player')
|
||||
other_coach = make_user('coach')
|
||||
as_role('coach')
|
||||
with app.app_context():
|
||||
self._register(player_id, tryout_id)
|
||||
db.session.add(
|
||||
Evaluation(player_id=player_id, evaluator_id=other_coach, tryout_id=tryout_id)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
assert self._pending(client) == 1
|
||||
|
||||
def test_a_withdrawn_registration_is_not_counted(
|
||||
self, app, client, as_role, make_user, tryout_id
|
||||
):
|
||||
player_id = make_user('player')
|
||||
as_role('coach')
|
||||
with app.app_context():
|
||||
self._register(player_id, tryout_id, status='withdrawn')
|
||||
db.session.commit()
|
||||
|
||||
assert self._pending(client) == 0
|
||||
|
||||
def test_a_player_registered_twice_counts_once(
|
||||
self, app, client, as_role, make_user, tryout_id
|
||||
):
|
||||
"""DB-006 has not landed yet, so double registrations are possible;
|
||||
the old set() absorbed them and the new count must too."""
|
||||
player_id = make_user('player')
|
||||
as_role('coach')
|
||||
with app.app_context():
|
||||
self._register(player_id, tryout_id)
|
||||
self._register(player_id, tryout_id, status='attended')
|
||||
db.session.commit()
|
||||
|
||||
assert self._pending(client) == 1
|
||||
Reference in New Issue
Block a user