Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d994d88c12 | ||
|
|
b9791d1547 |
+32
-11
@@ -686,6 +686,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
event_date: str,
|
event_date: str,
|
||||||
event_time: str,
|
event_time: str,
|
||||||
reference_id: int,
|
reference_id: int,
|
||||||
|
change_kind: str = 'scheduled',
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Send a schedule addition notification to a player.
|
"""Send a schedule addition notification to a player.
|
||||||
|
|
||||||
@@ -696,6 +697,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
event_date: Date string.
|
event_date: Date string.
|
||||||
event_time: Time string.
|
event_time: Time string.
|
||||||
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
||||||
|
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
|
||||||
"""
|
"""
|
||||||
# Look up the DB user to get their Discord user ID
|
# Look up the DB user to get their Discord user ID
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -712,16 +714,29 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
event_name = "Match" if event_type == 'match' else "Tryout"
|
event_name = "Match" if event_type == 'match' else "Tryout"
|
||||||
|
|
||||||
message = (
|
if change_kind == 'modified':
|
||||||
f"📅 **{event_name} Scheduled**\n\n"
|
message = (
|
||||||
f"You have been added to the following {event_type}:\n"
|
f"📅 **{event_name} Modified**\n\n"
|
||||||
f"**{event_title}**\n"
|
f"The {event_type} date and/or time has been modified for "
|
||||||
f"**Date:** {event_date}\n"
|
f"**{event_title}**.\n"
|
||||||
f"**Time:** {event_time}\n\n"
|
f"The new date and/or time is:\n"
|
||||||
"Please confirm your attendance:\n"
|
f"**Date:** {event_date}\n"
|
||||||
f"{CHECK_EMOJI} - Confirm attendance\n"
|
f"**Time:** {event_time}\n\n"
|
||||||
f"{CROSS_EMOJI} - Decline"
|
"Please confirm your attendance:\n"
|
||||||
)
|
f"{CHECK_EMOJI} - Confirm attendance\n"
|
||||||
|
f"{CROSS_EMOJI} - Decline"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"📅 **{event_name} Scheduled**\n\n"
|
||||||
|
f"You have been added to the following {event_type}:\n"
|
||||||
|
f"**{event_title}**\n"
|
||||||
|
f"**Date:** {event_date}\n"
|
||||||
|
f"**Time:** {event_time}\n\n"
|
||||||
|
"Please confirm your attendance:\n"
|
||||||
|
f"{CHECK_EMOJI} - Confirm attendance\n"
|
||||||
|
f"{CROSS_EMOJI} - Decline"
|
||||||
|
)
|
||||||
|
|
||||||
msg = await self._send_dm(
|
msg = await self._send_dm(
|
||||||
db_user.discord_user_id,
|
db_user.discord_user_id,
|
||||||
@@ -1444,8 +1459,13 @@ def send_schedule_notification(
|
|||||||
event_date: str,
|
event_date: str,
|
||||||
event_time: str,
|
event_time: str,
|
||||||
reference_id: int,
|
reference_id: int,
|
||||||
|
change_kind: str = 'scheduled',
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Queue a schedule addition notification to be sent by the bot."""
|
"""Queue a schedule addition notification to be sent by the bot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
|
||||||
|
"""
|
||||||
return _enqueue(
|
return _enqueue(
|
||||||
'schedule_addition',
|
'schedule_addition',
|
||||||
{
|
{
|
||||||
@@ -1455,6 +1475,7 @@ def send_schedule_notification(
|
|||||||
'event_date': event_date,
|
'event_date': event_date,
|
||||||
'event_time': event_time,
|
'event_time': event_time,
|
||||||
'reference_id': reference_id,
|
'reference_id': reference_id,
|
||||||
|
'change_kind': change_kind,
|
||||||
},
|
},
|
||||||
description=f'the {event_type} notification for user {user_id}',
|
description=f'the {event_type} notification for user {user_id}',
|
||||||
)
|
)
|
||||||
|
|||||||
+57
-20
@@ -37,6 +37,32 @@ def _users_by_id(user_ids):
|
|||||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_score(score_value):
|
||||||
|
"""Turn a raw form value into a criterion score (1..10), or None.
|
||||||
|
|
||||||
|
A blank value means "not assessed". Anything else outside 1..10 is a
|
||||||
|
validation error, matching the single-player form's ``EvaluationSchema``
|
||||||
|
-- a coach must not be able to quietly turn 11 or 0 into "no score"
|
||||||
|
(QUA-003).
|
||||||
|
"""
|
||||||
|
if score_value is None or str(score_value).strip() == '':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
score = int(score_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValidationError('A score must be a whole number from 1 to 10.') from exc
|
||||||
|
if not 1 <= score <= 10:
|
||||||
|
raise ValidationError('Scores run from 1 to 10.')
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_evaluation(evaluation, scores, comments, position):
|
||||||
|
"""Write validated scores, comments and position onto an Evaluation."""
|
||||||
|
evaluation.apply_scores(scores)
|
||||||
|
evaluation.comments = comments
|
||||||
|
evaluation.position_recommendation = position
|
||||||
|
|
||||||
|
|
||||||
@evaluations_bp.route('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
@@ -317,27 +343,38 @@ def batch_evaluate(tryout_id):
|
|||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
saved = 0
|
saved = 0
|
||||||
for entry in players:
|
try:
|
||||||
pid = entry['player'].id
|
for entry in players:
|
||||||
scores = {
|
pid = entry['player'].id
|
||||||
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
scores = {
|
||||||
for field_name, _ in EVALUATION_CRITERIA
|
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||||
}
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
comments = request.form.get(f'comments_{pid}')
|
}
|
||||||
position = request.form.get(f'position_recommendation_{pid}')
|
comments = request.form.get(f'comments_{pid}')
|
||||||
|
position = request.form.get(f'position_recommendation_{pid}')
|
||||||
|
|
||||||
existing = entry['existing']
|
existing = entry['existing']
|
||||||
if existing:
|
if existing:
|
||||||
_apply_evaluation(existing, scores, comments, position)
|
_apply_evaluation(existing, scores, comments, position)
|
||||||
else:
|
else:
|
||||||
evaluation = Evaluation(
|
evaluation = Evaluation(
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
player_id=pid,
|
player_id=pid,
|
||||||
evaluator_id=current_user.id,
|
evaluator_id=current_user.id,
|
||||||
)
|
)
|
||||||
_apply_evaluation(evaluation, scores, comments, position)
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
db.session.add(evaluation)
|
db.session.add(evaluation)
|
||||||
saved += 1
|
saved += 1
|
||||||
|
except ValidationError:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('Scores must be whole numbers from 1 to 10.'), 'danger')
|
||||||
|
return render_template(
|
||||||
|
'pages/batch_evaluate.html',
|
||||||
|
tryout=tryout,
|
||||||
|
players=players,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
game_positions=GAME_POSITIONS,
|
||||||
|
)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
|
|||||||
+92
-43
@@ -570,6 +570,12 @@ def edit_match(match_id):
|
|||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return rerender()
|
return rerender()
|
||||||
|
|
||||||
|
# Snapshot the date/time before we overwrite them, so we can detect
|
||||||
|
# whether only these fields changed and choose the right Discord message.
|
||||||
|
old_date = match.date
|
||||||
|
old_start = match.start_time
|
||||||
|
old_end = match.end_time
|
||||||
|
|
||||||
# Assigned only once the whole form has been accepted. Assigning as
|
# Assigned only once the whole form has been accepted. Assigning as
|
||||||
# each field was read meant a form rejected halfway had already
|
# each field was read meant a form rejected halfway had already
|
||||||
# changed the record in the session.
|
# changed the record in the session.
|
||||||
@@ -581,60 +587,103 @@ def edit_match(match_id):
|
|||||||
match.location = data['location']
|
match.location = data['location']
|
||||||
match.status = data['status']
|
match.status = data['status']
|
||||||
|
|
||||||
|
time_changed = (
|
||||||
|
match.date != old_date or match.start_time != old_start or match.end_time != old_end
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the desired participant set and reconcile it against the
|
||||||
|
# existing participants, rather than deleting and recreating everyone.
|
||||||
|
# This preserves attendance_confirmed for players who stay.
|
||||||
|
desired = set()
|
||||||
|
if match.match_type == 'team_vs_team':
|
||||||
|
match.team1_id = data['team1_id']
|
||||||
|
match.team2_id = data['team2_id']
|
||||||
|
for team_id in (data['team1_id'], data['team2_id']):
|
||||||
|
if team_id:
|
||||||
|
for member in TeamMember.query.filter_by(team_id=team_id).all():
|
||||||
|
desired.add(member.player_id)
|
||||||
|
elif match.match_type == 'player_vs_player':
|
||||||
|
for pid in data['team1_player_ids']:
|
||||||
|
desired.add(pid)
|
||||||
|
for pid in data['team2_player_ids']:
|
||||||
|
desired.add(pid)
|
||||||
|
else:
|
||||||
|
for pid in data['player_ids']:
|
||||||
|
desired.add(pid)
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
p.player_id: p for p in MatchParticipant.query.filter_by(match_id=match.id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
removed_ids = []
|
||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
|
kept_ids = set()
|
||||||
|
|
||||||
# Capture old participant ids before any deletion, so we can purge
|
for player_id, participant in list(existing.items()):
|
||||||
# their pending Discord notifications once the change is committed.
|
if player_id in desired:
|
||||||
stale_participant_ids = [
|
kept_ids.add(player_id)
|
||||||
p.id for p in MatchParticipant.query.filter_by(match_id=match.id).all()
|
notified_player_ids.append(player_id)
|
||||||
]
|
notified_participant_ids.append(participant.id)
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
|
||||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
|
||||||
if teams_changed:
|
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
|
||||||
match.team1_id = data['team1_id']
|
|
||||||
match.team2_id = data['team2_id']
|
|
||||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
|
||||||
else:
|
else:
|
||||||
# Same teams: the roster stands, but everyone is told again,
|
removed_ids.append(participant.id)
|
||||||
# because the date or the time may have moved.
|
db.session.delete(participant)
|
||||||
# Reuse the existing participant ids so reference_id is always
|
|
||||||
# a real participant row, never the match id (fallback).
|
# New players get new participant rows.
|
||||||
for team_id in (match.team1_id, match.team2_id):
|
for player_id in desired - set(existing.keys()):
|
||||||
if team_id:
|
participant = MatchParticipant(match_id=match.id, player_id=player_id)
|
||||||
for member in TeamMember.query.filter_by(team_id=team_id).all():
|
db.session.add(participant)
|
||||||
notified_player_ids.append(member.player_id)
|
db.session.flush()
|
||||||
existing = MatchParticipant.query.filter_by(
|
notified_player_ids.append(player_id)
|
||||||
match_id=match.id, player_id=member.player_id
|
notified_participant_ids.append(participant.id)
|
||||||
).first()
|
kept_ids.add(player_id)
|
||||||
if existing:
|
|
||||||
notified_participant_ids.append(existing.id)
|
# When the date or time changed, reset attendance on everyone who
|
||||||
stale_participant_ids = [
|
# stayed. They'll be asked to re-confirm via the modified message.
|
||||||
pid for pid in stale_participant_ids if pid != existing.id
|
if time_changed:
|
||||||
]
|
for participant in MatchParticipant.query.filter_by(match_id=match.id).all():
|
||||||
else:
|
if participant.player_id in kept_ids:
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
participant.attendance_confirmed = False
|
||||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Purge Discord pending entries for participants that no longer exist.
|
# Purge Discord pending entries for participants that no longer exist.
|
||||||
# This runs after the commit so the bot only sees the durable state.
|
if removed_ids:
|
||||||
if stale_participant_ids:
|
|
||||||
from app.discord_bot import purge_pending_for_participants
|
from app.discord_bot import purge_pending_for_participants
|
||||||
|
|
||||||
purge_pending_for_participants(stale_participant_ids)
|
purge_pending_for_participants(removed_ids)
|
||||||
|
|
||||||
notify_participants(
|
# Decide whether and what to notify.
|
||||||
title=match.title,
|
if time_changed:
|
||||||
date=match.date,
|
# All current participants get the "modified" message.
|
||||||
start_time=match.start_time,
|
all_current = list(zip(notified_player_ids, notified_participant_ids))
|
||||||
end_time=match.end_time,
|
notify_participants(
|
||||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
title=match.title,
|
||||||
fallback_id=match.id,
|
date=match.date,
|
||||||
)
|
start_time=match.start_time,
|
||||||
|
end_time=match.end_time,
|
||||||
|
participants=all_current,
|
||||||
|
fallback_id=match.id,
|
||||||
|
change_kind='modified',
|
||||||
|
)
|
||||||
|
elif desired - set(existing.keys()):
|
||||||
|
# Only the newly added players are told about the match.
|
||||||
|
new_player_ids = desired - set(existing.keys())
|
||||||
|
new_pairs = [
|
||||||
|
(pid, nid)
|
||||||
|
for pid, nid in zip(notified_player_ids, notified_participant_ids)
|
||||||
|
if pid in new_player_ids
|
||||||
|
]
|
||||||
|
notify_participants(
|
||||||
|
title=match.title,
|
||||||
|
date=match.date,
|
||||||
|
start_time=match.start_time,
|
||||||
|
end_time=match.end_time,
|
||||||
|
participants=new_pairs,
|
||||||
|
fallback_id=match.id,
|
||||||
|
change_kind='scheduled',
|
||||||
|
)
|
||||||
|
# else: nothing changed that needs a notification.
|
||||||
|
|
||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ def zip_participants(player_ids, participant_ids):
|
|||||||
yield player_id, participant_ids[index] if index < len(participant_ids) else None
|
yield player_id, participant_ids[index] if index < len(participant_ids) else None
|
||||||
|
|
||||||
|
|
||||||
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
|
def notify_participants(
|
||||||
|
*, title, date, start_time, end_time, participants, fallback_id, change_kind='scheduled'
|
||||||
|
):
|
||||||
"""Tell each participant that a match has been scheduled or changed.
|
"""Tell each participant that a match has been scheduled or changed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -73,6 +75,8 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
|
|||||||
attendance lands on the right row.
|
attendance lands on the right row.
|
||||||
fallback_id: Reference to use when a participant row has no id —
|
fallback_id: Reference to use when a participant row has no id —
|
||||||
the match's own, which the bot can still act on.
|
the match's own, which the bot can still act on.
|
||||||
|
change_kind: 'scheduled' (new match / new player added) or
|
||||||
|
'modified' (date/time changed for existing players).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: How many notifications were handed to the bot.
|
int: How many notifications were handed to the bot.
|
||||||
@@ -91,6 +95,7 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
|
|||||||
event_date=event_date,
|
event_date=event_date,
|
||||||
event_time=event_time,
|
event_time=event_time,
|
||||||
reference_id=participant_id or fallback_id,
|
reference_id=participant_id or fallback_id,
|
||||||
|
change_kind=change_kind,
|
||||||
)
|
)
|
||||||
sent += 1
|
sent += 1
|
||||||
return sent
|
return sent
|
||||||
|
|||||||
@@ -179,3 +179,61 @@ class TestThroughTheForm:
|
|||||||
evaluation = Evaluation.query.one()
|
evaluation = Evaluation.query.one()
|
||||||
assert evaluation.mecanics_score is None
|
assert evaluation.mecanics_score is None
|
||||||
assert evaluation.overall_score == 4
|
assert evaluation.overall_score == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchEvaluate:
|
||||||
|
"""The batch page drives the same two helpers the single-player form does,
|
||||||
|
through ``{field}_{player_id}`` names. The helpers were lost once, in a
|
||||||
|
refactor, and the page 500'd on every POST until someone noticed."""
|
||||||
|
|
||||||
|
def _submit(self, client, setup, **fields):
|
||||||
|
data = {'player_ids': [str(setup['player_id'])]}
|
||||||
|
data.update(fields)
|
||||||
|
return client.post(
|
||||||
|
f'/evaluations/{setup["tryout_id"]}/batch',
|
||||||
|
data=data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_valid_batch_evaluation_is_stored(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client,
|
||||||
|
evaluation_setup,
|
||||||
|
**{
|
||||||
|
f'mecanics_score_{pid}': '8',
|
||||||
|
f'mental_score_{pid}': '6',
|
||||||
|
f'comments_{pid}': 'Solid.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
evaluation = Evaluation.query.one()
|
||||||
|
assert evaluation.mecanics_score == 8
|
||||||
|
assert evaluation.overall_score == 7
|
||||||
|
assert evaluation.comments == 'Solid.'
|
||||||
|
assert evaluation.position_recommendation is None
|
||||||
|
|
||||||
|
def test_an_out_of_range_batch_score_stores_nothing(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client,
|
||||||
|
evaluation_setup,
|
||||||
|
**{f'mecanics_score_{pid}': '11', f'mental_score_{pid}': '6'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
assert Evaluation.query.count() == 0
|
||||||
|
|
||||||
|
def test_a_second_batch_submission_replaces_the_first(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client, evaluation_setup, **{f'mecanics_score_{pid}': '8', f'mental_score_{pid}': '4'}
|
||||||
|
)
|
||||||
|
self._submit(
|
||||||
|
client, evaluation_setup, **{f'mecanics_score_{pid}': '4', f'mental_score_{pid}': '4'}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
evaluation = Evaluation.query.one()
|
||||||
|
assert evaluation.overall_score == 4
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ class TestNotifying:
|
|||||||
assert [call['reference_id'] for call in sent] == [11, 22]
|
assert [call['reference_id'] for call in sent] == [11, 22]
|
||||||
assert {call['event_date'] for call in sent} == {'2030-04-01'}
|
assert {call['event_date'] for call in sent} == {'2030-04-01'}
|
||||||
assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'}
|
assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'}
|
||||||
|
assert {call['change_kind'] for call in sent} == {'scheduled'}
|
||||||
|
|
||||||
|
def test_modified_kind_is_forwarded(self, sent):
|
||||||
|
"""A modified notification carries 'modified' to the bot."""
|
||||||
|
scheduling.notify_participants(
|
||||||
|
title='Scrim',
|
||||||
|
date=date(2030, 4, 1),
|
||||||
|
start_time=time(19, 0),
|
||||||
|
end_time=time(21, 0),
|
||||||
|
participants=[(1, 11)],
|
||||||
|
fallback_id=99,
|
||||||
|
change_kind='modified',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sent[0]['change_kind'] == 'modified'
|
||||||
|
|
||||||
def test_a_participant_without_a_row_falls_back_to_the_match(self, sent):
|
def test_a_participant_without_a_row_falls_back_to_the_match(self, sent):
|
||||||
"""A Discord reaction resolves the reference back to a record; the
|
"""A Discord reaction resolves the reference back to a record; the
|
||||||
|
|||||||
Reference in New Issue
Block a user