From e2e2b1b07ee27479d0901dc0793673b83f5f68ed Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Sun, 6 Sep 2026 13:49:07 -0400 Subject: [PATCH] premier fix pour le discord bot qui envoie des reminders sur des versions de matchs qui n'existent plus --- _add_drive_test.py | 40 --------------- _add_tests.py | 48 ------------------ app/discord_bot.py | 53 ++++++++++++++++++-- app/models/match_model/base.py | 2 +- app/models/one_on_one_request.py | 2 +- app/models/tryout/tryout.py | 2 +- app/routes/matches.py | 28 +++++++++-- clear_db.py | 66 ------------------------- migrations/add_reminder_date_indexes.py | 36 ++++++++++++++ 9 files changed, 114 insertions(+), 163 deletions(-) delete mode 100644 _add_drive_test.py delete mode 100644 _add_tests.py delete mode 100644 clear_db.py create mode 100644 migrations/add_reminder_date_indexes.py diff --git a/_add_drive_test.py b/_add_drive_test.py deleted file mode 100644 index cf6e291..0000000 --- a/_add_drive_test.py +++ /dev/null @@ -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:// 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') \ No newline at end of file diff --git a/_add_tests.py b/_add_tests.py deleted file mode 100644 index a8a6715..0000000 --- a/_add_tests.py +++ /dev/null @@ -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') \ No newline at end of file diff --git a/app/discord_bot.py b/app/discord_bot.py index 8ba7562..3b196a7 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -492,6 +492,19 @@ class TeamTryoutsBot(commands.Bot): await self._send_schedule_notification(**item['data']) elif kind == 'one_on_one_response': 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: logger.error('Unknown queue item type %r; the notification was dropped', kind) @@ -992,6 +1005,12 @@ class TeamTryoutsBot(commands.Bot): player.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: db.session.rollback() logger.exception( @@ -1040,6 +1059,11 @@ class TeamTryoutsBot(commands.Bot): db.session.delete(row) else: 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: db.session.rollback() logger.exception( @@ -1222,10 +1246,11 @@ class TeamTryoutsBot(commands.Bot): async def send_match_reminder(self, player, match): """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 = ( "🔔 **Match Reminder**\n\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"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\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): """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 = ( "🔔 **Tryout Reminder**\n\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" "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): """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 = ( "🔔 **One on One Reminder**\n\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"**Discussion Points:** {session.points or 'No specific points provided'}\n\n" "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( player_discord_id: str, player_full_name: str, diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py index 4716064..786f5b4 100644 --- a/app/models/match_model/base.py +++ b/app/models/match_model/base.py @@ -11,7 +11,7 @@ class BaseMatch(db.Model): title = db.Column(db.String(200), nullable=False) 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) end_time = db.Column(db.Time, nullable=True) location = db.Column(db.String(200), nullable=True) diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py index 13f8c00..3d37d34 100644 --- a/app/models/one_on_one_request.py +++ b/app/models/one_on_one_request.py @@ -12,7 +12,7 @@ class OneOnOneRequest(db.Model): player_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) - date = db.Column(db.Date, nullable=False) + date = db.Column(db.Date, nullable=False, index=True) start_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False) points = db.Column(db.Text, nullable=True) diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index 1f680d2..1d4d2b9 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -13,7 +13,7 @@ class Tryout(db.Model): title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) 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) location = db.Column(db.String(200), nullable=True) status = db.Column(db.String(20), default='upcoming') diff --git a/app/routes/matches.py b/app/routes/matches.py index 7a91ab3..c42b135 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -583,6 +583,12 @@ def edit_match(match_id): notified_player_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': teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id if teams_changed: @@ -593,17 +599,33 @@ def edit_match(match_id): else: # Same teams: the roster stands, but everyone is told again, # 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): if team_id: - notified_player_ids.extend( - m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all() - ) + for member 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: MatchParticipant.query.filter_by(match_id=match.id).delete() notified_player_ids, notified_participant_ids = create_participants(match, data) 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( title=match.title, date=match.date, diff --git a/clear_db.py b/clear_db.py deleted file mode 100644 index 4d074b0..0000000 --- a/clear_db.py +++ /dev/null @@ -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='admin@teampro.com', - ) - 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() diff --git a/migrations/add_reminder_date_indexes.py b/migrations/add_reminder_date_indexes.py new file mode 100644 index 0000000..22f8bdf --- /dev/null +++ b/migrations/add_reminder_date_indexes.py @@ -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.') \ No newline at end of file