premier fix pour le discord bot qui envoie des reminders sur des versions de matchs qui n'existent plus
CI - Security, Lint & Tests / validate (push) Failing after 1m5s
CI - Security, Lint & Tests / validate (push) Failing after 1m5s
This commit is contained in:
@@ -1,40 +0,0 @@
|
|||||||
"""Add Drive signed upload test."""
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
p = pathlib.Path('tests/test_contract_uploads.py')
|
|
||||||
t = p.read_bytes()
|
|
||||||
|
|
||||||
drive_test = (
|
|
||||||
b"\n def test_signed_upload_with_google_drive(\n"
|
|
||||||
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
|
|
||||||
b" ):\n"
|
|
||||||
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
|
|
||||||
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
|
|
||||||
b" monkeypatch.setattr(\n"
|
|
||||||
b" 'app.storage.upload_google_drive_file',\n"
|
|
||||||
b" lambda **_kwargs: 'drive-signed-456',\n"
|
|
||||||
b" )\n"
|
|
||||||
b" player_id = as_role('player')\n"
|
|
||||||
b" admin_id = make_user('admin')\n"
|
|
||||||
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
|
|
||||||
b" client.post(\n"
|
|
||||||
b" f'/users/contracts/{contract_id}/upload_signed',\n"
|
|
||||||
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
|
|
||||||
b" content_type='multipart/form-data',\n"
|
|
||||||
b" follow_redirects=True,\n"
|
|
||||||
b" )\n\n"
|
|
||||||
b" with app.app_context():\n"
|
|
||||||
b" contract = db.session.get(Contract, contract_id)\n"
|
|
||||||
b" assert contract.status == 'signed'\n"
|
|
||||||
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add before 'class TestTheHelper:'
|
|
||||||
marker = b'\nclass TestTheHelper:'
|
|
||||||
pos = t.find(marker)
|
|
||||||
if pos >= 0:
|
|
||||||
t = t[:pos] + drive_test + t[pos:]
|
|
||||||
p.write_bytes(t)
|
|
||||||
print('Added Drive signed upload test')
|
|
||||||
else:
|
|
||||||
print('Could not find marker')
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Add new tests to test_contract_uploads.py."""
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
p = pathlib.Path('tests/test_contract_uploads.py')
|
|
||||||
t = p.read_bytes()
|
|
||||||
|
|
||||||
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
|
|
||||||
marker = b'\n\nclass TestTheHelper:'
|
|
||||||
test = (
|
|
||||||
b"\n def test_a_header_only_pdf_is_refused(\n"
|
|
||||||
b" self, app, client, as_role, make_user, contract_for\n"
|
|
||||||
b" ):\n"
|
|
||||||
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
|
|
||||||
b" player_id = as_role('player')\n"
|
|
||||||
b" admin_id = make_user('admin')\n"
|
|
||||||
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
|
|
||||||
b" client.post(\n"
|
|
||||||
b" f'/users/contracts/{contract_id}/upload_signed',\n"
|
|
||||||
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
|
|
||||||
b" content_type='multipart/form-data',\n"
|
|
||||||
b" follow_redirects=True,\n"
|
|
||||||
b" )\n\n"
|
|
||||||
b" with app.app_context():\n"
|
|
||||||
b" contract = db.session.get(Contract, contract_id)\n"
|
|
||||||
b" assert contract.status != 'signed'\n"
|
|
||||||
b" assert contract.signed_file_path is None\n\n"
|
|
||||||
)
|
|
||||||
pos = t.find(marker)
|
|
||||||
if pos >= 0:
|
|
||||||
t = t[:pos] + test + t[pos:]
|
|
||||||
|
|
||||||
# 2. Header-only helper test at end of TestTheHelper
|
|
||||||
trailing = t.rstrip(b'\n')
|
|
||||||
idx = t.find(trailing) + len(trailing)
|
|
||||||
helper = (
|
|
||||||
b"\n def test_a_header_only_file_is_refused(self, app):\n"
|
|
||||||
b' """A stream starting with %PDF- but holding non-PDF data is\n'
|
|
||||||
b" rejected (the header check is not enough).\"\"\"\n"
|
|
||||||
b" from werkzeug.datastructures import FileStorage\n\n"
|
|
||||||
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
|
|
||||||
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
|
|
||||||
b" with app.test_request_context('/'):\n"
|
|
||||||
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
|
|
||||||
)
|
|
||||||
t = t[:idx] + helper + t[idx:]
|
|
||||||
|
|
||||||
p.write_bytes(t)
|
|
||||||
print('Added new tests')
|
|
||||||
+50
-3
@@ -492,6 +492,19 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
await self._send_schedule_notification(**item['data'])
|
await self._send_schedule_notification(**item['data'])
|
||||||
elif kind == 'one_on_one_response':
|
elif kind == 'one_on_one_response':
|
||||||
await self._send_one_on_one_response_dm(**item['data'])
|
await self._send_one_on_one_response_dm(**item['data'])
|
||||||
|
elif kind == 'purge_pending':
|
||||||
|
participant_ids = item.get('data', {}).get('participant_ids', [])
|
||||||
|
if participant_ids:
|
||||||
|
changed = False
|
||||||
|
for msg_id, record in list(self.pending_requests.items()):
|
||||||
|
if (
|
||||||
|
record.get('type') == 'schedule_addition'
|
||||||
|
and record.get('id') in participant_ids
|
||||||
|
):
|
||||||
|
del self.pending_requests[msg_id]
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._save_pending()
|
||||||
else:
|
else:
|
||||||
logger.error('Unknown queue item type %r; the notification was dropped', kind)
|
logger.error('Unknown queue item type %r; the notification was dropped', kind)
|
||||||
|
|
||||||
@@ -992,6 +1005,12 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
player.id,
|
player.id,
|
||||||
reference_id,
|
reference_id,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# The row no longer exists (e.g. match was edited, participant
|
||||||
|
# was deleted). Silently drop the stale pending entry so the
|
||||||
|
# reaction is not answerable again. No user-facing reply.
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -1040,6 +1059,11 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
db.session.delete(row)
|
db.session.delete(row)
|
||||||
else:
|
else:
|
||||||
row.status = 'no_show'
|
row.status = 'no_show'
|
||||||
|
else:
|
||||||
|
# The row no longer exists. Silently drop the stale pending
|
||||||
|
# entry so the reaction is not answerable again.
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -1222,10 +1246,11 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
async def send_match_reminder(self, player, match):
|
async def send_match_reminder(self, player, match):
|
||||||
"""Send match reminder to player. True if it was delivered."""
|
"""Send match reminder to player. True if it was delivered."""
|
||||||
|
date_str = match.date.strftime('%A, %B %d, %Y') if match.date else 'TBD'
|
||||||
message = (
|
message = (
|
||||||
"🔔 **Match Reminder**\n\n"
|
"🔔 **Match Reminder**\n\n"
|
||||||
f"Your match **{match.title}** is scheduled for tomorrow:\n"
|
f"Your match **{match.title}** is scheduled for tomorrow:\n"
|
||||||
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
|
f"**Date:** {date_str}\n"
|
||||||
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
|
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
|
||||||
f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
|
f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
|
||||||
f"**Location:** {match.location or 'TBD'}\n\n"
|
f"**Location:** {match.location or 'TBD'}\n\n"
|
||||||
@@ -1241,10 +1266,11 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
async def send_tryout_reminder(self, player, tryout):
|
async def send_tryout_reminder(self, player, tryout):
|
||||||
"""Send tryout reminder to player. True if it was delivered."""
|
"""Send tryout reminder to player. True if it was delivered."""
|
||||||
|
date_str = tryout.date.strftime('%A, %B %d, %Y') if tryout.date else 'TBD'
|
||||||
message = (
|
message = (
|
||||||
"🔔 **Tryout Reminder**\n\n"
|
"🔔 **Tryout Reminder**\n\n"
|
||||||
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
|
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
|
||||||
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
|
f"**Date:** {date_str}\n"
|
||||||
f"**Location:** {tryout.location or 'TBD'}\n\n"
|
f"**Location:** {tryout.location or 'TBD'}\n\n"
|
||||||
"Please confirm your attendance in the app."
|
"Please confirm your attendance in the app."
|
||||||
)
|
)
|
||||||
@@ -1314,10 +1340,11 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
async def send_one_on_one_reminder(self, player, session):
|
async def send_one_on_one_reminder(self, player, session):
|
||||||
"""Send One on One reminder to player. True if it was delivered."""
|
"""Send One on One reminder to player. True if it was delivered."""
|
||||||
|
date_str = session.date.strftime('%A, %B %d, %Y') if session.date else 'TBD'
|
||||||
message = (
|
message = (
|
||||||
"🔔 **One on One Reminder**\n\n"
|
"🔔 **One on One Reminder**\n\n"
|
||||||
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
|
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
|
||||||
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
|
f"**Date:** {date_str}\n"
|
||||||
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
|
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
|
||||||
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
|
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
|
||||||
"Please prepare for your session!"
|
"Please prepare for your session!"
|
||||||
@@ -1433,6 +1460,26 @@ def send_schedule_notification(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_pending_for_participants(participant_ids):
|
||||||
|
"""Queue a purge of pending Discord entries for stale participant rows.
|
||||||
|
|
||||||
|
Called after a match edit deletes participants. The bot thread removes
|
||||||
|
matching ``schedule_addition`` pending entries so that a reaction on an
|
||||||
|
old message cannot produce a false confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant_ids: list[int] of MatchParticipant primary keys that no
|
||||||
|
longer exist.
|
||||||
|
"""
|
||||||
|
if not participant_ids:
|
||||||
|
return None
|
||||||
|
return _enqueue(
|
||||||
|
'purge_pending',
|
||||||
|
{'participant_ids': participant_ids},
|
||||||
|
description=f'purge of {len(participant_ids)} stale participant records',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_one_on_one_response(
|
def send_one_on_one_response(
|
||||||
player_discord_id: str,
|
player_discord_id: str,
|
||||||
player_full_name: str,
|
player_full_name: str,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class BaseMatch(db.Model):
|
|||||||
|
|
||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
date = db.Column(db.Date, nullable=False)
|
date = db.Column(db.Date, nullable=False, index=True)
|
||||||
start_time = db.Column(db.Time, nullable=True)
|
start_time = db.Column(db.Time, nullable=True)
|
||||||
end_time = db.Column(db.Time, nullable=True)
|
end_time = db.Column(db.Time, nullable=True)
|
||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class OneOnOneRequest(db.Model):
|
|||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
date = db.Column(db.Date, nullable=False)
|
date = db.Column(db.Date, nullable=False, index=True)
|
||||||
start_time = db.Column(db.Time, nullable=False)
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
end_time = db.Column(db.Time, nullable=False)
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
points = db.Column(db.Text, nullable=True)
|
points = db.Column(db.Text, nullable=True)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Tryout(db.Model):
|
|||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
game = db.Column(db.String(50), nullable=False)
|
game = db.Column(db.String(50), nullable=False)
|
||||||
date = db.Column(db.Date, nullable=False)
|
date = db.Column(db.Date, nullable=False, index=True)
|
||||||
end_date = db.Column(db.Date, nullable=True)
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
status = db.Column(db.String(20), default='upcoming')
|
status = db.Column(db.String(20), default='upcoming')
|
||||||
|
|||||||
+25
-3
@@ -583,6 +583,12 @@ def edit_match(match_id):
|
|||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
|
|
||||||
|
# Capture old participant ids before any deletion, so we can purge
|
||||||
|
# their pending Discord notifications once the change is committed.
|
||||||
|
stale_participant_ids = [
|
||||||
|
p.id for p in MatchParticipant.query.filter_by(match_id=match.id).all()
|
||||||
|
]
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
||||||
if teams_changed:
|
if teams_changed:
|
||||||
@@ -593,17 +599,33 @@ def edit_match(match_id):
|
|||||||
else:
|
else:
|
||||||
# Same teams: the roster stands, but everyone is told again,
|
# Same teams: the roster stands, but everyone is told again,
|
||||||
# because the date or the time may have moved.
|
# because the date or the time may have moved.
|
||||||
|
# Reuse the existing participant ids so reference_id is always
|
||||||
|
# a real participant row, never the match id (fallback).
|
||||||
for team_id in (match.team1_id, match.team2_id):
|
for team_id in (match.team1_id, match.team2_id):
|
||||||
if team_id:
|
if team_id:
|
||||||
notified_player_ids.extend(
|
for member in TeamMember.query.filter_by(team_id=team_id).all():
|
||||||
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
notified_player_ids.append(member.player_id)
|
||||||
)
|
existing = MatchParticipant.query.filter_by(
|
||||||
|
match_id=match.id, player_id=member.player_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
notified_participant_ids.append(existing.id)
|
||||||
|
stale_participant_ids = [
|
||||||
|
pid for pid in stale_participant_ids if pid != existing.id
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
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.
|
||||||
|
# This runs after the commit so the bot only sees the durable state.
|
||||||
|
if stale_participant_ids:
|
||||||
|
from app.discord_bot import purge_pending_for_participants
|
||||||
|
|
||||||
|
purge_pending_for_participants(stale_participant_ids)
|
||||||
|
|
||||||
notify_participants(
|
notify_participants(
|
||||||
title=match.title,
|
title=match.title,
|
||||||
date=match.date,
|
date=match.date,
|
||||||
|
|||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
"""Database seeding script for Team Tryouts application.
|
|
||||||
|
|
||||||
Wipes all data and creates an admin user. Run manually with:
|
|
||||||
python -m app.supporting_scripts.seed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.extensions import db, hash_password
|
|
||||||
from app.models import Admin
|
|
||||||
|
|
||||||
|
|
||||||
def seed_database():
|
|
||||||
"""Delete all existing data and create a single admin account."""
|
|
||||||
|
|
||||||
# Delete all data in FK-safe order
|
|
||||||
print("Deleting existing data...")
|
|
||||||
tables = [
|
|
||||||
'one_on_one_requests',
|
|
||||||
'player_disponibilities',
|
|
||||||
'coach_availabilities',
|
|
||||||
'match_participants',
|
|
||||||
'team_match_participants',
|
|
||||||
'matches',
|
|
||||||
'team_matches',
|
|
||||||
'team_players',
|
|
||||||
'team_members',
|
|
||||||
'teams',
|
|
||||||
'evaluations',
|
|
||||||
'tryout_registrations',
|
|
||||||
'tryouts',
|
|
||||||
'org_team_coaches',
|
|
||||||
'org_team_managers',
|
|
||||||
'team_notes',
|
|
||||||
'personal_notes',
|
|
||||||
'user_gamertags',
|
|
||||||
'org_teams',
|
|
||||||
'users',
|
|
||||||
]
|
|
||||||
for table in tables:
|
|
||||||
db.session.execute(db.text(f'DELETE FROM {table}'))
|
|
||||||
db.session.commit()
|
|
||||||
print("[OK] All data deleted.")
|
|
||||||
|
|
||||||
# Create the admin user
|
|
||||||
admin = Admin(
|
|
||||||
username='admin',
|
|
||||||
password_hash=hash_password('password'),
|
|
||||||
role='admin',
|
|
||||||
full_name='Admin',
|
|
||||||
email='[email protected]',
|
|
||||||
)
|
|
||||||
db.session.add(admin)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
print("[OK] Created admin user")
|
|
||||||
print("\n=== Admin Credentials ===")
|
|
||||||
print("Username: admin")
|
|
||||||
print("Password: password")
|
|
||||||
print("\n[SUCCESS] Database reset and seeded!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from app.app import create_app
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
with app.app_context():
|
|
||||||
seed_database()
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Migration: Create indexes on date columns used by the 18:00 daily reminder.
|
||||||
|
|
||||||
|
Run this script to add indexes on the date columns that the Discord bot's
|
||||||
|
daily reminder queries (Match.date, TeamMatch.date, Tryout.date,
|
||||||
|
OneOnOneRequest.date). The app uses create_all() for new tables, but existing
|
||||||
|
production databases need these added explicitly.
|
||||||
|
|
||||||
|
Usage: python migrations/add_reminder_date_indexes.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
indexes = [
|
||||||
|
('ix_matches_date', 'matches', 'date'),
|
||||||
|
('ix_team_matches_date', 'team_matches', 'date'),
|
||||||
|
('ix_tryouts_date', 'tryouts', 'date'),
|
||||||
|
('ix_one_on_one_requests_date', 'one_on_one_requests', 'date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for index_name, table, column in indexes:
|
||||||
|
db.session.execute(text(
|
||||||
|
f'CREATE INDEX IF NOT EXISTS {index_name} ON {table} ({column})'
|
||||||
|
))
|
||||||
|
print(f'Index {index_name} on {table}({column}) ensured.')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print('Migration complete.')
|
||||||
Reference in New Issue
Block a user