diff --git a/app/app.py b/app/app.py index cb05868..8a52587 100644 --- a/app/app.py +++ b/app/app.py @@ -550,7 +550,7 @@ def create_app(config=None): # not down, and a 503 here would take the site out of the load # balancer for it (OPS-012). if app.config['ENABLE_DISCORD_BOT']: - from app.discord_bot import bot_status + from app.discord import bot_status health_data['discord_bot'] = bot_status() @@ -750,7 +750,7 @@ def create_app(config=None): # Start the Discord bot for notifications if app.config['ENABLE_DISCORD_BOT']: try: - from app.discord_bot import start_bot + from app.discord import start_bot start_bot(flask_app=app) except Exception: # the site must come up even if the bot cannot diff --git a/app/discord/__init__.py b/app/discord/__init__.py new file mode 100644 index 0000000..22aa039 --- /dev/null +++ b/app/discord/__init__.py @@ -0,0 +1,29 @@ +"""Discord notification subsystem. + +Split from a single 1654-line module (app/discord_bot.py) into a package with +one concern per module (ARCH-003). + +Public API — these are the functions that Flask routes and services import: + start_bot, bot_status, get_bot → .bot + send_one_on_one_dm, send_schedule_notification, + send_one_on_one_response → .messaging + purge_pending_for_participants → .pending +""" + +from app.discord.bot import bot_status, get_bot, start_bot +from app.discord.messaging import ( + send_one_on_one_dm, + send_one_on_one_response, + send_schedule_notification, +) +from app.discord.pending import purge_pending_for_participants + +__all__ = [ + 'bot_status', + 'get_bot', + 'purge_pending_for_participants', + 'send_one_on_one_dm', + 'send_one_on_one_response', + 'send_schedule_notification', + 'start_bot', +] diff --git a/app/discord/bot.py b/app/discord/bot.py new file mode 100644 index 0000000..4ab8674 --- /dev/null +++ b/app/discord/bot.py @@ -0,0 +1,702 @@ +"""Discord bot lifecycle, class definition, and supervisor.""" + +import logging +import threading +import time +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy.exc import SQLAlchemyError + +from app.discord.constants import ( + BOT_RESTART_DELAY_SECONDS, + BOT_RESTART_MAX_DELAY_SECONDS, + BOT_RESTART_RESET_SECONDS, + CHECK_EMOJI, + CROSS_EMOJI, + DISCORD_BOT_TOKEN, + TIMEZONE, + USER_CACHE_MAX, +) +from app.discord.pending import ( + forget_pending, + load_pending, + save_pending, +) +from app.discord.queue import message_queue +from app.time_utils import utc_now_naive +from discord import ( + Forbidden, + HTTPException, + Intents, + LoginFailure, + NotFound, + PrivilegedIntentsRequired, +) +from discord.ext import commands + +_logger = logging.getLogger(__name__) +bot_instance = None +bot_thread = None +bot_flask_app = None +_stop_bot = threading.Event() + + +class TeamTryoutsBot(commands.Bot): + """Unified Discord bot for Team Tryouts notifications.""" + + def __init__(self, flask_app=None): + intents = Intents.default() + intents.message_content = True + intents.dm_messages = True + intents.dm_reactions = True + intents.reactions = True + intents.guilds = True + super().__init__(command_prefix='!', intents=intents) + self.flask_app = flask_app + self.pending_requests = load_pending() + self.message_queue = message_queue + self.scheduler = AsyncIOScheduler() + self.timezone = ZoneInfo(TIMEZONE) + self._user_cache = {} + self.reminders_scheduled = False + + # ------------------------------------------------------------------ + # Lifecycle hooks + # ------------------------------------------------------------------ + + async def setup_hook(self): + self.loop.create_task(self.process_queue()) + + async def on_error(self, event_method, *args, **kwargs): + _logger.error('Unhandled error in %s', event_method, exc_info=True) + + async def on_ready(self): + _logger.info('Bot logged in as %s', self.user) + await self.start_scheduler() + + async def start_scheduler(self): + if self.reminders_scheduled: + _logger.debug('Reminders already scheduled, skipping') + return + try: + self.scheduler.add_job( + self._send_daily_reminders_impl, + CronTrigger(hour=18, minute=0, timezone=self.timezone), + id='daily_match_check', + replace_existing=True, + ) + self.scheduler.start() + self.reminders_scheduled = True + _logger.info('Daily reminder scheduled for 18:00 %s', TIMEZONE) + except Exception: + _logger.exception('Failed to start the daily reminder scheduler') + self.reminders_scheduled = False + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _reply(self, channel, message): + try: + await channel.send(message) + except (HTTPException, Forbidden) as exc: + _logger.warning('Could not reply to channel: %s', exc) + + async def _commit_or_report(self, channel, *, operation): + from app.extensions import db + + try: + operation() + db.session.commit() + except SQLAlchemyError: + db.session.rollback() + _logger.exception('DB commit failed for %s', operation.__name__) + await self._reply(channel, 'Something went wrong saving that. Please try again.') + + def _forget_pending(self, message_id): + self.pending_requests = forget_pending(self.pending_requests, message_id) + + async def _resolve_user(self, discord_uid, *, context=''): + uid = str(discord_uid) + if uid in self._user_cache: + return self._user_cache[uid] + try: + user = await self.fetch_user(int(uid)) + except (NotFound, HTTPException) as exc: + _logger.warning('Could not resolve Discord user %s (%s): %s', uid, context, exc) + return None + if len(self._user_cache) >= USER_CACHE_MAX: + self._user_cache.pop(next(iter(self._user_cache))) + self._user_cache[uid] = user + return user + + async def _send_dm(self, discord_uid, message, *, purpose, recipient=''): + user = await self._resolve_user(discord_uid, context=purpose) + if not user: + _logger.warning( + 'Could not send DM (%s): user not found', purpose, recipient or discord_uid + ) + return None + try: + sent = await user.send(message) + _logger.info( + 'DM sent (%s) to %s (%s)', purpose, recipient or user.display_name, discord_uid + ) + return sent + except Forbidden: + _logger.warning('DMs closed for %s (%s)', recipient or discord_uid, discord_uid) + except HTTPException as exc: + _logger.warning('Failed to DM %s: %s', discord_uid, exc) + except Exception: + _logger.exception('DM to %s failed unexpectedly', discord_uid) + return None + + async def _dispatch(self, item): + kind = item['type'] + data = item['data'] + _logger.debug('Dispatching %s', kind) + try: + if kind == 'one_on_one_request': + await self._send_one_on_one_dm(**data) + elif kind == 'schedule_addition': + await self._send_schedule_notification(**data) + elif kind == 'one_on_one_response': + await self._send_one_on_one_response_dm(**data) + elif kind == 'purge_pending': + pids = data.get('participant_ids', []) + self.pending_requests = { + mid: e + for mid, e in self.pending_requests.items() + if e.get('type') != 'attendance' or e.get('reference_id') not in pids + } + save_pending(self.pending_requests) + else: + _logger.warning('Unknown queue item type: %s', kind) + except Exception: + _logger.exception('Error dispatching %s', kind) + + async def process_queue(self): + while True: + try: + item = self.message_queue.get(timeout=5) + except __import__('queue').Empty: + continue + await self._dispatch(item) + + def is_dm_channel(self, channel) -> bool: + return channel.type.name == 'private' + + # ------------------------------------------------------------------ + # Reaction handling + # ------------------------------------------------------------------ + + async def on_raw_reaction_add(self, payload): + if payload.user_id == self.user.id: + return + message_id = payload.message_id + entry = self.pending_requests.get(str(message_id)) + if not entry: + return + channel = self.get_channel(payload.channel_id) + if not channel: + return + reacting_user = payload.member or await self._resolve_user( + payload.user_id, + context='reaction handler', + ) + if not reacting_user: + return + entry_type = entry.get('type') + reference_id = entry.get('reference_id') + emoji = str(payload.emoji) + if emoji == CHECK_EMOJI: + if entry_type == 'one_on_one': + await self.handle_one_on_one_approve( + reacting_user, message_id, reference_id, channel + ) + elif entry_type in ('match_attendance', 'tryout_attendance'): + await self.handle_attendance_confirm( + reacting_user, message_id, reference_id, channel + ) + elif emoji == CROSS_EMOJI: + if entry_type == 'one_on_one': + await self.handle_one_on_one_reject( + reacting_user, message_id, reference_id, channel + ) + elif entry_type in ('match_attendance', 'tryout_attendance'): + await self.handle_attendance_decline( + reacting_user, message_id, reference_id, channel + ) + + # ------------------------------------------------------------------ + # One-on-One handlers + # ------------------------------------------------------------------ + + async def _send_one_on_one_dm( + self, + coach_name='', + coach_discord_id='', + player_name='', + team_name='', + date_str='', + start_time='', + end_time='', + points='', + request_id=None, + ): + embed = { + 'embeds': [ + { + 'title': 'One on One Request', + 'color': 3447003, + 'fields': [ + {'name': 'Player', 'value': player_name, 'inline': True}, + {'name': 'Team', 'value': team_name or 'Unknown', 'inline': True}, + {'name': 'Date', 'value': date_str, 'inline': True}, + {'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True}, + {'name': 'Discussion Points', 'value': points or 'None', 'inline': False}, + { + 'name': 'Quick Reply', + 'value': f'React with {CHECK_EMOJI} to approve, {CROSS_EMOJI} to reject.', + 'inline': False, + }, + ], + 'footer': {'text': f'Request #{request_id}'}, + } + ] + } + sent = await self._send_dm( + coach_discord_id, embed, purpose='one_on_one_request', recipient=coach_name + ) + if sent and request_id: + self.pending_requests[str(sent.id)] = { + 'type': 'one_on_one', + 'reference_id': request_id, + 'created_at': time.time(), + } + save_pending(self.pending_requests) + + async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): + from app.extensions import db + from app.models import OneOnOneRequest, User + + req = db.session.get(OneOnOneRequest, request_id) + if not req: + await self._reply(channel, 'This request no longer exists.') + self._forget_pending(message_id) + return + if str(coach.id) != str(req.coach.discord_user_id): + await self._reply(channel, 'This request is not for you.') + return + if req.status != 'pending': + await self._reply(channel, 'This request has already been handled.') + self._forget_pending(message_id) + return + + async def _approve(): + req.status = 'approved' + req.responded_at = utc_now_naive() + + await self._commit_or_report(channel, operation=_approve) + await self._reply(channel, f'One on One request from {req.player.full_name} approved!') + self._forget_pending(message_id) + player = db.session.get(User, req.player_id) + if player and player.discord_user_id: + await self._send_one_on_one_response_dm( + player_discord_id=player.discord_user_id, + player_full_name=player.full_name, + coach_full_name=getattr(coach, 'display_name', 'Coach'), + date_str=req.date.strftime('%A, %B %d, %Y'), + start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD', + end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD', + points=req.points or 'No specific points provided', + approved=True, + ) + + async def handle_one_on_one_reject(self, coach, message_id, request_id, channel): + from app.extensions import db + from app.models import OneOnOneRequest, User + + req = db.session.get(OneOnOneRequest, request_id) + if not req: + await self._reply(channel, 'This request no longer exists.') + self._forget_pending(message_id) + return + if str(coach.id) != str(req.coach.discord_user_id): + await self._reply(channel, 'This request is not for you.') + return + if req.status != 'pending': + await self._reply(channel, 'This request has already been handled.') + self._forget_pending(message_id) + return + refusal_note = None + try: + msg = await channel.fetch_message(message_id) + if msg and msg.reference and msg.reference.resolved: + reply_to = msg.reference.resolved + if reply_to.author.id == coach.id: + refusal_note = reply_to.content if isinstance(reply_to.content, str) else None + except (NotFound, HTTPException) as exc: + _logger.warning('Could not read rejection reply: %s', exc) + + async def _reject(): + req.status = 'rejected' + req.responded_at = utc_now_naive() + if refusal_note: + req.coach_rejection_message = refusal_note + + await self._commit_or_report(channel, operation=_reject) + await self._reply(channel, f'One on One request from {req.player.full_name} rejected.') + self._forget_pending(message_id) + player = db.session.get(User, req.player_id) + if player and player.discord_user_id: + await self._send_one_on_one_response_dm( + player_discord_id=player.discord_user_id, + player_full_name=player.full_name, + coach_full_name=getattr(coach, 'display_name', 'Coach'), + date_str=req.date.strftime('%A, %B %d, %Y'), + start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD', + end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD', + points=req.points or 'No specific points provided', + approved=False, + refusal_note=refusal_note, + ) + + # ------------------------------------------------------------------ + # Attendance handlers + # ------------------------------------------------------------------ + + @staticmethod + def _attendance_record(event_type, reference_id): + from app.models import MatchParticipant, TeamMatchParticipant + + if event_type == 'match_attendance': + return MatchParticipant.query.get(reference_id) + if event_type == 'team_match_attendance': + return TeamMatchParticipant.query.get(reference_id) + return None + + @staticmethod + def _reacting_user_owns(player_id, reacting_user): + from app.models import User + + player = User.query.get(player_id) + if not player: + return False + return str(player.discord_user_id) == str(reacting_user.id) + + async def handle_attendance_confirm(self, player, message_id, reference_id, channel): + entry = self.pending_requests.get(str(message_id)) + if not entry: + return + event_type = entry.get('type', '') + record = self._attendance_record(event_type, reference_id) + if not record: + await self._reply(channel, 'This event no longer exists.') + self._forget_pending(message_id) + return + if not self._reacting_user_owns(record.player_id, player): + await self._reply(channel, 'You cannot confirm attendance for someone else.') + return + + async def _confirm(): + record.attendance_confirmed = True + record.is_confirmed = True + + await self._commit_or_report(channel, operation=_confirm) + await self._reply(channel, 'Attendance confirmed! ✅') + self._forget_pending(message_id) + + async def handle_attendance_decline(self, player, message_id, reference_id, channel): + entry = self.pending_requests.get(str(message_id)) + if not entry: + return + event_type = entry.get('type', '') + record = self._attendance_record(event_type, reference_id) + if not record: + await self._reply(channel, 'This event no longer exists.') + self._forget_pending(message_id) + return + if not self._reacting_user_owns(record.player_id, player): + await self._reply(channel, 'You cannot decline attendance for someone else.') + return + + async def _decline(): + record.attendance_confirmed = False + record.is_confirmed = False + + await self._commit_or_report(channel, operation=_decline) + await self._reply(channel, 'Attendance declined. ❌') + self._forget_pending(message_id) + + # ------------------------------------------------------------------ + # Schedule notification + # ------------------------------------------------------------------ + + async def _send_schedule_notification( + self, + user_id=None, + event_type='match', + event_title='', + event_date='', + event_time='', + reference_id=None, + change_kind='scheduled', + ): + from app.models import User + + user = User.query.get(user_id) + if not user or not user.discord_user_id: + _logger.warning('Cannot notify user %s: no Discord user id', user_id) + return + title = '🎯 Match Updated' if change_kind == 'modified' else '🎯 New Match Scheduled' + desc = f'**{event_title}**\n📅 {event_date}\n⏰ {event_time}' + embed = { + 'embeds': [ + { + 'title': title, + 'color': 7506394 if change_kind == 'modified' else 3447003, + 'description': desc, + 'fields': [ + { + 'name': 'Quick RSVP', + 'value': f'React with {CHECK_EMOJI} to confirm, {CROSS_EMOJI} to decline.', + 'inline': False, + } + ], + } + ] + } + sent = await self._send_dm( + user.discord_user_id, + embed, + purpose=f'{change_kind}_{event_type}', + recipient=user.full_name, + ) + if sent and reference_id: + entry_type = 'match_attendance' if event_type == 'match' else 'tryout_attendance' + self.pending_requests[str(sent.id)] = { + 'type': entry_type, + 'reference_id': reference_id, + 'created_at': time.time(), + } + save_pending(self.pending_requests) + + # ------------------------------------------------------------------ + # One-on-One response DM + # ------------------------------------------------------------------ + + async def _send_one_on_one_response_dm( + self, + player_discord_id='', + player_full_name='', + coach_full_name='', + date_str='', + start_time='', + end_time='', + points='', + approved=True, + refusal_note=None, + ): + status = '✅ Approved' if approved else '❌ Rejected' + color = 3066993 if approved else 15158332 + desc = f'Your One on One request has been **{"approved" if approved else "rejected"}**.' + embed = { + 'embeds': [ + { + 'title': f'One on One {status}', + 'color': color, + 'description': desc, + 'fields': [ + {'name': 'Coach', 'value': coach_full_name, 'inline': True}, + {'name': 'Date', 'value': date_str, 'inline': True}, + {'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True}, + {'name': 'Discussion Points', 'value': points or 'None', 'inline': False}, + ], + } + ] + } + if refusal_note: + embed['embeds'][0]['fields'].append( + {'name': 'Reason', 'value': refusal_note, 'inline': False} + ) + await self._send_dm( + player_discord_id, embed, purpose='one_on_one_response', recipient=player_full_name + ) + + # ------------------------------------------------------------------ + # Daily reminders + # ------------------------------------------------------------------ + + async def send_daily_reminders(self): + """Public entry point, called from tests.""" + await self._send_daily_reminders_impl() + + async def _send_daily_reminders_impl(self): + from app.extensions import db + from app.models import Match, OneOnOneRequest, TeamMatch, Tryout, User + + tomorrow = (datetime.now(self.timezone) + timedelta(days=1)).date() + _logger.info('Running daily reminder check for %s', tomorrow) + for match in Match.query.filter(Match.date == tomorrow).all(): + for participant in match.participants: + await self.send_match_reminder(participant.player, match) + for tm in TeamMatch.query.filter(TeamMatch.date == tomorrow).all(): + for participant in tm.participants: + await self.send_match_reminder(participant.player, tm) + for tryout in Tryout.query.all(): + if any(m.date == tomorrow for m in tryout.matches): + for reg in tryout.registrations: + await self.send_tryout_reminder(reg.player, tryout) + sessions = OneOnOneRequest.query.filter( + OneOnOneRequest.status == 'pending', OneOnOneRequest.date == tomorrow + ).all() + for session in sessions: + player = db.session.get(User, session.player_id) + await self.send_one_on_one_reminder(player, session) + + async def send_match_reminder(self, player, match): + if not player or not player.discord_user_id: + return + embed = { + 'embeds': [ + { + 'title': '⏰ Match Tomorrow!', + 'color': 15844367, + 'description': f'**{match.title}**\n📅 {match.date.strftime("%A, %B %d, %Y")}\n⏰ {match.start_time.strftime("%I:%M %p") if match.start_time else "TBD"}', + } + ] + } + await self._send_dm( + player.discord_user_id, embed, purpose='match_reminder', recipient=player.full_name + ) + + async def send_tryout_reminder(self, player, tryout): + if not player or not player.discord_user_id: + return + embed = { + 'embeds': [ + { + 'title': '⏰ Tryout Tomorrow!', + 'color': 15844367, + 'description': f'**{tryout.name}**\n📅 {tryout.date.strftime("%A, %B %d, %Y")}\n⏰ {tryout.time.strftime("%I:%M %p") if tryout.time else "TBD"}', + } + ] + } + await self._send_dm( + player.discord_user_id, embed, purpose='tryout_reminder', recipient=player.full_name + ) + + async def send_one_on_one_reminder(self, player, session): + if not player or not player.discord_user_id: + return + embed = { + 'embeds': [ + { + 'title': '⏰ One on One Tomorrow!', + 'color': 15844367, + 'description': f'📅 {session.date.strftime("%A, %B %d, %Y")}\n⏰ {session.start_time.strftime("%I:%M %p") if session.start_time else "TBD"}', + } + ] + } + await self._send_dm( + player.discord_user_id, embed, purpose='one_on_one_reminder', recipient=player.full_name + ) + + # ----------------------------------------------------------------- + # Legacy alias + # ----------------------------------------------------------------- + + async def notify_player_about_one_on_one_direct( + self, player, coach, coach_name, coach_discord_id, date_str, start_time, end_time, points + ): + embed = { + 'embeds': [ + { + 'title': 'One on One Scheduled', + 'color': 3447003, + 'fields': [ + {'name': 'Coach', 'value': coach_name, 'inline': True}, + {'name': 'Date', 'value': date_str, 'inline': True}, + {'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True}, + {'name': 'Discussion Points', 'value': points or 'None', 'inline': False}, + ], + } + ] + } + await self._send_dm( + player.discord_user_id, embed, purpose='one_on_one_direct', recipient=player.full_name + ) + + +# ====================================================================== +# Module-level API +# ====================================================================== + + +def get_bot(flask_app=None): + global bot_instance, bot_flask_app + if flask_app is not None: + bot_flask_app = flask_app + if bot_instance is None: + bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_flask_app) + elif flask_app is not None and bot_instance.flask_app is None: + bot_instance.flask_app = flask_app + return bot_instance + + +def supervise_bot(run_once=False): + delay = BOT_RESTART_DELAY_SECONDS + while True: + bot = get_bot() + try: + bot.run(DISCORD_BOT_TOKEN, reconnect=True) + except LoginFailure: + _logger.critical('Discord bot token is invalid. Bot will not start.') + return + except PrivilegedIntentsRequired as exc: + _logger.critical('Missing privileged intent: %s. Bot will not start.', exc) + return + except Exception as exc: # noqa: BLE001 — supervisor boundary, restart on any failure + _logger.error('Discord bot stopped unexpectedly: %s', exc) + if run_once: + _logger.info('Bot run_once completed.') + return + bot_age = time.time() - getattr(bot, '_started_at', time.time()) + if bot_age >= BOT_RESTART_RESET_SECONDS: + delay = BOT_RESTART_DELAY_SECONDS + _logger.warning('Discord bot disconnected. Restarting in %s seconds.', delay) + if _stop_bot.wait(delay): + _logger.info('Discord bot supervisor asked to stop.') + return + delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS) + + +def start_bot(flask_app=None): + global bot_thread, bot_flask_app + if flask_app is not None: + bot_flask_app = flask_app + get_bot(flask_app=flask_app) + if DISCORD_BOT_TOKEN and bot_thread is None: + _stop_bot.clear() + bot_thread = threading.Thread(target=supervise_bot, daemon=True) + bot_thread.start() + _logger.info('TeamTryoutsBot started in background thread') + elif not DISCORD_BOT_TOKEN: + _logger.warning('DISCORD_BOT_TOKEN not set, bot not started') + + +def bot_status(): + running = bool(bot_thread and bot_thread.is_alive()) + instance = bot_instance + connected = bool(instance and not instance.is_closed() and instance.is_ready()) + return { + 'configured': bool(DISCORD_BOT_TOKEN), + 'running': running, + 'connected': connected, + 'pending': len(instance.pending_requests) if instance else 0, + 'reminders_scheduled': bool(instance and instance.reminders_scheduled), + } diff --git a/app/discord/bot_header.py.tmp b/app/discord/bot_header.py.tmp new file mode 100644 index 0000000..cd9e55d Binary files /dev/null and b/app/discord/bot_header.py.tmp differ diff --git a/app/discord/constants.py b/app/discord/constants.py new file mode 100644 index 0000000..55cd9e9 --- /dev/null +++ b/app/discord/constants.py @@ -0,0 +1,45 @@ +"""Constants used across the Discord notification subsystem.""" + +import os + +# --------------------------------------------------------------------------- +# Token +# --------------------------------------------------------------------------- +#: Discord bot token. Loaded once at import time so the bot can decide whether +#: to start before the application factory runs. +DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') + +# --------------------------------------------------------------------------- +# Emoji +# --------------------------------------------------------------------------- +CHECK_EMOJI = '✅' # Green checkmark +CROSS_EMOJI = '❌' # Red X + +# --------------------------------------------------------------------------- +# Timing +# --------------------------------------------------------------------------- +#: Entries older than this are dropped when the pending file is loaded. +PENDING_MAX_AGE_DAYS = 30 + +#: How late the daily reminder may still be sent. +REMINDER_GRACE_SECONDS = 3600 + +#: First wait before restarting a bot that stopped (OPS-004). +BOT_RESTART_DELAY_SECONDS = 5 + +#: Ceiling on that wait. +BOT_RESTART_MAX_DELAY_SECONDS = 300 + +#: A connection that lasted this long resets the back-off. +BOT_RESTART_RESET_SECONDS = 600 + +# --------------------------------------------------------------------------- +# Caching +# --------------------------------------------------------------------------- +#: How many Discord user objects to keep resolved (PERF-005). +USER_CACHE_MAX = 512 + +# --------------------------------------------------------------------------- +# Timezone +# --------------------------------------------------------------------------- +TIMEZONE = 'America/Toronto' diff --git a/app/discord/messaging.py b/app/discord/messaging.py new file mode 100644 index 0000000..96a4bf3 --- /dev/null +++ b/app/discord/messaging.py @@ -0,0 +1,128 @@ +"""Public API wrappers that enqueue notifications for the bot thread. + +These are the functions that Flask routes and services import. They run in the +request thread and hand the actual Discord work to the bot's async loop via the +shared queue. +""" + +import logging + +from app.discord.queue import enqueue + +_logger = logging.getLogger(__name__) + + +def send_one_on_one_dm( + coach_name: str, + coach_discord_id: str, + player_name: str, + team_name: str, + date_str: str, + start_time: str, + end_time: str, + points: str, + request_id: int, +) -> bool: + """Queue a One on One request DM to be sent by the bot. + + Args: + coach_name: Display name of the coach. + coach_discord_id: Discord snowflake of the coach. + player_name: Display name of the requesting player. + team_name: The player's team, or None/empty. + date_str, start_time, end_time: Already formatted for display. + points: Free-text discussion points. + request_id: OneOnOnRequest primary key for reaction routing. + + Returns: + True if queued, False on failure. + """ + return enqueue( + 'one_on_one_request', + { + 'coach_name': coach_name, + 'coach_discord_id': coach_discord_id, + 'player_name': player_name, + 'team_name': team_name, + 'date_str': date_str, + 'start_time': start_time, + 'end_time': end_time, + 'points': points, + 'request_id': request_id, + }, + description=f'the One on One request DM for request {request_id}', + ) + + +def send_schedule_notification( + user_id: int, + event_type: str, + event_title: str, + event_date: str, + event_time: str, + reference_id: int, + change_kind: str = 'scheduled', +) -> bool: + """Queue a schedule notification DM to be sent by the bot. + + Args: + user_id: The player's database id (resolved to a snowflake in the bot). + event_type: 'match' or 'tryout'. + event_title: Display title of the event. + event_date: Formatted date string. + event_time: Formatted time range, or 'TBD'. + reference_id: The participant id (or fallback match/tryout id) for + reaction routing. + change_kind: 'scheduled' for new events, 'modified' for changes. + + Returns: + True if queued, False on failure. + """ + return enqueue( + 'schedule_addition', + { + 'user_id': user_id, + 'event_type': event_type, + 'event_title': event_title, + 'event_date': event_date, + 'event_time': event_time, + 'reference_id': reference_id, + 'change_kind': change_kind, + }, + description=f'the {change_kind} notification for event {event_title}', + ) + + +def send_one_on_one_response( + player_discord_id: str, + player_full_name: str, + coach_full_name: str, + date_str: str, + start_time: str, + end_time: str, + points: str, + approved: bool, + refusal_note: str = None, +) -> bool: + """Queue a One on One response DM to be sent to the player by the bot. + + Called from Flask routes when a coach accepts/rejects via the web app. + + Returns: + True if queued, False on failure. + """ + return enqueue( + 'one_on_one_response', + { + 'player_discord_id': player_discord_id, + 'player_full_name': player_full_name, + 'coach_full_name': coach_full_name, + 'date_str': date_str, + 'start_time': start_time, + 'end_time': end_time, + 'points': points, + 'approved': approved, + 'refusal_note': refusal_note, + }, + description=f'the One on One response DM for {player_full_name}', + ) diff --git a/app/discord/pending.py b/app/discord/pending.py new file mode 100644 index 0000000..86ecff6 --- /dev/null +++ b/app/discord/pending.py @@ -0,0 +1,102 @@ +"""Persistence of pending reaction-request mappings across bot restarts. + +The bot uses reaction messages (✅ / ❌) on DMs to let a coach approve or reject +a one-on-one request without visiting the site. The mapping from Discord +message_id → {request_type, primary_key} is stored in a JSON file so that a +bot restart does not orphan reactions that were already sent. +""" + +import json +import logging +import os +from datetime import UTC, datetime + +from app.discord.constants import PENDING_MAX_AGE_DAYS +from app.discord.queue import enqueue + +_logger = logging.getLogger(__name__) + +#: File for persisting pending requests across bot restarts. +PENDING_FILE = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'discord_pending.json', +) + + +def load_pending() -> dict: + """Load pending requests from the JSON file. + + Returns: + dict mapping message_id (int) → entry dict. + """ + if not os.path.exists(PENDING_FILE): + return {} + try: + with open(PENDING_FILE, encoding='utf-8') as f: + entries = json.load(f) + except (json.JSONDecodeError, OSError): + _logger.warning('Could not read pending file, starting empty.', exc_info=True) + return {} + if not isinstance(entries, dict): + return {} + cleaned = drop_expired(entries) + if len(cleaned) < len(entries): + save_pending(cleaned) + return cleaned + + +def drop_expired(entries: dict) -> dict: + """Remove entries whose creation timestamp is too old. + + Args: + entries: dict mapping message_id → entry. + + Returns: + Filtered dict. + """ + now = datetime.now(UTC).timestamp() + cutoff = now - PENDING_MAX_AGE_DAYS * 86400 + return {mid: e for mid, e in entries.items() if e.get('created_at', 0) >= cutoff} + + +def save_pending(entries: dict) -> None: + """Persist pending entries to the JSON file.""" + try: + with open(PENDING_FILE, 'w', encoding='utf-8') as f: + json.dump(entries, f, indent=2) + except OSError: + _logger.exception('Could not write pending file') + + +def forget_pending(entries: dict, message_id: int) -> dict: + """Remove one message from the pending map and persist. + + Args: + entries: The current pending map. + message_id: The message whose entry to remove. + + Returns: + Updated dict (already persisted). + """ + entries.pop(str(message_id), None) + save_pending(entries) + return entries + + +def purge_pending_for_participants(participant_ids: list[int]) -> None: + """Queue a purge of stale participant pending entries. + + 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: MatchParticipant primary keys that no longer exist. + """ + if not participant_ids: + return + enqueue( + 'purge_pending', + {'participant_ids': participant_ids}, + description=f'purge of {len(participant_ids)} stale participant records', + ) diff --git a/app/discord/queue.py b/app/discord/queue.py new file mode 100644 index 0000000..32b81bc --- /dev/null +++ b/app/discord/queue.py @@ -0,0 +1,44 @@ +"""Shared queue that bridges Flask request threads to the bot's async loop. + +Module-level, not per-instance: the supervisor replaces the bot object on every +restart (discord.py cannot reuse a client whose run() has returned). A queue +living on the old object would be discarded with it, taking every pending +notification along. +""" + +import logging +from queue import Empty, Queue # noqa: F401 — re-exported for bot.py + +_logger = logging.getLogger(__name__) + +#: The shared queue instance. +message_queue: Queue = Queue() + + +def enqueue(kind: str, data: dict, *, description: str) -> bool: + """Hand one notification to the bot thread. Never raises. + + The three public senders were the same fifteen lines three times, each with + its own blanket catch. There is one now, and it is the only place where + breadth is a property of the caller rather than of the failure: this runs + in a Flask request thread, and the work the notification announces is + already committed. The meeting is booked whether or not Discord hears about + it, so a failure here must not become a 500 on a page whose job is done. + + What it is *not* is somewhere to lose a defect: the traceback is logged + and the caller is told False. + + Args: + kind: Event type name ('one_on_one_request', 'schedule_addition', …). + data: Payload dict. + description: Human-readable label for the log message. + + Returns: + True on success, False on failure. + """ + try: + message_queue.put({'type': kind, 'data': data}) + except Exception: # a notification must never fail the request that caused it # noqa: BLE001 + _logger.exception('Could not queue %s', description) + return False + return True diff --git a/app/discord_bot.py b/app/discord_bot.py index 3d9cd0d..e8934f6 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -1,1654 +1,30 @@ -"""Unified Discord bot for Team Tryouts notifications. +"""Backward-compatibility shim.""" -This module provides a persistent bot that handles: -- One on One request approvals/rejections via reactions -- Match/tryout schedule addition notifications with attendance confirmation -- Daily reminders at 18:00 EDT for upcoming events - -Three families of failure, and what each one does (ARCH-008 / QUA-004) ----------------------------------------------------------------------- - -Every handler in this file used to wrap its whole body in one -`try/except Exception` that logged and carried on. A database refusal, a -closed inbox and a typo in this module all produced the same line, and the -person who clicked the reaction was told nothing either way. The handlers -now separate three cases, because they need three different answers: - -1. **The database refused the write** — `SQLAlchemyError`. The session is - rolled back, the pending entry is kept so the same reaction can be tried - again, and the person is told that nothing was recorded. Silence here was - the worst of the three: a confirmation that failed to commit looked - exactly like a bot that was not running. - -2. **Discord could not be reached** — `HTTPException` and its subclasses - (`Forbidden`, `NotFound`). Expected, per recipient, and already named one - by one in `_send_dm`. After a commit these are best-effort: the decision - is recorded whether or not the message got through, and undoing it - because a DM bounced would be worse than the bounce. - -3. **Anything else is a defect** — it propagates. `on_error` records it with - its traceback, under this application's logger rather than discord.py's. - A defect that reads like a delivery failure never gets fixed. - -Four catches are still broad, and all four are boundaries with nothing above -them to catch anything: the queue loop, the APScheduler job, the thread the -bot runs in, and the hand-off from a Flask request. Each says on its line why. - -Ruff's `BLE` rule is enabled (QUA-004), and what it enforces is not "never -catch broadly" — it is satisfied by logging the traceback. Which is the -whole discipline: a boundary may swallow anything, provided it leaves behind -enough to tell a defect from an outage. -""" - -import asyncio -import json import logging -import os -import tempfile -import threading -import time -from datetime import datetime, timedelta -from queue import Empty, Queue -from zoneinfo import ZoneInfo -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.cron import CronTrigger -from discord import ( - Forbidden, - HTTPException, - Intents, - LoginFailure, - NotFound, - PrivilegedIntentsRequired, +from app.discord import ( # noqa: F401 + bot_status, + get_bot, + purge_pending_for_participants, + send_one_on_one_dm, + send_one_on_one_response, + send_schedule_notification, + start_bot, ) -from discord.ext import commands -from dotenv import load_dotenv -from sqlalchemy.exc import SQLAlchemyError - -from app.time_utils import utc_now_naive - -load_dotenv() -DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') - -# Configure logging -logger = logging.getLogger(__name__) - -# File for persisting pending requests across bot restarts -PENDING_FILE = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json' +from app.discord.bot import ( # noqa: F401 + TeamTryoutsBot, + _stop_bot, + bot_instance, + bot_thread, + supervise_bot, ) - -# Emoji constants -#: Entries older than this are dropped when the file is loaded: a message -#: nobody reacted to in a month will not be reacted to (OPS-007). -PENDING_MAX_AGE_DAYS = 30 - -#: How late the daily reminder may still be sent. Long enough to survive a -#: restart or a slow start-up, short of the next day's occurrence. -REMINDER_GRACE_SECONDS = 3600 - -#: How many Discord user objects to keep resolved (PERF-005). -#: -#: A direct message costs two sequential API calls: resolve the snowflake to -#: a user, then send. The first is identical every time for the same person, -#: and the bot writes to the same few dozen people over and over — a season -#: of matches, then a reminder every evening at 18:00. -#: -#: What this does not fix: the first notification of a twenty-player match -#: still resolves twenty distinct users. The saving is across notifications, -#: not within one. Cutting the second call would need Discord's bulk DM -#: endpoints, which do not exist. -#: -#: Bounded because the process is long-lived. Eviction is oldest-first on the -#: insertion order of the dict, close enough to least-recently-used for a -#: roster that fits several times over. -USER_CACHE_MAX = 512 - -CHECK_EMOJI = '✅' # Green checkmark -CROSS_EMOJI = '❌' # Red X - -#: First wait before restarting a bot that stopped (OPS-004). Short, because -#: the common case is a brief network blip. -BOT_RESTART_DELAY_SECONDS = 5 - -#: Ceiling on that wait. Five minutes is late enough not to hammer Discord -#: through a long outage, early enough that nobody plans an evening around -#: the reminder never arriving. -BOT_RESTART_MAX_DELAY_SECONDS = 300 - -#: A connection that lasted this long counts as healthy, and resets the -#: back-off. Without it, a bot that ran for a month and then dropped would -#: wait five minutes before its first retry, on the strength of an incident -#: that is long over. -BOT_RESTART_RESET_SECONDS = 600 - -#: Notifications waiting for the bot thread. -#: -#: Module level, not per-instance: the supervisor replaces the bot object on -#: every restart, because discord.py cannot reuse a client whose run() has -#: returned. A queue living on the old object would be discarded with it, -#: taking every pending notification along. -message_queue = Queue() - -#: Set to ask the supervisor to stop waiting and return. Only tests set it; -#: in production the thread is a daemon and the process exit ends it. -_stop_bot = threading.Event() - - -class TeamTryoutsBot(commands.Bot): - """Unified Discord bot for Team Tryouts notifications. - - Handles One on One requests, schedule additions, and daily reminders. - """ - - def __init__(self, flask_app=None): - # Only what the bot actually reads. `members` — the privileged - # GUILD_MEMBERS intent — was requested and never used: nothing here - # lists or looks up guild members, the bot reaches people through - # the discord_user_id stored on their account (OPS-014). - # - # `message_content` stays: on_raw_reaction_add reads the text of a - # coach's reply to record a refusal note. - intents = Intents.default() - intents.message_content = True - intents.dm_messages = True - intents.dm_reactions = True - intents.reactions = True - intents.guilds = True - - super().__init__(command_prefix='!', intents=intents) - self.flask_app = flask_app - self.pending_requests = {} # Maps message_id to {type, id} for reaction handling - # The shared module-level queue, not a new one: a restart builds a - # fresh bot, and notifications queued against the old one must not - # go with it (OPS-004). - self.message_queue = message_queue - self.scheduler = AsyncIOScheduler() - self.timezone = ZoneInfo('America/Toronto') # EDT timezone - self._user_cache = {} # Discord snowflake -> User, bounded (PERF-005) - #: Whether the 18:00 reminder job is actually scheduled. Starts False - #: and is only set by start_scheduler succeeding, so /health reports - #: 'not yet' rather than guessing (ARCH-008). - self.reminders_scheduled = False - - def _load_pending(self): - """Load pending requests from the JSON file. - - A corrupt file used to be logged at error level and then silently - replaced by an empty dict: every in-flight Discord reaction stopped - having any effect, and nothing said so (OPS-006). The file is now - moved aside instead of overwritten, so the mapping can be recovered - by hand, and the message says what was lost. - - Entries older than PENDING_MAX_AGE_DAYS are dropped here rather - than kept forever (OPS-007). A message nobody reacted to in a month - is not going to be reacted to. - """ - if not os.path.exists(PENDING_FILE): - logger.info('No pending requests file found, starting fresh.') - return - - try: - with open(PENDING_FILE, encoding='utf-8') as handle: - data = json.load(handle) - if not isinstance(data, dict): - raise ValueError(f'expected an object, found {type(data).__name__}') - # Keys are Discord message ids; JSON turns them into strings. - loaded = {int(key): value for key, value in data.items()} - except (OSError, ValueError) as exc: - quarantine = f'{PENDING_FILE}.corrupt-{int(time.time())}' - try: - os.replace(PENDING_FILE, quarantine) - except OSError: - quarantine = '(could not be moved aside)' - logger.error( - 'Pending requests file unreadable (%s). Moved to %s. Reactions on ' - 'messages already sent will no longer be recognised until those ' - 'requests are answered in the web interface.', - exc, - quarantine, - ) - return - - kept, expired = self._drop_expired(loaded) - self.pending_requests = kept - logger.info( - 'Loaded %d pending requests from %s (%d expired and dropped)', - len(kept), - PENDING_FILE, - expired, - ) - if expired: - self._save_pending() - - @staticmethod - def _drop_expired(entries): - """Split loaded entries into the ones still worth keeping. - - Args: - entries: message_id → record. - - Returns: - tuple[dict, int]: Entries to keep, and how many were dropped. - A record without a timestamp predates this field and is kept — - dropping it would be guessing. - """ - cutoff = time.time() - PENDING_MAX_AGE_DAYS * 86400 - kept = {} - expired = 0 - for message_id, record in entries.items(): - created = record.get('created_at') if isinstance(record, dict) else None - if created is not None and created < cutoff: - expired += 1 - continue - kept[message_id] = record - return kept, expired - - def _save_pending(self): - """Write the pending requests to disk, atomically. - - The previous version opened the destination for writing and then - serialised into it: an interruption anywhere in between left a - truncated JSON file, which the loader could not read. Writing to a - neighbouring temporary file and renaming means the destination is - either the old content or the new one, never half of either. - """ - directory = os.path.dirname(PENDING_FILE) or '.' - try: - with tempfile.NamedTemporaryFile( - 'w', encoding='utf-8', dir=directory, prefix='.pending-', delete=False - ) as handle: - json.dump(self.pending_requests, handle, indent=2) - handle.flush() - os.fsync(handle.fileno()) - temporary = handle.name - os.replace(temporary, PENDING_FILE) - except OSError as exc: - logger.error('Could not save pending requests: %s', exc) - - async def setup_hook(self): - """Called when the bot is ready.""" - self._load_pending() - logger.info(f'TeamTryoutsBot logged in as {self.user}') - - async def on_error(self, event_method, *args, **kwargs): - """Record an exception that escaped an event handler. - - This is the third family: not a database refusal, not Discord being - unreachable — a defect. discord.py's own handler logs it on the - `discord` logger, which `configure_logging` does not wire to any of - this application's files: it attaches handlers to the `app` package - (logging_config.py). So an unexpected failure inside a reaction - handler went to a logger nobody reads, and the reaction looked - ignored. - - Deliberately says nothing to the user. At this point what did and did - not happen is exactly what is unknown, and a reassuring message would - be a guess. - """ - logger.exception( - 'Unhandled exception in %s. This is a defect, not a delivery failure: ' - 'expected Discord and database errors are handled where they occur.', - event_method, - ) - - async def on_ready(self): - """Log when the bot is ready and start background tasks.""" - try: - guilds = [g.name for g in self.guilds] - logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}') - except AttributeError: - logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}') - - # Start the queue processing task - self.loop.create_task(self.process_queue()) - - # Start the daily reminder scheduler - self.loop.create_task(self.start_scheduler()) - - async def start_scheduler(self): - """Start the APScheduler for daily reminders. - - `coalesce` and `misfire_grace_time` are the two settings this job - needs and did not have (OPS-005). Without a grace time, a restart at - 18:05 dropped the day's reminders with no trace; without coalescing, - a scheduler that wakes up behind schedule fires once per missed run, - so players receive the same reminder several times. - - The grace period is deliberately short of the next occurrence: a - reminder for tonight is worth sending an hour late, not tomorrow. - - A failure here is permanent and total — no reminder is ever sent - again — and used to leave one line in a log. It is now visible from - outside, in /health, because that is the difference between finding - out at start-up and finding out when a player asks why the reminders - stopped (ARCH-008). - """ - try: - self.scheduler.add_job( - self.send_daily_reminders, - trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone), - id='daily_reminders', - replace_existing=True, - coalesce=True, - misfire_grace_time=REMINDER_GRACE_SECONDS, - max_instances=1, - ) - self.scheduler.start() - except Exception: # a scheduler that will not start must not take on_ready with it - self.reminders_scheduled = False - logger.exception( - 'Daily reminder scheduler could not be started. No reminder will be ' - 'sent until the process is restarted; /health reports this.' - ) - return - - self.reminders_scheduled = True - logger.info('Daily reminder scheduler started (18:00 EDT)') - - async def _reply(self, channel, message): - """Answer the person who reacted, and never let that answer be fatal. - - Returns True when it went out. A reply is the only feedback a - reaction gets, so a failed one is worth a line — but it must not undo - a state change that has already been committed. - """ - try: - await channel.send(message) - except HTTPException as exc: - logger.error('Could not answer a reaction on Discord: %s', exc) - return False - return True - - async def _commit_or_report(self, channel, *, operation): - """Make the pending change durable, or say plainly that it is not. - - Returns True when the write landed. On a database refusal the session - is rolled back, the pending entry is left in place so the same - reaction can be tried again, and the person is told that nothing was - recorded — previously the failure was logged and the coroutine - returned, which is indistinguishable from the bot being down. - - Anything that is not a database error propagates: it is a defect, and - on_error records it as one. - """ - from app.extensions import db - - try: - db.session.commit() - except SQLAlchemyError: - db.session.rollback() - logger.exception('%s was not recorded: the database refused the write', operation) - await self._reply( - channel, - "⚠️ Nothing was recorded — the database refused the change. " - "Please answer from the web interface instead.", - ) - return False - return True - - def _forget_pending(self, message_id): - """Drop a message from the pending map, once its answer is durable. - - `pop` rather than `del`: two reactions on the same message race - through two coroutines, and the loser raised KeyError inside the old - blanket handler, where it read as an error with no consequence. - """ - if self.pending_requests.pop(message_id, None) is not None: - self._save_pending() - - async def _resolve_user(self, discord_uid, *, context=''): - """Return the Discord user behind a snowflake, or None. - - Tries three sources in order of cost: the library's own cache, which - is free but mostly empty since the members intent was dropped - (OPS-014); ours, which survives across notifications; then the API. - - A failure here is logged once, saying which of the two reasons it - was — a snowflake nobody owns is a data problem to fix in the - account, an HTTP error is Discord being Discord (PERF-005). - """ - try: - uid = int(discord_uid) - except (TypeError, ValueError): - logger.warning('Invalid Discord user id %r%s', discord_uid, context) - return None - - user = self.get_user(uid) or self._user_cache.get(uid) - if user is not None: - return user - - try: - user = await self.fetch_user(uid) - except NotFound: - logger.warning( - 'Discord user %s does not exist%s. The id stored on the account is ' - 'wrong or the account was deleted; nothing will ever be delivered ' - 'to it.', - uid, - context, - ) - return None - except HTTPException as exc: - logger.warning('Could not resolve Discord user %s%s: %s', uid, context, exc) - return None - - if user is None: - return None - - self._user_cache[uid] = user - while len(self._user_cache) > USER_CACHE_MAX: - self._user_cache.pop(next(iter(self._user_cache))) - return user - - async def _send_dm(self, discord_uid, message, *, purpose, recipient=''): - """Send one direct message and record what became of it. - - Returns the sent Message, or None. Every path logs exactly once, with - the recipient and the purpose, so that nineteen deliveries out of - twenty read as nineteen successes and one named failure instead of - looking like twenty (PERF-005). - - The three failures are not the same problem and must not read alike: - `blocked` is permanent until the person reopens their DMs and no - retry will change it, `failed` is transient, `unreachable` means the - account itself could not be resolved. - """ - context = f' ({purpose}{", " + recipient if recipient else ""})' - user = await self._resolve_user(discord_uid, context=context) - if user is None: - logger.warning('Notification not delivered — %s: recipient unreachable', purpose) - return None - - who = recipient or getattr(user, 'name', str(discord_uid)) - try: - sent = await user.send(message) - except Forbidden: - logger.warning( - 'Notification not delivered — %s to %s: their direct messages are ' - 'closed to this bot. Retrying will not help; they have to allow ' - 'DMs from server members.', - purpose, - who, - ) - return None - except HTTPException as exc: - logger.error('Notification not delivered — %s to %s: %s', purpose, who, exc) - return None - - logger.info('Delivered %s to %s (message_id=%s)', purpose, who, sent.id) - return sent - - async def _dispatch(self, item): - """Send one queued notification, inside an app context where needed.""" - kind = item.get('type') - if kind == 'one_on_one_request': - await self._send_one_on_one_dm(**item['data']) - elif kind == 'schedule_addition': - if self.flask_app: - with self.flask_app.app_context(): - await self._send_schedule_notification(**item['data']) - else: - 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) - - async def process_queue(self): - """Drain the queue fed by the Flask threads, for the life of the bot. - - The loop is the last thing standing between one bad item and every - subsequent notification, so it catches everything. What reaches it is - narrower than it used to be: `_send_dm` already reports a closed inbox - or an outage per recipient and returns None, so anything arriving here - got past the typed handling and is a defect. It reads as one. - """ - while True: - try: - item = self.message_queue.get_nowait() - except Empty: - await asyncio.sleep(0.5) - continue - - try: - await self._dispatch(item) - except Exception: # the loop must outlive a single bad item - logger.exception( - 'Unexpected failure while sending a %r notification. It is lost; ' - 'delivery failures that are merely expected do not reach here.', - item.get('type'), - ) - await asyncio.sleep(0.1) - - def is_dm_channel(self, channel) -> bool: - """Check if a channel is a DM channel.""" - return hasattr(channel, 'recipient') or hasattr(channel, 'recipients') - - async def on_raw_reaction_add(self, payload): - """Handle when a reaction is added to a message (works even after bot restart).""" - # Ignore bot's own reactions - if payload.user_id == self.user.id: - return - - # Check if this is a pending request we're tracking - if payload.message_id not in self.pending_requests: - return - - # Fetch the channel and check if it's a DM - try: - channel = await self.fetch_channel(payload.channel_id) - except HTTPException as exc: - # Silent before. The reaction is now unanswerable — there is no - # channel to answer in — so the log line is the only trace there - # will ever be that someone clicked and nothing happened. - logger.warning( - 'Could not open the channel of a reaction on message %s: %s. ' - 'The reaction was not acted on.', - payload.message_id, - exc, - ) - return - - if not self.is_dm_channel(channel): - return - - # Fetch the user who reacted - user = await self._resolve_user(payload.user_id, context=' (reaction handler)') - if user is None: - return - - request_info = self.pending_requests[payload.message_id] - emoji_str = str(payload.emoji) - - handler_type = request_info.get('type') - request_id = request_info.get('id') - - if emoji_str == CHECK_EMOJI: - if handler_type == 'one_on_one': - if self.flask_app: - with self.flask_app.app_context(): - await self.handle_one_on_one_approve( - user, payload.message_id, request_id, channel - ) - else: - await self.handle_one_on_one_approve( - user, payload.message_id, request_id, channel - ) - elif handler_type == 'schedule_addition': - if self.flask_app: - with self.flask_app.app_context(): - await self.handle_attendance_confirm( - user, payload.message_id, request_id, channel - ) - else: - await self.handle_attendance_confirm( - user, payload.message_id, request_id, channel - ) - elif emoji_str == CROSS_EMOJI: - if handler_type == 'one_on_one': - if self.flask_app: - with self.flask_app.app_context(): - await self.handle_one_on_one_reject( - user, payload.message_id, request_id, channel - ) - else: - await self.handle_one_on_one_reject( - user, payload.message_id, request_id, channel - ) - elif handler_type == 'schedule_addition': - if self.flask_app: - with self.flask_app.app_context(): - await self.handle_attendance_decline( - user, payload.message_id, request_id, channel - ) - else: - await self.handle_attendance_decline( - user, payload.message_id, request_id, channel - ) - - async def _send_one_on_one_dm( - self, - coach_name: str, - coach_discord_id: str, - player_name: str, - team_name: str, - date_str: str, - start_time: str, - end_time: str, - points: str, - request_id: int, - ) -> int: - """Send a One on One request DM to a coach with reactions.""" - message = ( - "📅 **One on One Request**\n\n" - f"**Player:** {player_name}\n" - f"**Team:** {team_name or 'Unknown Team'}\n" - f"**Date:** {date_str}\n" - f"**Time:** {start_time} - {end_time}\n" - f"**Discussion Points:** {points or 'No specific points provided'}\n\n" - "Please respond by clicking a reaction below:\n" - f"{CHECK_EMOJI} - Confirm the meeting\n" - f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)" - ) - - msg = await self._send_dm( - coach_discord_id, - message, - purpose='one-on-one request', - recipient=coach_name, - ) - if msg is None: - return None - - try: - await msg.add_reaction(CHECK_EMOJI) - await msg.add_reaction(CROSS_EMOJI) - except HTTPException as exc: - # The message is out; without reactions the coach cannot answer - # from Discord, but the web interface still works. - logger.error( - 'One on One request %s reached %s without its reactions (%s). It can ' - 'only be answered from the web interface.', - request_id, - coach_name, - exc, - ) - return None - - # Track this pending request - self.pending_requests[msg.id] = { - 'type': 'one_on_one', - 'id': request_id, - 'created_at': time.time(), - } - self._save_pending() - return msg.id - - async def _send_schedule_notification( - self, - user_id: int, - event_type: str, - event_title: str, - event_date: str, - event_time: str, - reference_id: int, - change_kind: str = 'scheduled', - ) -> int: - """Send a schedule addition notification to a player. - - Args: - user_id: Database primary key of the User (NOT Discord user ID). - event_type: 'match' or 'tryout'. - event_title: Title of the event. - event_date: Date string. - event_time: Time string. - 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 - from app.extensions import db - from app.models import User as DBUser - - db_user = db.session.get(DBUser, user_id) - if not db_user: - logger.warning(f"DB user {user_id} not found for schedule notification") - return None - - if not db_user.discord_user_id: - logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM") - return None - - event_name = "Match" if event_type == 'match' else "Tryout" - - if change_kind == 'modified': - message = ( - f"📅 **{event_name} Modified**\n\n" - f"The {event_type} date and/or time has been modified for " - f"**{event_title}**.\n" - f"The new date and/or time is:\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" - ) - 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( - db_user.discord_user_id, - message, - purpose=f'{event_type} schedule notification', - recipient=db_user.username, - ) - if msg is None: - return None - - try: - await msg.add_reaction(CHECK_EMOJI) - await msg.add_reaction(CROSS_EMOJI) - except HTTPException as exc: - # Without the reactions the player has no way to answer: the - # message asks them to click something that is not there. - logger.error( - 'Schedule notification reached %s without its reactions (%s). They ' - 'cannot confirm attendance from Discord.', - db_user.username, - exc, - ) - return None - - # Track this pending request - self.pending_requests[msg.id] = { - 'type': 'schedule_addition', - 'id': reference_id, - 'event_type': event_type, - 'created_at': time.time(), - } - self._save_pending() - return msg.id - - async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): - """Record a coach's approval, then tell the coach and the player. - - Four steps, and they do not fail alike: read the request and stage - the change (database), commit it (database, and the only step whose - failure means nothing happened), answer the coach (Discord), tell the - player (Discord). Everything after the commit is best-effort by - definition — the decision is recorded, and a DM that bounces does not - un-record it. - """ - from app.extensions import db - from app.models import OneOnOneRequest - - try: - request = db.session.get(OneOnOneRequest, request_id) - if not request: - # The row is gone; no reaction on this message can ever mean - # anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS - # was invented to bound. - logger.info( - 'One on One request %s no longer exists; its pending message was dropped.', - request_id, - ) - self._forget_pending(message_id) - return - - if request.coach.discord_user_id != str(coach.id): - await self._reply(channel, "⚠️ You are not the intended recipient.") - return - - # Re-attach to current session (object may be detached across app contexts) - request = db.session.merge(request) - - # Capture data before commit - player_full_name = request.player.full_name if request.player else 'Unknown' - player_discord_id = request.player.discord_user_id if request.player else None - coach_obj = request.coach - - request.status = 'approved' - request.responded_at = utc_now_naive() - except SQLAlchemyError: - db.session.rollback() - logger.exception('Could not read One on One request %s to approve it', request_id) - await self._reply( - channel, - "⚠️ Nothing was recorded — the request could not be read. " - "Please answer from the web interface instead.", - ) - return - - if not await self._commit_or_report( - channel, operation=f'Approval of One on One request {request_id}' - ): - return - - # Durable from here. Order matters: forget the pending entry before - # the two messages, so that a bounced DM cannot leave a request that - # is already approved answerable a second time. - self._forget_pending(message_id) - - await self._reply( - channel, f"✅ You have **approved** the One on One session with {player_full_name}." - ) - - if player_discord_id: - await self.notify_player_about_one_on_one_direct( - player_discord_id=player_discord_id, - player_full_name=player_full_name, - coach_full_name=coach_obj.full_name if coach_obj else 'Coach', - request=request, - approved=True, - ) - - async def handle_one_on_one_reject(self, coach, message_id, request_id, channel): - """Record a coach's refusal, with the reason they replied, if any. - - Same four steps as the approval, plus a fifth that fails on its own - terms: reading back the coach's reply to find a refusal note. That - one is optional by construction — a refusal without a note is a - refusal — so it is caught where it happens and the rest continues. - """ - from app.extensions import db - from app.models import OneOnOneRequest - - try: - request = db.session.get(OneOnOneRequest, request_id) - if not request: - logger.info( - 'One on One request %s no longer exists; its pending message was dropped.', - request_id, - ) - self._forget_pending(message_id) - return - - if request.coach.discord_user_id != str(coach.id): - await self._reply(channel, "⚠️ You are not the intended recipient.") - return - - # Re-attach to current session (object may be detached across app contexts) - request = db.session.merge(request) - - player_full_name = request.player.full_name if request.player else 'Unknown' - player_discord_id = request.player.discord_user_id if request.player else None - coach_obj = request.coach - except SQLAlchemyError: - db.session.rollback() - logger.exception('Could not read One on One request %s to reject it', request_id) - await self._reply( - channel, - "⚠️ Nothing was recorded — the request could not be read. " - "Please answer from the web interface instead.", - ) - return - - refusal_note = None - try: - async for reply in channel.history(limit=20): - if ( - reply.author.id == coach.id - and reply.reference - and reply.reference.message_id == message_id - ): - refusal_note = reply.content - break - except HTTPException as exc: - logger.warning( - 'Could not read the channel history for a refusal note on request %s: %s. ' - 'The refusal is still recorded, without a reason.', - request_id, - exc, - ) - - try: - request.status = 'rejected' - request.responded_at = utc_now_naive() - if refusal_note: - request.coach_rejection_message = refusal_note - except SQLAlchemyError: - db.session.rollback() - logger.exception('Could not stage the refusal of One on One request %s', request_id) - await self._reply(channel, "⚠️ Nothing was recorded. Please use the web interface.") - return - - if not await self._commit_or_report( - channel, operation=f'Refusal of One on One request {request_id}' - ): - return - - self._forget_pending(message_id) - - rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}." - if refusal_note: - rejection_msg += f"\n**Reason:** {refusal_note}" - else: - rejection_msg += "\n\nℹ️ The player has been notified that you are not available." - - await self._reply(channel, rejection_msg) - if player_discord_id: - await self.notify_player_about_one_on_one_direct( - player_discord_id=player_discord_id, - player_full_name=player_full_name, - coach_full_name=coach_obj.full_name if coach_obj else 'Coach', - request=request, - approved=False, - refusal_note=refusal_note, - ) - - @staticmethod - def _attendance_record(event_type, reference_id): - """The row an attendance reaction refers to, and whose it is. - - Args: - event_type: 'match' or 'tryout'. - reference_id: Primary key of the participation row. - - Returns: - tuple: (row, player_id) — either may be None. - """ - from app.extensions import db - from app.models import MatchParticipant, TryoutRegistration - - if event_type == 'match': - row = db.session.get(MatchParticipant, reference_id) - elif event_type == 'tryout': - row = db.session.get(TryoutRegistration, reference_id) - else: - row = None - return row, getattr(row, 'player_id', None) - - @staticmethod - def _reacting_user_owns(player_id, reacting_user): - """Whether the Discord account that reacted is the row's owner. - - handle_one_on_one_approve and _reject have always compared the - reacting account against the coach the request was sent to. The two - attendance handlers did not (OPS-009) — same message shape, same - threat, one of them checked. The asymmetry was the bug. - """ - from app.extensions import db - from app.models import User - - if not player_id: - return False - owner = db.session.get(User, player_id) - return bool(owner and owner.discord_user_id == str(reacting_user.id)) - - async def handle_attendance_confirm(self, player, message_id, reference_id, channel): - """Record a player confirming attendance for a match or tryout. - - This is the handler the audit used to describe ARCH-008. The player - was told "✅ Your attendance has been confirmed!" from inside the same - blanket handler that swallowed the commit — so a database refusal - produced no message at all, and the reaction was indistinguishable - from a bot that had stopped running. The commit and the message are - now separate steps with separate outcomes. - """ - from app.extensions import db - - request_info = self.pending_requests.get(message_id) - if request_info is None: - # Two awaits happened since on_raw_reaction_add checked; the - # other reaction on the same message got here first. - return - event_type = request_info.get('event_type') - - try: - row, player_id = self._attendance_record(event_type, reference_id) - - if row is not None and not self._reacting_user_owns(player_id, player): - await self._reply(channel, "⚠️ You are not the intended recipient.") - return - - if row is not None: - row = db.session.merge(row) - if event_type == 'match': - row.attendance_confirmed = True - else: - # TryoutRegistration has no attendance_confirmed column - # (DB-008, waiting on Alembic). Assigning here sets a - # Python attribute that is never written, so the player - # was told "confirmed" and nothing was recorded. Still - # true — but no longer silent. - logger.warning( - 'Tryout attendance confirmation from user_id=%s for ' - 'registration %s was not persisted: TryoutRegistration ' - 'has no attendance_confirmed column (DB-008).', - 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( - 'Could not read the %s participation row %s to confirm attendance', - event_type, - reference_id, - ) - await self._reply( - channel, - "⚠️ Nothing was recorded. Please confirm from the web interface instead.", - ) - return - - if not await self._commit_or_report( - channel, operation=f'Attendance confirmation for {event_type} row {reference_id}' - ): - return - - self._forget_pending(message_id) - await self._reply(channel, "✅ Your attendance has been confirmed!") - - async def handle_attendance_decline(self, player, message_id, reference_id, channel): - """Record a player declining attendance for a match or tryout. - - The mirror of handle_attendance_confirm, and the same reasoning: a - declined match deletes a participation row, which is exactly the kind - of write a foreign key can refuse. The player now hears about it. - """ - from app.extensions import db - - request_info = self.pending_requests.get(message_id) - if request_info is None: - return - event_type = request_info.get('event_type') - - try: - row, player_id = self._attendance_record(event_type, reference_id) - - if row is not None and not self._reacting_user_owns(player_id, player): - await self._reply(channel, "⚠️ You are not the intended recipient.") - return - - if row is not None: - row = db.session.merge(row) - if event_type == 'match': - 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( - 'Could not read the %s participation row %s to decline attendance', - event_type, - reference_id, - ) - await self._reply( - channel, - "⚠️ Nothing was recorded. Please answer from the web interface instead.", - ) - return - - if not await self._commit_or_report( - channel, operation=f'Attendance refusal for {event_type} row {reference_id}' - ): - return - - self._forget_pending(message_id) - await self._reply(channel, "❌ Your attendance has been declined.") - - async def notify_player_about_one_on_one_direct( - self, - player_discord_id, - player_full_name, - coach_full_name, - request, - approved=True, - refusal_note=None, - ): - """Send confirmation to player about One on One response using pre-fetched data. - - This method avoids session expiration issues by using data captured before - the database commit. - - Args: - player_discord_id: The player's Discord user ID string. - player_full_name: The player's full name. - coach_full_name: The coach's full name. - request: The OneOnOneRequest object (for date/time/points data only). - approved: Whether the session was approved. - refusal_note: Optional coach refusal reason. - - Catches nothing of its own. Every caller reaches this after the - coach's answer is committed, so a failure here costs the player a - message and nothing else — and if it is a defect (a request whose - date is None, say) it must be seen as one, in on_error, rather than - flattened into "Error in direct One on One notification". - """ - if not player_discord_id: - logger.warning('Player has no Discord user ID for request %s', request.id) - return - - if approved: - message = ( - "🎉 **One on One Session Confirmed!**\n\n" - f"Your coach **{coach_full_name}** has approved your request:\n" - f"**Date:** {request.date.strftime('%A, %B %d, %Y')}\n" - f"**Time:** {request.start_time.strftime('%I:%M %p')} - {request.end_time.strftime('%I:%M %p')}\n" - f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n" - "Please prepare for your session!" - ) - elif refusal_note: - message = ( - "😞 **One on One Session Rejected**\n\n" - f"Your coach **{coach_full_name}** has declined:\n" - f"**Reason:** {refusal_note}\n\n" - "Please try selecting a different time slot." - ) - else: - message = ( - "😞 **One on One Session Unavailable**\n\n" - f"Your coach **{coach_full_name}** is not available.\n\n" - "Please try selecting a different time slot." - ) - - await self._send_dm( - player_discord_id, - message, - purpose=f'one-on-one response (request {request.id})', - recipient=player_full_name, - ) - - async def send_daily_reminders(self): - """Run the 18:00 batch. The APScheduler job boundary. - - Broad on purpose: nothing above this is ours. An exception escaping - an APScheduler job is logged by the library and the job stays - scheduled, but the line it writes says nothing about what this batch - was doing, and it lands on a logger this application does not - collect. - """ - try: - if self.flask_app: - with self.flask_app.app_context(): - await self._send_daily_reminders_impl() - else: - await self._send_daily_reminders_impl() - except Exception: # a failed batch must not unschedule tomorrow's - logger.exception('The daily reminder batch failed. Tomorrow’s run is unaffected.') - - async def _send_daily_reminders_impl(self): - """Internal implementation of daily reminders with proper app context. - - Only the database is caught here. A batch that cannot read the - schedule has nothing to send and is worth one clear line; a batch - that fails for any other reason is a defect and belongs in the - traceback that send_daily_reminders writes. - """ - from sqlalchemy.orm import joinedload - - from app.models import ( - Match, - MatchParticipant, - OneOnOneRequest, - Tryout, - TryoutRegistration, - ) - - now = datetime.now(self.timezone) - tomorrow = now.date() + timedelta(days=1) - - # Counted so that a partial run is visible as such. Per-recipient - # failures are logged by _send_dm; without this total, a batch in - # which half the reminders bounced looked exactly like one where - # they all went out (PERF-005). - attempted = delivered = 0 - - try: - # Find matches for tomorrow - matches = Match.query.filter(Match.date == tomorrow).all() - for match in matches: - participants = MatchParticipant.query.filter_by(match_id=match.id).all() - for participant in participants: - if participant.player.discord_user_id: - attempted += 1 - delivered += await self.send_match_reminder(participant.player, match) - - # Find tryouts for tomorrow - tryouts = Tryout.query.filter(Tryout.date == tomorrow).all() - for tryout in tryouts: - registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() - for reg in registrations: - if reg.player.discord_user_id: - attempted += 1 - delivered += await self.send_tryout_reminder(reg.player, tryout) - - # Find One on One sessions for tomorrow (only approved ones) - one_on_ones = ( - OneOnOneRequest.query.options( - joinedload(OneOnOneRequest.player), joinedload(OneOnOneRequest.coach) - ) - .filter(OneOnOneRequest.date == tomorrow, OneOnOneRequest.status == 'approved') - .all() - ) - for session in one_on_ones: - if session.player and session.player.discord_user_id: - attempted += 1 - delivered += await self.send_one_on_one_reminder(session.player, session) - except SQLAlchemyError: - db_reached = f'{delivered} of {attempted} sent before it stopped' - logger.exception( - 'The daily reminder batch for %s could not read the schedule (%s)', - tomorrow, - db_reached, - ) - return - - if attempted and delivered < attempted: - logger.warning( - 'Daily reminders for %s: %d of %d delivered, %d failed. See the ' - 'lines above for who and why.', - tomorrow, - delivered, - attempted, - attempted - delivered, - ) - else: - logger.info('Daily reminders for %s: %d delivered', tomorrow, delivered) - - 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:** {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" - "Please confirm your attendance in the app." - ) - sent = await self._send_dm( - player.discord_user_id, - message, - purpose='match reminder', - recipient=player.username, - ) - return sent is not None - - 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:** {date_str}\n" - f"**Location:** {tryout.location or 'TBD'}\n\n" - "Please confirm your attendance in the app." - ) - sent = await self._send_dm( - player.discord_user_id, - message, - purpose='tryout reminder', - recipient=player.username, - ) - return sent is not None - - async def _send_one_on_one_response_dm( - self, - player_discord_id: str, - player_full_name: str, - coach_full_name: str, - date_str: str, - start_time: str, - end_time: str, - points: str, - approved: bool, - refusal_note: str = None, - ) -> bool: - """Send a DM to a player notifying them of their One on One request response. - - Called from the message queue when a coach accepts/rejects via the web app. - - Every argument is a string prepared by the caller, and _send_dm - already handles a closed inbox and an outage. There is nothing left - for a blanket catch to protect against, so anything that goes wrong - here is a defect and process_queue records it as one. - """ - if not player_discord_id: - logger.warning("Cannot send response DM: no player_discord_id") - return False - - if approved: - message = ( - "🎉 **One on One Session Confirmed!**\n\n" - f"Your coach **{coach_full_name}** has approved your request:\n" - f"**Date:** {date_str}\n" - f"**Time:** {start_time} - {end_time}\n" - f"**Discussion Points:** {points or 'No specific points provided'}\n\n" - "Please prepare for your session!" - ) - elif refusal_note: - message = ( - "😞 **One on One Session Rejected**\n\n" - f"Your coach **{coach_full_name}** has declined:\n" - f"**Reason:** {refusal_note}\n\n" - "Please try selecting a different time slot." - ) - else: - message = ( - "😞 **One on One Session Unavailable**\n\n" - f"Your coach **{coach_full_name}** is not available.\n\n" - "Please try selecting a different time slot." - ) - - sent = await self._send_dm( - player_discord_id, - message, - purpose=f'one-on-one response ({"approved" if approved else "declined"})', - recipient=player_full_name, - ) - return sent is not None - - 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:** {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!" - ) - sent = await self._send_dm( - player.discord_user_id, - message, - purpose='one-on-one reminder', - recipient=player.username, - ) - return sent is not None - - -# Global bot instance -bot_instance = None -bot_thread = None - -#: The application the bot binds its database work to. Held separately from -#: the instance because the supervisor discards the instance on restart and -#: has to build the next one with the same application (OPS-004). -bot_flask_app = None - - -def get_bot(flask_app=None): - """Get or create the bot instance. - - Falls back to the application the supervisor was started with, so that - a bot rebuilt after a disconnection still reaches the database — the - caller that has the application is `create_app`, and it only calls this - once (OPS-004). - """ - global bot_instance, bot_flask_app - if flask_app is not None: - bot_flask_app = flask_app - if bot_instance is None: - bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_flask_app) - elif flask_app is not None and bot_instance.flask_app is None: - bot_instance.flask_app = flask_app - return bot_instance - - -def _enqueue(kind: str, data: dict, *, description: str) -> bool: - """Hand one notification to the bot thread. Never raises. - - The three public senders below were the same fifteen lines three times, - each with its own blanket catch. There is one now, and it is the only - place in this module where breadth is a property of the caller rather - than of the failure: this runs in a Flask request thread, and the work - the notification announces is already committed. The meeting is booked - whether or not Discord hears about it, so a failure here must not become - a 500 on a page whose job is done. - - What it is not is somewhere to lose a defect: the traceback is logged - and the caller is told False. - """ - try: - get_bot().message_queue.put({'type': kind, 'data': data}) - except Exception: # a notification must never fail the request that caused it - logger.exception('Could not queue %s', description) - return False - return True - - -def send_one_on_one_dm( - coach_name: str, - coach_discord_id: str, - player_name: str, - team_name: str, - date_str: str, - start_time: str, - end_time: str, - points: str, - request_id: int, -) -> bool: - """Queue a One on One request DM to be sent by the bot.""" - return _enqueue( - 'one_on_one_request', - { - 'coach_name': coach_name, - 'coach_discord_id': coach_discord_id, - 'player_name': player_name, - 'team_name': team_name, - 'date_str': date_str, - 'start_time': start_time, - 'end_time': end_time, - 'points': points, - 'request_id': request_id, - }, - description=f'the One on One request DM for request {request_id}', - ) - - -def send_schedule_notification( - user_id: int, - event_type: str, - event_title: str, - event_date: str, - event_time: str, - reference_id: int, - change_kind: str = 'scheduled', -) -> bool: - """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( - 'schedule_addition', - { - 'user_id': user_id, - 'event_type': event_type, - 'event_title': event_title, - 'event_date': event_date, - 'event_time': event_time, - 'reference_id': reference_id, - 'change_kind': change_kind, - }, - description=f'the {event_type} notification for user {user_id}', - ) - - -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, - coach_full_name: str, - date_str: str, - start_time: str, - end_time: str, - points: str, - approved: bool, - refusal_note: str = None, -) -> bool: - """Queue a One on One response DM to be sent to the player by the bot. - - Called from Flask routes when a coach accepts/rejects via the web app. - """ - return _enqueue( - 'one_on_one_response', - { - 'player_discord_id': player_discord_id, - 'player_full_name': player_full_name, - 'coach_full_name': coach_full_name, - 'date_str': date_str, - 'start_time': start_time, - 'end_time': end_time, - 'points': points, - 'approved': approved, - 'refusal_note': refusal_note, - }, - description=f'the One on One response DM for {player_full_name}', - ) - - -def supervise_bot(run_once=False): - """Keep a Discord bot running, or say clearly why one will not be. - - `bot.run()` returning means the connection is gone for good — discord.py - reconnects on its own for anything recoverable. What happened next was - nothing: the thread ended, `bot_thread` stayed non-None so `start_bot` - would never start another, and the web application went on serving pages - while every notification and every daily reminder had stopped. Nothing - reported it; the outage could last weeks (OPS-004). - - Two kinds of ending are told apart, because retrying helps with exactly - one of them. A rejected token or a missing privileged intent is a - configuration error: retrying it accomplishes nothing except hammering - Discord's login endpoint, which is how an application gets rate-limited - or banned outright. Everything else is treated as an outage and retried - with an exponential back-off, capped, for as long as the process lives. - - The back-off resets after a connection that lasted. Otherwise a bot that - runs happily for a month and then drops would wait five minutes before - its first retry, having "learned" from an incident that is long over. - - Args: - run_once: Stop after a single attempt. For tests — the production - caller never sets it. - """ - global bot_instance - - delay = BOT_RESTART_DELAY_SECONDS - while True: - # A fresh instance every time, deliberately. discord.py closes the - # client when run() returns, and a closed Client will not log in - # again — reusing it is how a "restart" turns into a thread that - # spins on an exception. This is also why message_queue lives at - # module level: a queue on the old object would take every pending - # notification with it. - bot = get_bot(flask_app=bot_flask_app) - started = time.monotonic() - - try: - bot.run(DISCORD_BOT_TOKEN) - except (LoginFailure, PrivilegedIntentsRequired) as exc: - logger.error( - 'The Discord bot cannot log in (%s). This is a configuration error, ' - 'not an outage: retrying would only hammer the login endpoint. No ' - 'notification will be sent until the token or the intents are fixed ' - 'and the process is restarted.', - type(exc).__name__, - ) - return - except Exception: # top of a thread; there is nothing above to catch it - logger.exception('The Discord bot stopped on an unhandled error') - else: - logger.error('The Discord bot disconnected and did not recover on its own') - - bot_instance = None - - if time.monotonic() - started >= BOT_RESTART_RESET_SECONDS: - delay = BOT_RESTART_DELAY_SECONDS - - if run_once: - return - - logger.warning( - 'Restarting the Discord bot in %ds. Notifications are not being sent ' - 'until it reconnects.', - delay, - ) - if _stop_bot.wait(delay): - logger.info('Discord bot supervisor asked to stop.') - return - delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS) - - -def start_bot(flask_app=None): - """Start the supervised Discord bot in a background thread.""" - global bot_thread, bot_flask_app - - if flask_app is not None: - bot_flask_app = flask_app - get_bot(flask_app=flask_app) - - if DISCORD_BOT_TOKEN and bot_thread is None: - _stop_bot.clear() - bot_thread = threading.Thread(target=supervise_bot, daemon=True) - bot_thread.start() - logger.info("TeamTryoutsBot started in background thread") - elif not DISCORD_BOT_TOKEN: - logger.warning("DISCORD_BOT_TOKEN not set, bot not started") - - -def bot_status(): - """What the bot is doing, for /health (OPS-012). - - The bot runs in a daemon thread inside the web process. When that - thread dies — a revoked token, a fatal gateway error — the web - application keeps serving pages and no notification is ever sent again. - Nothing reported it, so nobody found out until someone asked why they - had stopped receiving reminders. - - `reminders_scheduled` is the second silent-death case (ARCH-008): the - thread can be alive and the gateway connected while the 18:00 job never - got scheduled, in which case nothing is ever sent at 18:00 and the other - three keys all read green. - - Returns: - dict: `configured` (a token is set), `running` (the thread is - alive), `connected` (the gateway session is up), `pending` - (reaction mappings held in memory) and `reminders_scheduled`. - """ - running = bool(bot_thread and bot_thread.is_alive()) - instance = bot_instance - connected = bool(instance and not instance.is_closed() and instance.is_ready()) - return { - 'configured': bool(DISCORD_BOT_TOKEN), - 'running': running, - 'connected': connected, - 'pending': len(instance.pending_requests) if instance else 0, - 'reminders_scheduled': bool(instance and instance.reminders_scheduled), - } +from app.discord.constants import ( # noqa: F401 + CHECK_EMOJI, + CROSS_EMOJI, + PENDING_MAX_AGE_DAYS, + REMINDER_GRACE_SECONDS, + USER_CACHE_MAX, +) +from app.discord.queue import message_queue # noqa: F401 + +logger = logging.getLogger('app.discord_bot') # noqa: F811 diff --git a/app/routes/admin.py b/app/routes/admin.py index 88d3ebc..41fddec 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -211,7 +211,7 @@ def restore_backup(backup_id): # Create a safety backup of the current state before restoring. try: - safety = _create_backup_record( + _create_backup_record( backup_type='pre_restore', notes='Automatic safety backup before restoring ' + record.filename, ) diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index a8a8798..4c4f83f 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -29,6 +29,40 @@ from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') +def validate_score(value): + """Convert a raw form value to an int 1..10, or None if blank/invalid. + + Args: + value: Raw value from request.form — a string, or None for a missing field. + + Returns: + int | None: Score 1-10, or None. + """ + if value is None or (isinstance(value, str) and value.strip() == ''): + return None + try: + score = int(value) + except (ValueError, TypeError): + return None + if score < 1 or score > 10: + return None + return score + + +def _apply_evaluation(evaluation, scores, comments, position): + """Write scores, comments and recommended position onto an Evaluation row. + + Args: + evaluation: The Evaluation instance (new or existing). + scores: Dict mapping criterion name to int or None. + comments: Free-text comment string, or None. + position: Recommended position string, or None. + """ + evaluation.apply_scores(scores) + evaluation.comments = comments + evaluation.position_recommendation = position + + def _users_by_id(user_ids): """Load a set of users once for aggregate/list views.""" wanted = {user_id for user_id in user_ids if user_id} diff --git a/app/routes/matches.py b/app/routes/matches.py index b282015..a467613 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -649,14 +649,14 @@ def edit_match(match_id): # Purge Discord pending entries for participants that no longer exist. if removed_ids: - from app.discord_bot import purge_pending_for_participants + from app.discord import purge_pending_for_participants purge_pending_for_participants(removed_ids) # Decide whether and what to notify. if time_changed: # All current participants get the "modified" message. - all_current = list(zip(notified_player_ids, notified_participant_ids)) + all_current = list(zip(notified_player_ids, notified_participant_ids, strict=True)) notify_participants( title=match.title, date=match.date, @@ -671,7 +671,7 @@ def edit_match(match_id): new_player_ids = desired - set(existing.keys()) new_pairs = [ (pid, nid) - for pid, nid in zip(notified_player_ids, notified_participant_ids) + for pid, nid in zip(notified_player_ids, notified_participant_ids, strict=True) if pid in new_player_ids ] notify_participants( diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py index 6335f0e..4ac3a92 100644 --- a/app/routes/users/contracts.py +++ b/app/routes/users/contracts.py @@ -3,17 +3,17 @@ import os import uuid -from flask import flash, redirect, render_template, request, send_file, url_for +from flask import flash, render_template, request, send_file, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError from werkzeug.utils import secure_filename from app.extensions import db -from app.responders import respond_after_change from app.models import Admin, Coach, Contract, Manager, Player, User from app.models.admin_settings import AppSettings from app.permissions import can_manage_player_contract, coach_player_ids +from app.responders import respond_after_change from app.routes.users._shared import ( ALLOWED_CONTRACT_EXTENSIONS, ALLOWED_SIGNED_EXTENSIONS, diff --git a/app/routes/users/notes.py b/app/routes/users/notes.py index d2e80ec..852b77c 100644 --- a/app/routes/users/notes.py +++ b/app/routes/users/notes.py @@ -10,7 +10,6 @@ from flask_login import current_user, login_required from marshmallow import ValidationError from app.extensions import db -from app.responders import respond_after_change from app.forms import flash_validation_errors, form_payload from app.models import ( Coach, @@ -32,6 +31,7 @@ from app.permissions import ( coach_player_ids, coach_tryouts, ) +from app.responders import respond_after_change from app.routes.users.blueprint import users_bp from app.validators import NoteContentSchema, PersonalNoteSchema diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index 19b8cf7..78dc8c9 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -6,9 +6,9 @@ from flask_login import current_user, login_required from marshmallow import ValidationError from app.extensions import db -from app.responders import respond_after_change from app.forms import flash_validation_errors, form_payload from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote +from app.responders import respond_after_change from app.routes.users.blueprint import users_bp from app.services.notifications import send_discord_notification from app.time_utils import utc_now_naive @@ -183,7 +183,7 @@ def accept_one_on_one(request_id): # Notify player via Discord (same message as if approved through Discord reactions) if player and player.discord_user_id: - from app.discord_bot import send_one_on_one_response + from app.discord import send_one_on_one_response send_one_on_one_response( player_discord_id=player.discord_user_id, @@ -242,7 +242,7 @@ def reject_one_on_one(request_id): # Notify player via Discord (same message as if rejected through Discord reactions) if player and player.discord_user_id: - from app.discord_bot import send_one_on_one_response + from app.discord import send_one_on_one_response send_one_on_one_response( player_discord_id=player.discord_user_id, diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index 04b8bc3..6525a09 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -1,12 +1,11 @@ """The signed-in user's own profile.""" -from flask import flash, redirect, render_template, request, url_for +from flask import flash, 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, hash_password -from app.responders import respond_after_change from app.logging_config import log_auth_event from app.models import ( ESPORT_GAMES, @@ -17,6 +16,7 @@ from app.models import ( Player, User, ) +from app.responders import respond_after_change from app.routes.users._shared import ( flash_validation_errors, form_payload, diff --git a/app/services/notifications.py b/app/services/notifications.py index 496fb1d..17ae20f 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -53,7 +53,7 @@ def send_discord_notification( if coach_discord_id: try: - from app.discord_bot import send_one_on_one_dm + from app.discord import send_one_on_one_dm send_one_on_one_dm( coach_name=coach_name, @@ -76,7 +76,7 @@ def send_discord_notification( # coach has no id of their own. Queueing it cannot raise (see _enqueue). if DISCORD_WEBHOOK_URL.isdigit(): if not coach_discord_id: - from app.discord_bot import send_one_on_one_dm + from app.discord import send_one_on_one_dm send_one_on_one_dm( coach_name=coach_name, diff --git a/app/services/scheduling.py b/app/services/scheduling.py index 83f4eb0..fb00812 100644 --- a/app/services/scheduling.py +++ b/app/services/scheduling.py @@ -15,7 +15,7 @@ two. import logging -from app.discord_bot import send_schedule_notification +from app.discord import send_schedule_notification logger = logging.getLogger(__name__) diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 080beb9..04cfecf 100644 Binary files a/app/translations/en/LC_MESSAGES/messages.mo and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index 7e1f100..4ec4fae 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-26 22:04-0400\n" +"POT-Creation-Date: 2026-09-08 14:29-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -19,12 +19,20 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app/app.py:564 +#: app/app.py:379 +msgid "" +"This page was changed by someone else. Reload to see the latest changes " +"before saving." +msgstr "" +"This page was changed by someone else. Reload to see the latest changes " +"before saving." + +#: app/app.py:614 msgid "Please log in to access this page." msgstr "Please log in to access this page." #: app/forms.py:37 app/routes/auth.py:230 app/routes/auth.py:389 -#: app/routes/auth.py:403 app/routes/users/contracts.py:120 +#: app/routes/auth.py:403 app/routes/users/contracts.py:121 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" @@ -78,7 +86,7 @@ msgstr "Full name is required." msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:284 app/validators.py:924 +#: app/validators.py:284 app/validators.py:936 msgid "Unknown game." msgstr "Unknown game." @@ -98,7 +106,7 @@ msgstr "Unknown platform for this game." msgid "Invalid role selected." msgstr "Invalid role selected." -#: app/validators.py:473 app/validators.py:596 app/validators.py:629 +#: app/validators.py:473 app/validators.py:596 app/validators.py:641 msgid "Player must be selected." msgstr "Player must be selected." @@ -106,15 +114,15 @@ msgstr "Player must be selected." msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:513 app/validators.py:994 +#: app/validators.py:513 app/validators.py:1006 msgid "Invalid coach selection." msgstr "Invalid coach selection." -#: app/validators.py:519 app/validators.py:1000 +#: app/validators.py:519 app/validators.py:1012 msgid "Invalid manager selection." msgstr "Invalid manager selection." -#: app/validators.py:537 app/validators.py:595 app/validators.py:628 +#: app/validators.py:537 app/validators.py:595 app/validators.py:640 msgid "Invalid player selection." msgstr "Invalid player selection." @@ -134,131 +142,131 @@ msgstr "Unknown registration status." msgid "Team name must be between 1 and 100 characters." msgstr "Team name must be between 1 and 100 characters." -#: app/validators.py:603 +#: app/validators.py:603 app/validators.py:615 msgid "Position must be 50 characters or less." msgstr "Position must be 50 characters or less." -#: app/validators.py:616 +#: app/validators.py:628 msgid "Note content must be between 1 and 5000 characters." msgstr "Note content must be between 1 and 5000 characters." -#: app/validators.py:642 +#: app/validators.py:654 msgid "Select at most one note context." msgstr "Select at most one note context." -#: app/validators.py:654 +#: app/validators.py:666 msgid "Rejection reason must be 2000 characters or less." msgstr "Rejection reason must be 2000 characters or less." -#: app/validators.py:678 +#: app/validators.py:690 msgid "Date must be in YYYY-MM-DD format." msgstr "Date must be in YYYY-MM-DD format." -#: app/validators.py:679 +#: app/validators.py:691 msgid "A date is required." msgstr "A date is required." -#: app/validators.py:685 app/validators.py:750 +#: app/validators.py:697 app/validators.py:762 msgid "Start time must be in HH:MM format." msgstr "Start time must be in HH:MM format." -#: app/validators.py:686 app/validators.py:751 +#: app/validators.py:698 app/validators.py:763 msgid "A start time is required." msgstr "A start time is required." -#: app/validators.py:692 +#: app/validators.py:704 msgid "End time must be in HH:MM format." msgstr "End time must be in HH:MM format." -#: app/validators.py:693 +#: app/validators.py:705 msgid "An end time is required." msgstr "An end time is required." -#: app/validators.py:697 +#: app/validators.py:709 msgid "Points must be 2000 characters or less." msgstr "Points must be 2000 characters or less." -#: app/validators.py:712 +#: app/validators.py:724 msgid "End time must be after start time." msgstr "End time must be after start time." -#: app/validators.py:741 app/validators.py:743 +#: app/validators.py:753 app/validators.py:755 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/validators.py:744 +#: app/validators.py:756 msgid "A day is required." msgstr "A day is required." -#: app/validators.py:779 +#: app/validators.py:791 msgid "Player selection is malformed." msgstr "Player selection is malformed." -#: app/validators.py:805 app/validators.py:915 +#: app/validators.py:817 app/validators.py:927 msgid "A title is required." msgstr "A title is required." -#: app/validators.py:814 +#: app/validators.py:826 msgid "Invalid date format." msgstr "Invalid date format." -#: app/validators.py:819 app/validators.py:826 +#: app/validators.py:831 app/validators.py:838 msgid "Invalid time format." msgstr "Invalid time format." -#: app/validators.py:820 +#: app/validators.py:832 msgid "Start time is required. Please select a time slot." msgstr "Start time is required. Please select a time slot." -#: app/validators.py:834 +#: app/validators.py:846 msgid "Unknown match status." msgstr "Unknown match status." -#: app/validators.py:850 +#: app/validators.py:862 msgid "The end time must come after the start time." msgstr "The end time must come after the start time." -#: app/validators.py:864 +#: app/validators.py:876 msgid "Unknown match type." msgstr "Unknown match type." -#: app/validators.py:876 +#: app/validators.py:888 msgid "A team cannot play against itself." msgstr "A team cannot play against itself." -#: app/validators.py:929 +#: app/validators.py:941 msgid "Invalid start date format." msgstr "Invalid start date format." -#: app/validators.py:930 +#: app/validators.py:942 msgid "A start date is required." msgstr "A start date is required." -#: app/validators.py:936 +#: app/validators.py:948 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/validators.py:944 +#: app/validators.py:956 msgid "A tryout must allow at least one player." msgstr "A tryout must allow at least one player." -#: app/validators.py:947 +#: app/validators.py:959 msgid "The player limit must be a whole number." msgstr "The player limit must be a whole number." -#: app/validators.py:960 +#: app/validators.py:972 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/validators.py:987 app/validators.py:988 +#: app/validators.py:999 app/validators.py:1000 msgid "Team name is required." msgstr "Team name is required." -#: app/validators.py:1012 +#: app/validators.py:1024 msgid "Scores run from 1 to 10." msgstr "Scores run from 1 to 10." -#: app/validators.py:1013 +#: app/validators.py:1025 msgid "A score must be a whole number from 1 to 10." msgstr "A score must be a whole number from 1 to 10." @@ -344,40 +352,42 @@ msgstr "Discord account connected! Your profile has been pre-filled." msgid "You have been logged out." msgstr "You have been logged out." -#: app/routes/evaluations.py:53 +#: app/routes/evaluations.py:81 msgid "You do not have permission to view evaluations." msgstr "You do not have permission to view evaluations." -#: app/routes/evaluations.py:143 +#: app/routes/evaluations.py:171 msgid "You do not have permission to evaluate players." msgstr "You do not have permission to evaluate players." -#: app/routes/evaluations.py:148 app/routes/evaluations.py:234 +#: app/routes/evaluations.py:176 app/routes/evaluations.py:262 msgid "You do not have permission to evaluate players in this tryout." msgstr "You do not have permission to evaluate players in this tryout." -#: app/routes/evaluations.py:159 +#: app/routes/evaluations.py:187 msgid "Player is not registered for this tryout." msgstr "Player is not registered for this tryout." -#: app/routes/evaluations.py:164 +#: app/routes/evaluations.py:192 msgid "Can only evaluate players." msgstr "Can only evaluate players." -#: app/routes/evaluations.py:210 +#: app/routes/evaluations.py:238 msgid "Evaluation submitted successfully!" msgstr "Evaluation submitted successfully!" -#: app/routes/evaluations.py:212 +#: app/routes/evaluations.py:240 msgid "Evaluation updated!" msgstr "Evaluation updated!" -#: app/routes/evaluations.py:229 app/routes/teams.py:340 -#: app/routes/teams.py:383 app/routes/teams.py:426 app/routes/teams.py:454 -#: app/routes/teams.py:482 app/routes/teams.py:519 app/routes/tryouts.py:507 -#: app/routes/tryouts.py:531 app/routes/tryouts.py:559 -#: app/routes/tryouts.py:610 app/routes/tryouts.py:650 -#: app/routes/tryouts.py:677 +#: app/routes/evaluations.py:257 app/routes/teams.py:341 +#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 +#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:521 +#: app/routes/tryouts.py:548 app/routes/tryouts.py:579 +#: app/routes/tryouts.py:633 app/routes/tryouts.py:676 +#: app/routes/tryouts.py:706 app/routes/tryouts.py:756 +#: app/routes/tryouts.py:788 app/routes/tryouts.py:832 +#: app/routes/tryouts.py:870 msgid "Permission denied." msgstr "Permission denied." @@ -385,277 +395,295 @@ msgstr "Permission denied." msgid "That language is not available." msgstr "That language is not available." -#: app/routes/matches.py:383 +#: app/routes/matches.py:448 msgid "You do not have permission to schedule matches for this tryout." msgstr "You do not have permission to schedule matches for this tryout." -#: app/routes/matches.py:387 app/routes/matches.py:463 +#: app/routes/matches.py:452 app/routes/matches.py:528 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "This tryout has ended. Matches can no longer be created or modified." -#: app/routes/matches.py:445 +#: app/routes/matches.py:510 msgid "Match scheduled successfully!" msgstr "Match scheduled successfully!" -#: app/routes/matches.py:459 app/routes/team_matches.py:235 +#: app/routes/matches.py:524 app/routes/team_matches.py:238 msgid "You do not have permission to edit this match." msgstr "You do not have permission to edit this match." -#: app/routes/matches.py:552 app/routes/team_matches.py:265 +#: app/routes/matches.py:688 app/routes/team_matches.py:268 msgid "Match updated successfully!" msgstr "Match updated successfully!" -#: app/routes/matches.py:588 app/routes/team_matches.py:284 +#: app/routes/matches.py:724 app/routes/team_matches.py:289 msgid "You do not have permission to delete this match." msgstr "You do not have permission to delete this match." -#: app/routes/matches.py:591 +#: app/routes/matches.py:727 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "This tryout has ended. Matches can no longer be deleted." -#: app/routes/matches.py:604 app/routes/team_matches.py:289 +#: app/routes/matches.py:740 app/routes/team_matches.py:294 msgid "Match deleted successfully." msgstr "Match deleted successfully." -#: app/routes/team_matches.py:128 +#: app/routes/team_matches.py:131 msgid "You do not have permission to schedule matches for this team." msgstr "You do not have permission to schedule matches for this team." -#: app/routes/team_matches.py:220 +#: app/routes/team_matches.py:223 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Team match \"%(title)s\" scheduled successfully!" -#: app/routes/teams.py:43 +#: app/routes/teams.py:44 msgid "Use My Team(s) to view your teams." msgstr "Use My Team(s) to view your teams." -#: app/routes/teams.py:46 +#: app/routes/teams.py:47 msgid "You do not have permission to view teams." msgstr "You do not have permission to view teams." -#: app/routes/teams.py:78 +#: app/routes/teams.py:79 msgid "This page is for players." msgstr "This page is for players." -#: app/routes/teams.py:185 +#: app/routes/teams.py:186 msgid "You do not have permission to create teams." msgstr "You do not have permission to create teams." -#: app/routes/teams.py:196 app/routes/teams.py:238 +#: app/routes/teams.py:197 app/routes/teams.py:239 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "Team \"%(name)s\" already exists." -#: app/routes/teams.py:217 +#: app/routes/teams.py:218 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Team \"%(name)s\" created successfully!" -#: app/routes/teams.py:227 +#: app/routes/teams.py:228 msgid "You do not have permission to edit this team." msgstr "You do not have permission to edit this team." -#: app/routes/teams.py:271 +#: app/routes/teams.py:272 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Team \"%(name)s\" updated successfully!" -#: app/routes/teams.py:299 +#: app/routes/teams.py:300 msgid "You do not have permission to delete teams." msgstr "You do not have permission to delete teams." -#: app/routes/teams.py:330 +#: app/routes/teams.py:331 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Team \"%(name)s\" deleted successfully." -#: app/routes/teams.py:347 +#: app/routes/teams.py:348 msgid "Please select a coach." msgstr "Please select a coach." -#: app/routes/teams.py:352 +#: app/routes/teams.py:353 msgid "Only coaches can be assigned as coach." msgstr "Only coaches can be assigned as coach." -#: app/routes/teams.py:358 +#: app/routes/teams.py:359 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s is already a coach of %(name)s." -#: app/routes/teams.py:371 +#: app/routes/teams.py:372 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s added as coach of %(name)s." -#: app/routes/teams.py:390 +#: app/routes/teams.py:391 msgid "Please select a manager." msgstr "Please select a manager." -#: app/routes/teams.py:395 +#: app/routes/teams.py:396 msgid "Only managers can be assigned as manager." msgstr "Only managers can be assigned as manager." -#: app/routes/teams.py:401 +#: app/routes/teams.py:402 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s is already a manager of %(name)s." -#: app/routes/teams.py:414 +#: app/routes/teams.py:415 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s added as manager of %(name)s." -#: app/routes/teams.py:444 +#: app/routes/teams.py:445 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach removed from %(name)s." -#: app/routes/teams.py:472 +#: app/routes/teams.py:473 #, python-format msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:489 app/routes/tryouts.py:568 +#: app/routes/teams.py:490 app/routes/tryouts.py:588 msgid "Please select a player." msgstr "Please select a player." -#: app/routes/teams.py:495 +#: app/routes/teams.py:496 msgid "Can only assign players to teams." msgstr "Can only assign players to teams." -#: app/routes/teams.py:501 +#: app/routes/teams.py:502 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s is already on %(name)s." -#: app/routes/teams.py:509 +#: app/routes/teams.py:510 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s added to %(name)s!" -#: app/routes/teams.py:526 app/routes/teams.py:604 +#: app/routes/teams.py:527 app/routes/teams.py:605 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s is not on %(name)s." -#: app/routes/teams.py:534 +#: app/routes/teams.py:535 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s removed from %(name)s." -#: app/routes/teams.py:571 app/routes/teams.py:593 +#: app/routes/teams.py:572 app/routes/teams.py:594 msgid "You do not have permission to add notes to this team." msgstr "You do not have permission to add notes to this team." -#: app/routes/teams.py:583 +#: app/routes/teams.py:584 msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:598 app/routes/users/notes.py:222 -#: app/routes/users/notes.py:262 +#: app/routes/teams.py:599 app/routes/users/notes.py:223 +#: app/routes/users/notes.py:263 msgid "Can only add notes for players." msgstr "Can only add notes for players." -#: app/routes/teams.py:618 +#: app/routes/teams.py:619 #, python-format msgid "Note added for %(username)s!" msgstr "Note added for %(username)s!" -#: app/routes/tryouts.py:147 +#: app/routes/tryouts.py:152 msgid "You do not have permission to create tryouts." msgstr "You do not have permission to create tryouts." -#: app/routes/tryouts.py:194 +#: app/routes/tryouts.py:199 msgid "Tryout created successfully!" msgstr "Tryout created successfully!" -#: app/routes/tryouts.py:211 +#: app/routes/tryouts.py:219 msgid "You do not have permission to edit this tryout." msgstr "You do not have permission to edit this tryout." -#: app/routes/tryouts.py:215 +#: app/routes/tryouts.py:223 msgid "This tryout has ended and can no longer be modified." msgstr "This tryout has ended and can no longer be modified." -#: app/routes/tryouts.py:261 +#: app/routes/tryouts.py:269 msgid "Tryout updated successfully!" msgstr "Tryout updated successfully!" -#: app/routes/tryouts.py:301 +#: app/routes/tryouts.py:307 msgid "You do not have permission to view this tryout." msgstr "You do not have permission to view this tryout." -#: app/routes/tryouts.py:469 +#: app/routes/tryouts.py:480 msgid "Only players can register for tryouts." msgstr "Only players can register for tryouts." -#: app/routes/tryouts.py:474 +#: app/routes/tryouts.py:485 msgid "This tryout is not accepting registrations." msgstr "This tryout is not accepting registrations." -#: app/routes/tryouts.py:481 +#: app/routes/tryouts.py:492 msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:487 app/routes/tryouts.py:590 +#: app/routes/tryouts.py:498 app/routes/tryouts.py:610 msgid "This tryout is full." msgstr "This tryout is full." -#: app/routes/tryouts.py:493 +#: app/routes/tryouts.py:504 msgid "Successfully registered for tryout!" msgstr "Successfully registered for tryout!" -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:531 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Tryout status updated to %(new_status)s." -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:562 msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:576 +#: app/routes/tryouts.py:596 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:582 +#: app/routes/tryouts.py:602 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:596 +#: app/routes/tryouts.py:616 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:636 +#: app/routes/tryouts.py:659 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:662 +#: app/routes/tryouts.py:688 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:699 app/routes/users/notes.py:350 +#: app/routes/tryouts.py:728 app/routes/users/notes.py:351 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:704 +#: app/routes/tryouts.py:733 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:709 +#: app/routes/tryouts.py:738 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:723 +#: app/routes/tryouts.py:770 +#, python-format +msgid "Team renamed to \"%(team_name)s\"." +msgstr "Team renamed to \"%(team_name)s\"." + +#: app/routes/tryouts.py:814 +#, python-format +msgid "Team \"%(name)s\" deleted." +msgstr "Team \"%(name)s\" deleted." + +#: app/routes/tryouts.py:849 +msgid "Player removed from team." +msgstr "Player removed from team." + +#: app/routes/tryouts.py:884 +msgid "Position updated." +msgstr "Position updated." + +#: app/routes/tryouts.py:901 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:759 +#: app/routes/tryouts.py:937 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." @@ -723,190 +751,186 @@ msgstr "Only the president can create users." msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" -#: app/routes/users/availability.py:210 -msgid "Only coaches can manage availability." -msgstr "Only coaches can manage availability." - -#: app/routes/users/contracts.py:104 +#: app/routes/users/contracts.py:105 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users/contracts.py:108 app/routes/users/contracts.py:189 +#: app/routes/users/contracts.py:109 app/routes/users/contracts.py:190 msgid "Document uploads are currently disabled." msgstr "Document uploads are currently disabled." -#: app/routes/users/contracts.py:127 +#: app/routes/users/contracts.py:128 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users/contracts.py:155 app/routes/users/contracts.py:210 -#: app/routes/users/contracts.py:233 app/routes/users/contracts.py:252 +#: app/routes/users/contracts.py:156 app/routes/users/contracts.py:211 +#: app/routes/users/contracts.py:234 app/routes/users/contracts.py:253 msgid "Contract storage is temporarily unavailable. Please try again later." msgstr "Contract storage is temporarily unavailable. Please try again later." -#: app/routes/users/contracts.py:171 +#: app/routes/users/contracts.py:172 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users/contracts.py:185 +#: app/routes/users/contracts.py:186 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users/contracts.py:218 +#: app/routes/users/contracts.py:219 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users/contracts.py:228 app/routes/users/contracts.py:244 +#: app/routes/users/contracts.py:229 app/routes/users/contracts.py:245 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users/contracts.py:247 +#: app/routes/users/contracts.py:248 msgid "No signed contract available." msgstr "No signed contract available." -#: app/routes/users/notes.py:43 +#: app/routes/users/notes.py:44 msgid "This page is for players only." msgstr "This page is for players only." -#: app/routes/users/notes.py:80 +#: app/routes/users/notes.py:81 msgid "Only coaches can access the notes dashboard." msgstr "Only coaches can access the notes dashboard." -#: app/routes/users/notes.py:172 +#: app/routes/users/notes.py:173 msgid "Only coaches can manage team notes." msgstr "Only coaches can manage team notes." -#: app/routes/users/notes.py:178 +#: app/routes/users/notes.py:179 msgid "You are not assigned to a team." msgstr "You are not assigned to a team." -#: app/routes/users/notes.py:195 +#: app/routes/users/notes.py:196 msgid "Team notes saved successfully!" msgstr "Team notes saved successfully!" -#: app/routes/users/notes.py:210 +#: app/routes/users/notes.py:211 msgid "Only coaches can manage personal notes." msgstr "Only coaches can manage personal notes." -#: app/routes/users/notes.py:226 app/routes/users/notes.py:266 -#: app/routes/users/notes.py:346 app/routes/users/notes.py:411 +#: app/routes/users/notes.py:227 app/routes/users/notes.py:267 +#: app/routes/users/notes.py:347 app/routes/users/notes.py:412 msgid "You can only write notes about players you work with." msgstr "You can only write notes about players you work with." -#: app/routes/users/notes.py:236 app/routes/users/notes.py:306 +#: app/routes/users/notes.py:237 app/routes/users/notes.py:307 #, python-format msgid "Note added for %(username)s." msgstr "Note added for %(username)s." -#: app/routes/users/notes.py:250 app/routes/users/notes.py:320 -#: app/routes/users/notes.py:384 +#: app/routes/users/notes.py:251 app/routes/users/notes.py:321 +#: app/routes/users/notes.py:385 msgid "Only coaches can add personal notes." msgstr "Only coaches can add personal notes." -#: app/routes/users/notes.py:272 +#: app/routes/users/notes.py:273 msgid "You cannot use that match as note context." msgstr "You cannot use that match as note context." -#: app/routes/users/notes.py:275 +#: app/routes/users/notes.py:276 msgid "That player did not participate in the selected match." msgstr "That player did not participate in the selected match." -#: app/routes/users/notes.py:281 +#: app/routes/users/notes.py:282 msgid "You cannot use that tryout as note context." msgstr "You cannot use that tryout as note context." -#: app/routes/users/notes.py:284 +#: app/routes/users/notes.py:285 msgid "That player is not registered for the selected tryout." msgstr "That player is not registered for the selected tryout." -#: app/routes/users/notes.py:290 +#: app/routes/users/notes.py:291 msgid "You cannot use that team as note context." msgstr "You cannot use that team as note context." -#: app/routes/users/notes.py:293 +#: app/routes/users/notes.py:294 msgid "That player is not on the selected team." msgstr "That player is not on the selected team." -#: app/routes/users/notes.py:325 +#: app/routes/users/notes.py:326 msgid "You do not have permission to add notes for this tryout." msgstr "You do not have permission to add notes for this tryout." -#: app/routes/users/notes.py:342 +#: app/routes/users/notes.py:343 msgid "Invalid tryout context." msgstr "Invalid tryout context." -#: app/routes/users/notes.py:361 app/routes/users/notes.py:426 +#: app/routes/users/notes.py:362 app/routes/users/notes.py:427 msgid "Note added successfully." msgstr "Note added successfully." -#: app/routes/users/notes.py:389 +#: app/routes/users/notes.py:390 msgid "You do not have permission to add notes for this match." msgstr "You do not have permission to add notes for this match." -#: app/routes/users/notes.py:407 +#: app/routes/users/notes.py:408 msgid "Invalid match context." msgstr "Invalid match context." -#: app/routes/users/notes.py:415 +#: app/routes/users/notes.py:416 msgid "That player did not participate in this match." msgstr "That player did not participate in this match." -#: app/routes/users/one_on_one.py:22 +#: app/routes/users/one_on_one.py:23 msgid "Only players can request One on One sessions." msgstr "Only players can request One on One sessions." -#: app/routes/users/one_on_one.py:35 +#: app/routes/users/one_on_one.py:36 msgid "You do not have a coach assigned to your team." msgstr "You do not have a coach assigned to your team." -#: app/routes/users/one_on_one.py:66 +#: app/routes/users/one_on_one.py:67 msgid "Cannot request One on One - no coach assigned." msgstr "Cannot request One on One - no coach assigned." -#: app/routes/users/one_on_one.py:92 +#: app/routes/users/one_on_one.py:93 msgid "The requested time is not within the coach's availability." msgstr "The requested time is not within the coach's availability." -#: app/routes/users/one_on_one.py:120 +#: app/routes/users/one_on_one.py:121 msgid "Your One on One request has been submitted!" msgstr "Your One on One request has been submitted!" -#: app/routes/users/one_on_one.py:165 +#: app/routes/users/one_on_one.py:166 msgid "Only coaches can accept One on One requests." msgstr "Only coaches can accept One on One requests." -#: app/routes/users/one_on_one.py:171 app/routes/users/one_on_one.py:221 +#: app/routes/users/one_on_one.py:172 app/routes/users/one_on_one.py:222 msgid "This request is not for you." msgstr "This request is not for you." -#: app/routes/users/one_on_one.py:175 app/routes/users/one_on_one.py:225 +#: app/routes/users/one_on_one.py:176 app/routes/users/one_on_one.py:226 msgid "This request has already been processed." msgstr "This request has already been processed." -#: app/routes/users/one_on_one.py:202 +#: app/routes/users/one_on_one.py:203 #, python-format msgid "One on One request from %(player)s has been approved!" msgstr "One on One request from %(player)s has been approved!" -#: app/routes/users/one_on_one.py:215 +#: app/routes/users/one_on_one.py:216 msgid "Only coaches can reject One on One requests." msgstr "Only coaches can reject One on One requests." -#: app/routes/users/one_on_one.py:262 +#: app/routes/users/one_on_one.py:263 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "One on One request from %(player)s has been rejected." -#: app/routes/users/profile.py:82 +#: app/routes/users/profile.py:83 msgid "Username already taken." msgstr "Username already taken." -#: app/routes/users/profile.py:92 +#: app/routes/users/profile.py:93 msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users/profile.py:131 +#: app/routes/users/profile.py:132 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" @@ -1142,7 +1166,7 @@ msgstr "Add Personal Note" #: app/templates/pages/notes.html:122 #: app/templates/pages/team_match_form.html:114 #: app/templates/pages/teams.html:145 app/templates/pages/view_tryout.html:154 -#: app/templates/pages/view_tryout.html:498 +#: app/templates/pages/view_tryout.html:575 msgid "Player" msgstr "Player" @@ -1193,11 +1217,11 @@ msgstr "Team Notes (Reference)" #: app/templates/pages/evaluate_player.html:131 #: app/templates/pages/notes.html:100 #: app/templates/pages/personal_notes.html:36 -#: app/templates/pages/view_tryout.html:309 +#: app/templates/pages/view_tryout.html:380 msgid "Add Note" msgstr "Add Note" -#: app/templates/pages/calendar.html:9 app/templates/pages/view_tryout.html:302 +#: app/templates/pages/calendar.html:9 app/templates/pages/view_tryout.html:373 msgid "Schedule" msgstr "Schedule" @@ -1275,6 +1299,7 @@ msgstr "Confirm" #: app/templates/pages/calendar.html:102 #: app/templates/pages/team_matches.html:103 +#: app/templates/pages/view_tryout.html:534 msgid "Delete Match" msgstr "Delete Match" @@ -1287,33 +1312,6 @@ msgstr "Edit Match" msgid "View Tryout" msgstr "View Tryout" -#: app/templates/pages/coach_availability.html:2 -#: app/templates/pages/coach_availability.html:3 -msgid "Manage Availability" -msgstr "Manage Availability" - -#: app/templates/pages/coach_availability.html:9 -msgid "Set Your Weekly Availability" -msgstr "Set Your Weekly Availability" - -#: app/templates/pages/coach_availability.html:10 -msgid "Select time slots when you're available for One on One sessions" -msgstr "Select time slots when you're available for One on One sessions" - -#: app/templates/pages/coach_availability.html:14 -#: app/templates/pages/profile.html:256 -msgid "Loading availability grid..." -msgstr "Loading availability grid..." - -#: app/templates/pages/coach_availability.html:19 -msgid "Save Availability" -msgstr "Save Availability" - -#: app/templates/pages/coach_availability.html:22 -#: app/templates/pages/profile.html:240 app/templates/pages/profile.html:260 -msgid "Clear All" -msgstr "Clear All" - #: app/templates/pages/contracts.html:9 #: app/templates/pages/upload_contract.html:2 #: app/templates/pages/upload_contract.html:3 @@ -1344,7 +1342,7 @@ msgstr "Contract" #: app/templates/pages/team_match_form.html:61 #: app/templates/pages/team_matches.html:33 app/templates/pages/teams.html:146 #: app/templates/pages/users.html:25 app/templates/pages/view_tryout.html:155 -#: app/templates/pages/view_tryout.html:327 +#: app/templates/pages/view_tryout.html:398 msgid "Status" msgstr "Status" @@ -1356,7 +1354,7 @@ msgstr "Uploaded" #: app/templates/pages/notes.html:127 app/templates/pages/team_matches.html:34 #: app/templates/pages/teams.html:151 app/templates/pages/users.html:27 #: app/templates/pages/view_tryout.html:160 -#: app/templates/pages/view_tryout.html:329 +#: app/templates/pages/view_tryout.html:400 msgid "Actions" msgstr "Actions" @@ -1408,7 +1406,7 @@ msgstr "Select Signed Contract File" #: app/templates/pages/contracts.html:98 #: app/templates/pages/upload_contract.html:28 msgid "Accepted format: PDF" -msgstr "Accepted formats: PDF, DOC, DOCX, JPG, PNG" +msgstr "Accepted format: PDF" #: app/templates/pages/contracts.html:101 #: app/templates/pages/match_form.html:265 app/templates/pages/notes.html:189 @@ -1527,7 +1525,7 @@ msgstr "Title" #: app/templates/pages/notes.html:123 app/templates/pages/one_on_one.html:70 #: app/templates/pages/team_match_form.html:34 #: app/templates/pages/team_matches.html:29 -#: app/templates/pages/view_tryout.html:323 +#: app/templates/pages/view_tryout.html:394 msgid "Date" msgstr "Date" @@ -1540,7 +1538,7 @@ msgstr "Upcoming Matches" #: app/templates/pages/dashboard.html:202 #: app/templates/pages/dashboard.html:285 app/templates/pages/my_teams.html:90 #: app/templates/pages/notes.html:69 app/templates/pages/team_matches.html:26 -#: app/templates/pages/teams.html:132 app/templates/pages/view_tryout.html:321 +#: app/templates/pages/teams.html:132 app/templates/pages/view_tryout.html:392 msgid "Match" msgstr "Match" @@ -1623,7 +1621,7 @@ msgid "Avg Score" msgstr "Avg Score" #: app/templates/pages/dashboard.html:373 -#: app/templates/pages/view_tryout.html:532 +#: app/templates/pages/view_tryout.html:609 msgid "No evaluations yet." msgstr "No evaluations yet." @@ -1817,7 +1815,7 @@ msgid "Recommended Position" msgstr "Recommended Position" #: app/templates/pages/evaluate_player.html:109 -#: app/templates/pages/view_tryout.html:280 +#: app/templates/pages/view_tryout.html:339 msgid "-- Select Position --" msgstr "-- Select Position --" @@ -1838,63 +1836,63 @@ msgid "Add note for this player" msgstr "Add note for this player" #: app/templates/pages/evaluate_player.html:147 -#: app/templates/pages/view_tryout.html:499 +#: app/templates/pages/view_tryout.html:576 msgid "Evaluator" msgstr "Evaluator" #: app/templates/pages/evaluate_player.html:148 -#: app/templates/pages/view_tryout.html:500 +#: app/templates/pages/view_tryout.html:577 msgid "Mecanics" msgstr "Mecanics" #: app/templates/pages/evaluate_player.html:149 -#: app/templates/pages/view_tryout.html:501 +#: app/templates/pages/view_tryout.html:578 msgid "Cohesion" msgstr "Cohesion" #: app/templates/pages/evaluate_player.html:150 -#: app/templates/pages/view_tryout.html:502 +#: app/templates/pages/view_tryout.html:579 msgid "Communication" msgstr "Communication" #: app/templates/pages/evaluate_player.html:151 -#: app/templates/pages/view_tryout.html:503 +#: app/templates/pages/view_tryout.html:580 msgid "Gamesense" msgstr "Gamesense" #: app/templates/pages/evaluate_player.html:152 -#: app/templates/pages/view_tryout.html:504 +#: app/templates/pages/view_tryout.html:581 msgid "Versatility" msgstr "Versatility" #: app/templates/pages/evaluate_player.html:153 -#: app/templates/pages/view_tryout.html:505 +#: app/templates/pages/view_tryout.html:582 msgid "Discipline" msgstr "Discipline" #: app/templates/pages/evaluate_player.html:154 -#: app/templates/pages/view_tryout.html:506 +#: app/templates/pages/view_tryout.html:583 msgid "Analysis" msgstr "Analysis" #: app/templates/pages/evaluate_player.html:155 -#: app/templates/pages/view_tryout.html:507 +#: app/templates/pages/view_tryout.html:584 msgid "Sport Ethics" msgstr "Sport Ethics" #: app/templates/pages/evaluate_player.html:156 -#: app/templates/pages/view_tryout.html:508 +#: app/templates/pages/view_tryout.html:585 msgid "Mental" msgstr "Mental" #: app/templates/pages/evaluate_player.html:157 -#: app/templates/pages/view_tryout.html:509 +#: app/templates/pages/view_tryout.html:586 msgid "Overall" msgstr "Overall" #: app/templates/pages/evaluate_player.html:158 #: app/templates/pages/my_teams.html:54 -#: app/templates/pages/view_tryout.html:286 +#: app/templates/pages/view_tryout.html:345 msgid "Position" msgstr "Position" @@ -1998,7 +1996,7 @@ msgstr "Reset time selection" msgid "Reset Time" msgstr "Reset Time" -#: app/templates/pages/match_form.html:86 app/templates/pages/profile.html:236 +#: app/templates/pages/match_form.html:86 app/templates/pages/profile.html:193 msgid "Loading..." msgstr "Loading..." @@ -2125,14 +2123,14 @@ msgstr "No players on this team." #: app/templates/pages/my_teams.html:93 app/templates/pages/notes.html:124 #: app/templates/pages/one_on_one.html:71 #: app/templates/pages/team_matches.html:30 -#: app/templates/pages/view_tryout.html:325 +#: app/templates/pages/view_tryout.html:396 msgid "Time" msgstr "Time" #: app/templates/pages/my_teams.html:95 #: app/templates/pages/team_match_form.html:115 #: app/templates/pages/team_matches.html:32 -#: app/templates/pages/view_tryout.html:326 +#: app/templates/pages/view_tryout.html:397 msgid "Presence" msgstr "Presence" @@ -2419,23 +2417,23 @@ msgstr "Evaluations Given" msgid "No statistics available for this role." msgstr "No statistics available for this role." -#: app/templates/pages/profile.html:195 +#: app/templates/pages/profile.html:152 msgid "My Contracts" msgstr "My Contracts" -#: app/templates/pages/profile.html:217 +#: app/templates/pages/profile.html:174 msgid "View All Contracts" msgstr "View All Contracts" -#: app/templates/pages/profile.html:221 +#: app/templates/pages/profile.html:178 msgid "No contracts have been uploaded for you yet." msgstr "No contracts have been uploaded for you yet." -#: app/templates/pages/profile.html:231 +#: app/templates/pages/profile.html:188 msgid "My Disponibilities" msgstr "My Disponibilities" -#: app/templates/pages/profile.html:234 +#: app/templates/pages/profile.html:191 msgid "" "Select your available time blocks for matches (5pm to 12am). Green = " "selected, Gray = available to select." @@ -2443,11 +2441,15 @@ msgstr "" "Select your available time blocks for matches (5pm to 12am). Green = " "selected, Gray = available to select." -#: app/templates/pages/profile.html:251 +#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217 +msgid "Clear All" +msgstr "Clear All" + +#: app/templates/pages/profile.html:208 msgid "My Coaching Availability" msgstr "My Coaching Availability" -#: app/templates/pages/profile.html:252 +#: app/templates/pages/profile.html:209 msgid "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." @@ -2455,7 +2457,11 @@ msgstr "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." -#: app/templates/pages/profile.html:500 +#: app/templates/pages/profile.html:213 +msgid "Loading availability grid..." +msgstr "Loading availability grid..." + +#: app/templates/pages/profile.html:464 msgid "Click or click-and-drag to select your available hours" msgstr "Click or click-and-drag to select your available hours" @@ -2592,7 +2598,7 @@ msgid "No players on this team. Add players in the Teams page first." msgstr "No players on this team. Add players in the Teams page first." #: app/templates/pages/team_match_form.html:107 -#: app/templates/pages/view_tryout.html:324 +#: app/templates/pages/view_tryout.html:395 msgid "Participants" msgstr "Participants" @@ -2610,6 +2616,7 @@ msgid "+ Schedule Match" msgstr "+ Schedule Match" #: app/templates/pages/team_matches.html:101 +#: app/templates/pages/view_tryout.html:532 msgid "Delete this match?" msgstr "Delete this match?" @@ -2680,7 +2687,8 @@ msgstr "Create Team" #: app/templates/pages/teams.html:64 app/templates/pages/users.html:59 #: app/templates/pages/view_tryout.html:29 -#: app/templates/pages/view_tryout.html:459 +#: app/templates/pages/view_tryout.html:256 +#: app/templates/pages/view_tryout.html:530 msgid "Edit" msgstr "Edit" @@ -2689,7 +2697,7 @@ msgstr "Edit" msgid "Delete team %(name)s? It will be unassigned from any linked tryouts." msgstr "Delete team %(name)s? It will be unassigned from any linked tryouts." -#: app/templates/pages/teams.html:69 +#: app/templates/pages/teams.html:69 app/templates/pages/view_tryout.html:535 msgid "Delete" msgstr "Delete" @@ -2907,7 +2915,7 @@ msgid "Tryout Details" msgstr "Tryout Details" #: app/templates/pages/view_tryout.html:14 -#: app/templates/pages/view_tryout.html:306 +#: app/templates/pages/view_tryout.html:377 msgid "Schedule Match" msgstr "Schedule Match" @@ -2977,6 +2985,7 @@ msgid "No players registered yet." msgstr "No players registered yet." #: app/templates/pages/view_tryout.html:243 +#: app/templates/pages/view_tryout.html:289 msgid "Team name" msgstr "Team name" @@ -2984,47 +2993,90 @@ msgstr "Team name" msgid "Create" msgstr "Create" -#: app/templates/pages/view_tryout.html:263 +#: app/templates/pages/view_tryout.html:270 +#: app/templates/pages/view_tryout.html:320 msgid "No players assigned yet." msgstr "No players assigned yet." -#: app/templates/pages/view_tryout.html:271 +#: app/templates/pages/view_tryout.html:282 +#, python-format +msgid "Edit Team: %(name)s" +msgstr "Edit Team: %(name)s" + +#: app/templates/pages/view_tryout.html:291 +msgid "Rename" +msgstr "Rename" + +#: app/templates/pages/view_tryout.html:297 +msgid "Roster" +msgstr "Roster" + +#: app/templates/pages/view_tryout.html:306 +msgid "No role" +msgstr "No role" + +#: app/templates/pages/view_tryout.html:312 +#, python-format +msgid "Remove %(player)s from this team?" +msgstr "Remove %(player)s from this team?" + +#: app/templates/pages/view_tryout.html:314 +msgid "Remove from team" +msgstr "Remove from team" + +#: app/templates/pages/view_tryout.html:326 +msgid "Add Player" +msgstr "Add Player" + +#: app/templates/pages/view_tryout.html:330 msgid "Select player..." msgstr "Select player..." -#: app/templates/pages/view_tryout.html:288 +#: app/templates/pages/view_tryout.html:347 msgid "Add" msgstr "Add" -#: app/templates/pages/view_tryout.html:293 +#: app/templates/pages/view_tryout.html:353 +msgid "" +"Delete this team? Players already in the team will be removed and matches" +" referencing it will be unlinked." +msgstr "" +"Delete this team? Players already in the team will be removed and matches" +" referencing it will be unlinked." + +#: app/templates/pages/view_tryout.html:355 +msgid "Delete Team" +msgstr "Delete Team" + +#: app/templates/pages/view_tryout.html:364 msgid "No teams created yet." msgstr "No teams created yet." -#: app/templates/pages/view_tryout.html:322 +#: app/templates/pages/view_tryout.html:393 msgid "Type" msgstr "Type" -#: app/templates/pages/view_tryout.html:387 +#: app/templates/pages/view_tryout.html:458 msgid "Toggle your attendance" msgstr "Toggle your attendance" -#: app/templates/pages/view_tryout.html:462 +#: app/templates/pages/view_tryout.html:539 msgid "Add Note for this Match" msgstr "Add Note for this Match" -#: app/templates/pages/view_tryout.html:463 +#: app/templates/pages/view_tryout.html:540 msgid "Note" msgstr "Note" -#: app/templates/pages/view_tryout.html:481 +#: app/templates/pages/view_tryout.html:558 msgid "No matches scheduled yet. Check back later!" msgstr "No matches scheduled yet. Check back later!" -#: app/templates/pages/view_tryout.html:491 +#: app/templates/pages/view_tryout.html:568 msgid "Evaluation Summary" msgstr "Evaluation Summary" -#: app/templates/pages/view_tryout.html:510 +#: app/templates/pages/view_tryout.html:587 msgid "Recommendation" msgstr "Recommendation" @@ -3100,3 +3152,18 @@ msgstr "Availability" #~ msgid "No players registered for this tryout." #~ msgstr "No players registered for this tryout." +#~ msgid "Only coaches can manage availability." +#~ msgstr "Only coaches can manage availability." + +#~ msgid "Manage Availability" +#~ msgstr "Manage Availability" + +#~ msgid "Set Your Weekly Availability" +#~ msgstr "Set Your Weekly Availability" + +#~ msgid "Select time slots when you're available for One on One sessions" +#~ msgstr "Select time slots when you're available for One on One sessions" + +#~ msgid "Save Availability" +#~ msgstr "Save Availability" + diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index 5e241b7..7ff09a7 100644 Binary files a/app/translations/fr/LC_MESSAGES/messages.mo and b/app/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 27f061b..7327154 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-26 22:04-0400\n" +"POT-Creation-Date: 2026-09-08 14:29-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -19,12 +19,18 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app/app.py:564 +#: app/app.py:379 +msgid "" +"This page was changed by someone else. Reload to see the latest changes " +"before saving." +msgstr "This page changefix" + +#: app/app.py:614 msgid "Please log in to access this page." msgstr "Veuillez vous connecter pour accéder à cette page." #: app/forms.py:37 app/routes/auth.py:230 app/routes/auth.py:389 -#: app/routes/auth.py:403 app/routes/users/contracts.py:120 +#: app/routes/auth.py:403 app/routes/users/contracts.py:121 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" @@ -80,7 +86,7 @@ msgstr "Le nom complet est obligatoire." msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:284 app/validators.py:924 +#: app/validators.py:284 app/validators.py:936 msgid "Unknown game." msgstr "Jeu inconnu." @@ -100,7 +106,7 @@ msgstr "Plateforme inconnue pour ce jeu." msgid "Invalid role selected." msgstr "Rôle sélectionné invalide." -#: app/validators.py:473 app/validators.py:596 app/validators.py:629 +#: app/validators.py:473 app/validators.py:596 app/validators.py:641 msgid "Player must be selected." msgstr "Vous devez choisir un joueur." @@ -108,15 +114,15 @@ msgstr "Vous devez choisir un joueur." msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:513 app/validators.py:994 +#: app/validators.py:513 app/validators.py:1006 msgid "Invalid coach selection." msgstr "Sélection de coach invalide." -#: app/validators.py:519 app/validators.py:1000 +#: app/validators.py:519 app/validators.py:1012 msgid "Invalid manager selection." msgstr "Sélection de gérant invalide." -#: app/validators.py:537 app/validators.py:595 app/validators.py:628 +#: app/validators.py:537 app/validators.py:595 app/validators.py:640 msgid "Invalid player selection." msgstr "Sélection de joueur invalide." @@ -136,131 +142,131 @@ msgstr "Statut d’inscription inconnu." msgid "Team name must be between 1 and 100 characters." msgstr "Le nom de l’équipe doit compter de 1 à 100 caractères." -#: app/validators.py:603 +#: app/validators.py:603 app/validators.py:615 msgid "Position must be 50 characters or less." msgstr "La position ne doit pas dépasser 50 caractères." -#: app/validators.py:616 +#: app/validators.py:628 msgid "Note content must be between 1 and 5000 characters." msgstr "La note doit compter de 1 à 5000 caractères." -#: app/validators.py:642 +#: app/validators.py:654 msgid "Select at most one note context." msgstr "Sélectionnez au plus un contexte pour la note." -#: app/validators.py:654 +#: app/validators.py:666 msgid "Rejection reason must be 2000 characters or less." msgstr "Le motif de refus ne doit pas dépasser 2000 caractères." -#: app/validators.py:678 +#: app/validators.py:690 msgid "Date must be in YYYY-MM-DD format." msgstr "La date doit être au format AAAA-MM-JJ." -#: app/validators.py:679 +#: app/validators.py:691 msgid "A date is required." msgstr "Une date est requise." -#: app/validators.py:685 app/validators.py:750 +#: app/validators.py:697 app/validators.py:762 msgid "Start time must be in HH:MM format." msgstr "L’heure de début doit être au format HH:MM." -#: app/validators.py:686 app/validators.py:751 +#: app/validators.py:698 app/validators.py:763 msgid "A start time is required." msgstr "Une heure de début est requise." -#: app/validators.py:692 +#: app/validators.py:704 msgid "End time must be in HH:MM format." msgstr "L’heure de fin doit être au format HH:MM." -#: app/validators.py:693 +#: app/validators.py:705 msgid "An end time is required." msgstr "Une heure de fin est requise." -#: app/validators.py:697 +#: app/validators.py:709 msgid "Points must be 2000 characters or less." msgstr "Les points ne doivent pas dépasser 2000 caractères." -#: app/validators.py:712 +#: app/validators.py:724 msgid "End time must be after start time." msgstr "L'heure de fin doit être postérieure à l'heure de début." -#: app/validators.py:741 app/validators.py:743 +#: app/validators.py:753 app/validators.py:755 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/validators.py:744 +#: app/validators.py:756 msgid "A day is required." msgstr "Un jour est requis." -#: app/validators.py:779 +#: app/validators.py:791 msgid "Player selection is malformed." msgstr "La sélection de joueurs est mal formée." -#: app/validators.py:805 app/validators.py:915 +#: app/validators.py:817 app/validators.py:927 msgid "A title is required." msgstr "Un titre est requis." -#: app/validators.py:814 +#: app/validators.py:826 msgid "Invalid date format." msgstr "Format de date invalide." -#: app/validators.py:819 app/validators.py:826 +#: app/validators.py:831 app/validators.py:838 msgid "Invalid time format." msgstr "Format d’heure invalide." -#: app/validators.py:820 +#: app/validators.py:832 msgid "Start time is required. Please select a time slot." msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." -#: app/validators.py:834 +#: app/validators.py:846 msgid "Unknown match status." msgstr "Statut de match inconnu." -#: app/validators.py:850 +#: app/validators.py:862 msgid "The end time must come after the start time." msgstr "L'heure de fin doit être postérieure à l'heure de début." -#: app/validators.py:864 +#: app/validators.py:876 msgid "Unknown match type." msgstr "Type de match inconnu." -#: app/validators.py:876 +#: app/validators.py:888 msgid "A team cannot play against itself." msgstr "Une équipe ne peut pas jouer contre elle-même." -#: app/validators.py:929 +#: app/validators.py:941 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/validators.py:930 +#: app/validators.py:942 msgid "A start date is required." msgstr "Une date de début est requise." -#: app/validators.py:936 +#: app/validators.py:948 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/validators.py:944 +#: app/validators.py:956 msgid "A tryout must allow at least one player." msgstr "Une sélection doit accepter au moins un joueur." -#: app/validators.py:947 +#: app/validators.py:959 msgid "The player limit must be a whole number." msgstr "La limite de joueurs doit être un nombre entier." -#: app/validators.py:960 +#: app/validators.py:972 msgid "End date cannot be before start date." msgstr "La date de fin ne peut pas précéder la date de début." -#: app/validators.py:987 app/validators.py:988 +#: app/validators.py:999 app/validators.py:1000 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/validators.py:1012 +#: app/validators.py:1024 msgid "Scores run from 1 to 10." msgstr "Les notes vont de 1 à 10." -#: app/validators.py:1013 +#: app/validators.py:1025 msgid "A score must be a whole number from 1 to 10." msgstr "Une note doit être un nombre entier de 1 à 10." @@ -346,40 +352,42 @@ msgstr "Compte Discord connecté. Votre profil a été pré-rempli." msgid "You have been logged out." msgstr "Vous avez été déconnecté." -#: app/routes/evaluations.py:53 +#: app/routes/evaluations.py:81 msgid "You do not have permission to view evaluations." msgstr "Vous n’avez pas les droits pour consulter les évaluations." -#: app/routes/evaluations.py:143 +#: app/routes/evaluations.py:171 msgid "You do not have permission to evaluate players." msgstr "Vous n’avez pas les droits pour évaluer des joueurs." -#: app/routes/evaluations.py:148 app/routes/evaluations.py:234 +#: app/routes/evaluations.py:176 app/routes/evaluations.py:262 msgid "You do not have permission to evaluate players in this tryout." msgstr "Vous n’avez pas les droits pour évaluer des joueurs dans cette sélection." -#: app/routes/evaluations.py:159 +#: app/routes/evaluations.py:187 msgid "Player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/evaluations.py:164 +#: app/routes/evaluations.py:192 msgid "Can only evaluate players." msgstr "Seuls des joueurs peuvent être évalués." -#: app/routes/evaluations.py:210 +#: app/routes/evaluations.py:238 msgid "Evaluation submitted successfully!" msgstr "Évaluation enregistrée." -#: app/routes/evaluations.py:212 +#: app/routes/evaluations.py:240 msgid "Evaluation updated!" msgstr "Évaluation mise à jour." -#: app/routes/evaluations.py:229 app/routes/teams.py:340 -#: app/routes/teams.py:383 app/routes/teams.py:426 app/routes/teams.py:454 -#: app/routes/teams.py:482 app/routes/teams.py:519 app/routes/tryouts.py:507 -#: app/routes/tryouts.py:531 app/routes/tryouts.py:559 -#: app/routes/tryouts.py:610 app/routes/tryouts.py:650 -#: app/routes/tryouts.py:677 +#: app/routes/evaluations.py:257 app/routes/teams.py:341 +#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 +#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:521 +#: app/routes/tryouts.py:548 app/routes/tryouts.py:579 +#: app/routes/tryouts.py:633 app/routes/tryouts.py:676 +#: app/routes/tryouts.py:706 app/routes/tryouts.py:756 +#: app/routes/tryouts.py:788 app/routes/tryouts.py:832 +#: app/routes/tryouts.py:870 msgid "Permission denied." msgstr "Accès refusé." @@ -387,279 +395,297 @@ msgstr "Accès refusé." msgid "That language is not available." msgstr "Cette langue n’est pas disponible." -#: app/routes/matches.py:383 +#: app/routes/matches.py:448 msgid "You do not have permission to schedule matches for this tryout." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection." -#: app/routes/matches.py:387 app/routes/matches.py:463 +#: app/routes/matches.py:452 app/routes/matches.py:528 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "" "Cette sélection est terminée. Les matchs ne peuvent plus être créés ni " "modifiés." -#: app/routes/matches.py:445 +#: app/routes/matches.py:510 msgid "Match scheduled successfully!" msgstr "Match planifié." -#: app/routes/matches.py:459 app/routes/team_matches.py:235 +#: app/routes/matches.py:524 app/routes/team_matches.py:238 msgid "You do not have permission to edit this match." msgstr "Vous n’avez pas les droits pour modifier ce match." -#: app/routes/matches.py:552 app/routes/team_matches.py:265 +#: app/routes/matches.py:688 app/routes/team_matches.py:268 msgid "Match updated successfully!" msgstr "Match mis à jour." -#: app/routes/matches.py:588 app/routes/team_matches.py:284 +#: app/routes/matches.py:724 app/routes/team_matches.py:289 msgid "You do not have permission to delete this match." msgstr "Vous n’avez pas les droits pour supprimer ce match." -#: app/routes/matches.py:591 +#: app/routes/matches.py:727 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés." -#: app/routes/matches.py:604 app/routes/team_matches.py:289 +#: app/routes/matches.py:740 app/routes/team_matches.py:294 msgid "Match deleted successfully." msgstr "Match supprimé." -#: app/routes/team_matches.py:128 +#: app/routes/team_matches.py:131 msgid "You do not have permission to schedule matches for this team." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette équipe." -#: app/routes/team_matches.py:220 +#: app/routes/team_matches.py:223 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Match d’équipe « %(title)s » planifié." -#: app/routes/teams.py:43 +#: app/routes/teams.py:44 msgid "Use My Team(s) to view your teams." msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes." -#: app/routes/teams.py:46 +#: app/routes/teams.py:47 msgid "You do not have permission to view teams." msgstr "Vous n’avez pas les droits pour consulter les équipes." -#: app/routes/teams.py:78 +#: app/routes/teams.py:79 msgid "This page is for players." msgstr "Cette page est réservée aux joueurs." -#: app/routes/teams.py:185 +#: app/routes/teams.py:186 msgid "You do not have permission to create teams." msgstr "Vous n’avez pas les droits pour créer une équipe." -#: app/routes/teams.py:196 app/routes/teams.py:238 +#: app/routes/teams.py:197 app/routes/teams.py:239 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "L’équipe « %(name)s » existe déjà." -#: app/routes/teams.py:217 +#: app/routes/teams.py:218 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Équipe « %(name)s » créée." -#: app/routes/teams.py:227 +#: app/routes/teams.py:228 msgid "You do not have permission to edit this team." msgstr "Vous n’avez pas les droits pour modifier cette équipe." -#: app/routes/teams.py:271 +#: app/routes/teams.py:272 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Équipe « %(name)s » mise à jour." -#: app/routes/teams.py:299 +#: app/routes/teams.py:300 msgid "You do not have permission to delete teams." msgstr "Vous n’avez pas les droits pour supprimer une équipe." -#: app/routes/teams.py:330 +#: app/routes/teams.py:331 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Équipe « %(name)s » supprimée." -#: app/routes/teams.py:347 +#: app/routes/teams.py:348 msgid "Please select a coach." msgstr "Veuillez choisir un coach." -#: app/routes/teams.py:352 +#: app/routes/teams.py:353 msgid "Only coaches can be assigned as coach." msgstr "Seuls les coachs peuvent être assignés comme coach." -#: app/routes/teams.py:358 +#: app/routes/teams.py:359 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s est déjà coach de %(name)s." -#: app/routes/teams.py:371 +#: app/routes/teams.py:372 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s a été ajouté comme coach de %(name)s." -#: app/routes/teams.py:390 +#: app/routes/teams.py:391 msgid "Please select a manager." msgstr "Veuillez choisir un gérant." -#: app/routes/teams.py:395 +#: app/routes/teams.py:396 msgid "Only managers can be assigned as manager." msgstr "Seuls les gérants peuvent être assignés comme gérant." -#: app/routes/teams.py:401 +#: app/routes/teams.py:402 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s est déjà gérant de %(name)s." -#: app/routes/teams.py:414 +#: app/routes/teams.py:415 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s a été ajouté comme gérant de %(name)s." -#: app/routes/teams.py:444 +#: app/routes/teams.py:445 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach retiré de %(name)s." -#: app/routes/teams.py:472 +#: app/routes/teams.py:473 #, python-format msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:489 app/routes/tryouts.py:568 +#: app/routes/teams.py:490 app/routes/tryouts.py:588 msgid "Please select a player." msgstr "Veuillez choisir un joueur." -#: app/routes/teams.py:495 +#: app/routes/teams.py:496 msgid "Can only assign players to teams." msgstr "Seuls des joueurs peuvent être assignés à une équipe." -#: app/routes/teams.py:501 +#: app/routes/teams.py:502 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s fait déjà partie de %(name)s." -#: app/routes/teams.py:509 +#: app/routes/teams.py:510 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s a été ajouté à %(name)s." -#: app/routes/teams.py:526 app/routes/teams.py:604 +#: app/routes/teams.py:527 app/routes/teams.py:605 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s ne fait pas partie de %(name)s." -#: app/routes/teams.py:534 +#: app/routes/teams.py:535 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s a été retiré de %(name)s." -#: app/routes/teams.py:571 app/routes/teams.py:593 +#: app/routes/teams.py:572 app/routes/teams.py:594 msgid "You do not have permission to add notes to this team." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." -#: app/routes/teams.py:583 +#: app/routes/teams.py:584 msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:598 app/routes/users/notes.py:222 -#: app/routes/users/notes.py:262 +#: app/routes/teams.py:599 app/routes/users/notes.py:223 +#: app/routes/users/notes.py:263 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." -#: app/routes/teams.py:618 +#: app/routes/teams.py:619 #, python-format msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." -#: app/routes/tryouts.py:147 +#: app/routes/tryouts.py:152 msgid "You do not have permission to create tryouts." msgstr "Vous n’avez pas les droits pour créer une sélection." -#: app/routes/tryouts.py:194 +#: app/routes/tryouts.py:199 msgid "Tryout created successfully!" msgstr "Sélection créée." -#: app/routes/tryouts.py:211 +#: app/routes/tryouts.py:219 msgid "You do not have permission to edit this tryout." msgstr "Vous n’avez pas les droits pour modifier cette sélection." -#: app/routes/tryouts.py:215 +#: app/routes/tryouts.py:223 msgid "This tryout has ended and can no longer be modified." msgstr "Cette sélection est terminée et ne peut plus être modifiée." -#: app/routes/tryouts.py:261 +#: app/routes/tryouts.py:269 msgid "Tryout updated successfully!" msgstr "Sélection mise à jour." -#: app/routes/tryouts.py:301 +#: app/routes/tryouts.py:307 msgid "You do not have permission to view this tryout." msgstr "Vous n’avez pas les droits pour consulter cette sélection." -#: app/routes/tryouts.py:469 +#: app/routes/tryouts.py:480 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:474 +#: app/routes/tryouts.py:485 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:481 +#: app/routes/tryouts.py:492 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:487 app/routes/tryouts.py:590 +#: app/routes/tryouts.py:498 app/routes/tryouts.py:610 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:493 +#: app/routes/tryouts.py:504 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:531 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Statut de la sélection mis à jour : %(new_status)s." -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:562 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:576 +#: app/routes/tryouts.py:596 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:582 +#: app/routes/tryouts.py:602 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s est déjà inscrit à cette sélection." -#: app/routes/tryouts.py:596 +#: app/routes/tryouts.py:616 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:636 +#: app/routes/tryouts.py:659 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:662 +#: app/routes/tryouts.py:688 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:699 app/routes/users/notes.py:350 +#: app/routes/tryouts.py:728 app/routes/users/notes.py:351 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:704 +#: app/routes/tryouts.py:733 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:709 +#: app/routes/tryouts.py:738 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:723 +#: app/routes/tryouts.py:770 +#, python-format +msgid "Team renamed to \"%(team_name)s\"." +msgstr "Équipe « %(team_name)s » créée." + +#: app/routes/tryouts.py:814 +#, python-format +msgid "Team \"%(name)s\" deleted." +msgstr "Équipe « %(name)s » supprimée." + +#: app/routes/tryouts.py:849 +msgid "Player removed from team." +msgstr "Gérant retiré de %(name)s." + +#: app/routes/tryouts.py:884 +msgid "Position updated." +msgstr "Dernière mise à jour" + +#: app/routes/tryouts.py:901 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:759 +#: app/routes/tryouts.py:937 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." @@ -729,194 +755,190 @@ msgstr "Seul le président peut créer des utilisateurs." msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." -#: app/routes/users/availability.py:210 -msgid "Only coaches can manage availability." -msgstr "Seuls les coachs peuvent gérer leurs disponibilités." - -#: app/routes/users/contracts.py:104 +#: app/routes/users/contracts.py:105 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users/contracts.py:108 app/routes/users/contracts.py:189 +#: app/routes/users/contracts.py:109 app/routes/users/contracts.py:190 msgid "Document uploads are currently disabled." msgstr "Les téléversements de documents sont actuellement désactivés." -#: app/routes/users/contracts.py:127 +#: app/routes/users/contracts.py:128 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users/contracts.py:155 app/routes/users/contracts.py:210 -#: app/routes/users/contracts.py:233 app/routes/users/contracts.py:252 +#: app/routes/users/contracts.py:156 app/routes/users/contracts.py:211 +#: app/routes/users/contracts.py:234 app/routes/users/contracts.py:253 msgid "Contract storage is temporarily unavailable. Please try again later." msgstr "" "Le stockage des contrats est temporairement indisponible. Veuillez " "r\\xc3\\xa9essayer plus tard." -#: app/routes/users/contracts.py:171 +#: app/routes/users/contracts.py:172 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users/contracts.py:185 +#: app/routes/users/contracts.py:186 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users/contracts.py:218 +#: app/routes/users/contracts.py:219 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users/contracts.py:228 app/routes/users/contracts.py:244 +#: app/routes/users/contracts.py:229 app/routes/users/contracts.py:245 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users/contracts.py:247 +#: app/routes/users/contracts.py:248 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users/notes.py:43 +#: app/routes/users/notes.py:44 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users/notes.py:80 +#: app/routes/users/notes.py:81 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users/notes.py:172 +#: app/routes/users/notes.py:173 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users/notes.py:178 +#: app/routes/users/notes.py:179 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users/notes.py:195 +#: app/routes/users/notes.py:196 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users/notes.py:210 +#: app/routes/users/notes.py:211 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users/notes.py:226 app/routes/users/notes.py:266 -#: app/routes/users/notes.py:346 app/routes/users/notes.py:411 +#: app/routes/users/notes.py:227 app/routes/users/notes.py:267 +#: app/routes/users/notes.py:347 app/routes/users/notes.py:412 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users/notes.py:236 app/routes/users/notes.py:306 +#: app/routes/users/notes.py:237 app/routes/users/notes.py:307 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users/notes.py:250 app/routes/users/notes.py:320 -#: app/routes/users/notes.py:384 +#: app/routes/users/notes.py:251 app/routes/users/notes.py:321 +#: app/routes/users/notes.py:385 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users/notes.py:272 +#: app/routes/users/notes.py:273 msgid "You cannot use that match as note context." msgstr "Vous ne pouvez pas utiliser ce match comme contexte de note." -#: app/routes/users/notes.py:275 +#: app/routes/users/notes.py:276 msgid "That player did not participate in the selected match." msgstr "Ce joueur n’a pas participé au match sélectionné." -#: app/routes/users/notes.py:281 +#: app/routes/users/notes.py:282 msgid "You cannot use that tryout as note context." msgstr "Vous ne pouvez pas utiliser cette sélection comme contexte de note." -#: app/routes/users/notes.py:284 +#: app/routes/users/notes.py:285 msgid "That player is not registered for the selected tryout." msgstr "Ce joueur n’est pas inscrit à la sélection choisie." -#: app/routes/users/notes.py:290 +#: app/routes/users/notes.py:291 msgid "You cannot use that team as note context." msgstr "Vous ne pouvez pas utiliser cette équipe comme contexte de note." -#: app/routes/users/notes.py:293 +#: app/routes/users/notes.py:294 msgid "That player is not on the selected team." msgstr "Ce joueur ne fait pas partie de l’équipe sélectionnée." -#: app/routes/users/notes.py:325 +#: app/routes/users/notes.py:326 msgid "You do not have permission to add notes for this tryout." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette sélection." -#: app/routes/users/notes.py:342 +#: app/routes/users/notes.py:343 msgid "Invalid tryout context." msgstr "Contexte de sélection invalide." -#: app/routes/users/notes.py:361 app/routes/users/notes.py:426 +#: app/routes/users/notes.py:362 app/routes/users/notes.py:427 msgid "Note added successfully." msgstr "Note ajoutée." -#: app/routes/users/notes.py:389 +#: app/routes/users/notes.py:390 msgid "You do not have permission to add notes for this match." msgstr "Vous n’avez pas les droits pour ajouter des notes à ce match." -#: app/routes/users/notes.py:407 +#: app/routes/users/notes.py:408 msgid "Invalid match context." msgstr "Contexte de match invalide." -#: app/routes/users/notes.py:415 +#: app/routes/users/notes.py:416 msgid "That player did not participate in this match." msgstr "Ce joueur n’a pas participé à ce match." -#: app/routes/users/one_on_one.py:22 +#: app/routes/users/one_on_one.py:23 msgid "Only players can request One on One sessions." msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." -#: app/routes/users/one_on_one.py:35 +#: app/routes/users/one_on_one.py:36 msgid "You do not have a coach assigned to your team." msgstr "Aucun coach n’est assigné à votre équipe." -#: app/routes/users/one_on_one.py:66 +#: app/routes/users/one_on_one.py:67 msgid "Cannot request One on One - no coach assigned." msgstr "Impossible de demander une rencontre : aucun coach assigné." -#: app/routes/users/one_on_one.py:92 +#: app/routes/users/one_on_one.py:93 msgid "The requested time is not within the coach's availability." msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." -#: app/routes/users/one_on_one.py:120 +#: app/routes/users/one_on_one.py:121 msgid "Your One on One request has been submitted!" msgstr "Votre demande de rencontre a été envoyée." -#: app/routes/users/one_on_one.py:165 +#: app/routes/users/one_on_one.py:166 msgid "Only coaches can accept One on One requests." msgstr "Seuls les coachs peuvent accepter une demande de rencontre." -#: app/routes/users/one_on_one.py:171 app/routes/users/one_on_one.py:221 +#: app/routes/users/one_on_one.py:172 app/routes/users/one_on_one.py:222 msgid "This request is not for you." msgstr "Cette demande ne vous est pas destinée." -#: app/routes/users/one_on_one.py:175 app/routes/users/one_on_one.py:225 +#: app/routes/users/one_on_one.py:176 app/routes/users/one_on_one.py:226 msgid "This request has already been processed." msgstr "Cette demande a déjà été traitée." -#: app/routes/users/one_on_one.py:202 +#: app/routes/users/one_on_one.py:203 #, python-format msgid "One on One request from %(player)s has been approved!" msgstr "La demande de rencontre de %(player)s a été approuvée." -#: app/routes/users/one_on_one.py:215 +#: app/routes/users/one_on_one.py:216 msgid "Only coaches can reject One on One requests." msgstr "Seuls les coachs peuvent refuser une demande de rencontre." -#: app/routes/users/one_on_one.py:262 +#: app/routes/users/one_on_one.py:263 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "La demande de rencontre de %(player)s a été refusée." -#: app/routes/users/profile.py:82 +#: app/routes/users/profile.py:83 msgid "Username already taken." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/users/profile.py:92 +#: app/routes/users/profile.py:93 msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users/profile.py:131 +#: app/routes/users/profile.py:132 msgid "Profile updated successfully!" msgstr "Profil mis à jour." @@ -1150,7 +1172,7 @@ msgstr "Ajouter une note personnelle" #: app/templates/pages/notes.html:122 #: app/templates/pages/team_match_form.html:114 #: app/templates/pages/teams.html:145 app/templates/pages/view_tryout.html:154 -#: app/templates/pages/view_tryout.html:498 +#: app/templates/pages/view_tryout.html:575 msgid "Player" msgstr "Joueur" @@ -1201,11 +1223,11 @@ msgstr "Notes d’équipe (référence)" #: app/templates/pages/evaluate_player.html:131 #: app/templates/pages/notes.html:100 #: app/templates/pages/personal_notes.html:36 -#: app/templates/pages/view_tryout.html:309 +#: app/templates/pages/view_tryout.html:380 msgid "Add Note" msgstr "Ajouter une note" -#: app/templates/pages/calendar.html:9 app/templates/pages/view_tryout.html:302 +#: app/templates/pages/calendar.html:9 app/templates/pages/view_tryout.html:373 msgid "Schedule" msgstr "Planifier" @@ -1283,6 +1305,7 @@ msgstr "Confirmer" #: app/templates/pages/calendar.html:102 #: app/templates/pages/team_matches.html:103 +#: app/templates/pages/view_tryout.html:534 msgid "Delete Match" msgstr "Supprimer le match" @@ -1295,35 +1318,6 @@ msgstr "Modifier le match" msgid "View Tryout" msgstr "Voir la sélection" -#: app/templates/pages/coach_availability.html:2 -#: app/templates/pages/coach_availability.html:3 -msgid "Manage Availability" -msgstr "Gérer mes disponibilités" - -#: app/templates/pages/coach_availability.html:9 -msgid "Set Your Weekly Availability" -msgstr "Définir vos disponibilités hebdomadaires" - -#: app/templates/pages/coach_availability.html:10 -msgid "Select time slots when you're available for One on One sessions" -msgstr "" -"Choisissez les plages où vous êtes disponible pour des rencontres " -"individuelles" - -#: app/templates/pages/coach_availability.html:14 -#: app/templates/pages/profile.html:256 -msgid "Loading availability grid..." -msgstr "Chargement de la grille de disponibilités..." - -#: app/templates/pages/coach_availability.html:19 -msgid "Save Availability" -msgstr "Enregistrer les disponibilités" - -#: app/templates/pages/coach_availability.html:22 -#: app/templates/pages/profile.html:240 app/templates/pages/profile.html:260 -msgid "Clear All" -msgstr "Tout effacer" - #: app/templates/pages/contracts.html:9 #: app/templates/pages/upload_contract.html:2 #: app/templates/pages/upload_contract.html:3 @@ -1354,7 +1348,7 @@ msgstr "Contrat" #: app/templates/pages/team_match_form.html:61 #: app/templates/pages/team_matches.html:33 app/templates/pages/teams.html:146 #: app/templates/pages/users.html:25 app/templates/pages/view_tryout.html:155 -#: app/templates/pages/view_tryout.html:327 +#: app/templates/pages/view_tryout.html:398 msgid "Status" msgstr "Statut" @@ -1366,7 +1360,7 @@ msgstr "Téléversé le" #: app/templates/pages/notes.html:127 app/templates/pages/team_matches.html:34 #: app/templates/pages/teams.html:151 app/templates/pages/users.html:27 #: app/templates/pages/view_tryout.html:160 -#: app/templates/pages/view_tryout.html:329 +#: app/templates/pages/view_tryout.html:400 msgid "Actions" msgstr "Actions" @@ -1535,7 +1529,7 @@ msgstr "Titre" #: app/templates/pages/notes.html:123 app/templates/pages/one_on_one.html:70 #: app/templates/pages/team_match_form.html:34 #: app/templates/pages/team_matches.html:29 -#: app/templates/pages/view_tryout.html:323 +#: app/templates/pages/view_tryout.html:394 msgid "Date" msgstr "Date" @@ -1548,7 +1542,7 @@ msgstr "Matchs à venir" #: app/templates/pages/dashboard.html:202 #: app/templates/pages/dashboard.html:285 app/templates/pages/my_teams.html:90 #: app/templates/pages/notes.html:69 app/templates/pages/team_matches.html:26 -#: app/templates/pages/teams.html:132 app/templates/pages/view_tryout.html:321 +#: app/templates/pages/teams.html:132 app/templates/pages/view_tryout.html:392 msgid "Match" msgstr "Match" @@ -1631,7 +1625,7 @@ msgid "Avg Score" msgstr "Note moyenne" #: app/templates/pages/dashboard.html:373 -#: app/templates/pages/view_tryout.html:532 +#: app/templates/pages/view_tryout.html:609 msgid "No evaluations yet." msgstr "Aucune évaluation pour l’instant." @@ -1827,7 +1821,7 @@ msgid "Recommended Position" msgstr "Poste recommandé" #: app/templates/pages/evaluate_player.html:109 -#: app/templates/pages/view_tryout.html:280 +#: app/templates/pages/view_tryout.html:339 msgid "-- Select Position --" msgstr "-- Choisir un poste --" @@ -1848,63 +1842,63 @@ msgid "Add note for this player" msgstr "Ajouter une note pour ce joueur" #: app/templates/pages/evaluate_player.html:147 -#: app/templates/pages/view_tryout.html:499 +#: app/templates/pages/view_tryout.html:576 msgid "Evaluator" msgstr "Évaluateur" #: app/templates/pages/evaluate_player.html:148 -#: app/templates/pages/view_tryout.html:500 +#: app/templates/pages/view_tryout.html:577 msgid "Mecanics" msgstr "Mécaniques" #: app/templates/pages/evaluate_player.html:149 -#: app/templates/pages/view_tryout.html:501 +#: app/templates/pages/view_tryout.html:578 msgid "Cohesion" msgstr "Cohésion" #: app/templates/pages/evaluate_player.html:150 -#: app/templates/pages/view_tryout.html:502 +#: app/templates/pages/view_tryout.html:579 msgid "Communication" msgstr "Communication" #: app/templates/pages/evaluate_player.html:151 -#: app/templates/pages/view_tryout.html:503 +#: app/templates/pages/view_tryout.html:580 msgid "Gamesense" msgstr "Sens du jeu" #: app/templates/pages/evaluate_player.html:152 -#: app/templates/pages/view_tryout.html:504 +#: app/templates/pages/view_tryout.html:581 msgid "Versatility" msgstr "Polyvalence" #: app/templates/pages/evaluate_player.html:153 -#: app/templates/pages/view_tryout.html:505 +#: app/templates/pages/view_tryout.html:582 msgid "Discipline" msgstr "Discipline" #: app/templates/pages/evaluate_player.html:154 -#: app/templates/pages/view_tryout.html:506 +#: app/templates/pages/view_tryout.html:583 msgid "Analysis" msgstr "Analyse" #: app/templates/pages/evaluate_player.html:155 -#: app/templates/pages/view_tryout.html:507 +#: app/templates/pages/view_tryout.html:584 msgid "Sport Ethics" msgstr "Éthique sportive" #: app/templates/pages/evaluate_player.html:156 -#: app/templates/pages/view_tryout.html:508 +#: app/templates/pages/view_tryout.html:585 msgid "Mental" msgstr "Mental" #: app/templates/pages/evaluate_player.html:157 -#: app/templates/pages/view_tryout.html:509 +#: app/templates/pages/view_tryout.html:586 msgid "Overall" msgstr "Global" #: app/templates/pages/evaluate_player.html:158 #: app/templates/pages/my_teams.html:54 -#: app/templates/pages/view_tryout.html:286 +#: app/templates/pages/view_tryout.html:345 msgid "Position" msgstr "Poste" @@ -2009,7 +2003,7 @@ msgstr "Réinitialiser la plage horaire" msgid "Reset Time" msgstr "Réinitialiser l’heure" -#: app/templates/pages/match_form.html:86 app/templates/pages/profile.html:236 +#: app/templates/pages/match_form.html:86 app/templates/pages/profile.html:193 msgid "Loading..." msgstr "Chargement..." @@ -2140,14 +2134,14 @@ msgstr "Aucun joueur dans cette équipe." #: app/templates/pages/my_teams.html:93 app/templates/pages/notes.html:124 #: app/templates/pages/one_on_one.html:71 #: app/templates/pages/team_matches.html:30 -#: app/templates/pages/view_tryout.html:325 +#: app/templates/pages/view_tryout.html:396 msgid "Time" msgstr "Heure" #: app/templates/pages/my_teams.html:95 #: app/templates/pages/team_match_form.html:115 #: app/templates/pages/team_matches.html:32 -#: app/templates/pages/view_tryout.html:326 +#: app/templates/pages/view_tryout.html:397 msgid "Presence" msgstr "Présence" @@ -2434,35 +2428,39 @@ msgstr "Évaluations données" msgid "No statistics available for this role." msgstr "Aucune statistique pour ce rôle." -#: app/templates/pages/profile.html:195 +#: app/templates/pages/profile.html:152 msgid "My Contracts" msgstr "Mes contrats" -#: app/templates/pages/profile.html:217 +#: app/templates/pages/profile.html:174 msgid "View All Contracts" msgstr "Voir tous les contrats" -#: app/templates/pages/profile.html:221 +#: app/templates/pages/profile.html:178 msgid "No contracts have been uploaded for you yet." msgstr "Aucun contrat n’a encore été téléversé pour vous." -#: app/templates/pages/profile.html:231 +#: app/templates/pages/profile.html:188 msgid "My Disponibilities" msgstr "Mes disponibilités" -#: app/templates/pages/profile.html:234 +#: app/templates/pages/profile.html:191 msgid "" "Select your available time blocks for matches (5pm to 12am). Green = " -"available, Gray = unavailable" +"selected, Gray = available to select." msgstr "" "Choisissez vos plages disponibles pour les matchs (17 h à minuit). Vert =" " disponible, gris = non-disponible." -#: app/templates/pages/profile.html:251 +#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217 +msgid "Clear All" +msgstr "Tout effacer" + +#: app/templates/pages/profile.html:208 msgid "My Coaching Availability" msgstr "Mes disponibilités de coaching" -#: app/templates/pages/profile.html:252 +#: app/templates/pages/profile.html:209 msgid "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." @@ -2470,7 +2468,11 @@ msgstr "" "Choisissez les plages où vous êtes disponible pour des rencontres " "individuelles (8 h à 22 h)." -#: app/templates/pages/profile.html:500 +#: app/templates/pages/profile.html:213 +msgid "Loading availability grid..." +msgstr "Chargement de la grille de disponibilités..." + +#: app/templates/pages/profile.html:464 msgid "Click or click-and-drag to select your available hours" msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité" @@ -2609,7 +2611,7 @@ msgid "No players on this team. Add players in the Teams page first." msgstr "Aucun joueur dans cette équipe. Ajoutez-en d’abord depuis la page Équipes." #: app/templates/pages/team_match_form.html:107 -#: app/templates/pages/view_tryout.html:324 +#: app/templates/pages/view_tryout.html:395 msgid "Participants" msgstr "Participants" @@ -2627,6 +2629,7 @@ msgid "+ Schedule Match" msgstr "+ Planifier un match" #: app/templates/pages/team_matches.html:101 +#: app/templates/pages/view_tryout.html:532 msgid "Delete this match?" msgstr "Supprimer ce match ?" @@ -2697,7 +2700,8 @@ msgstr "Créer l’équipe" #: app/templates/pages/teams.html:64 app/templates/pages/users.html:59 #: app/templates/pages/view_tryout.html:29 -#: app/templates/pages/view_tryout.html:459 +#: app/templates/pages/view_tryout.html:256 +#: app/templates/pages/view_tryout.html:530 msgid "Edit" msgstr "Modifier" @@ -2708,7 +2712,7 @@ msgstr "" "Supprimer l’équipe %(name)s ? Elle sera détachée de toutes les sélections" " liées." -#: app/templates/pages/teams.html:69 +#: app/templates/pages/teams.html:69 app/templates/pages/view_tryout.html:535 msgid "Delete" msgstr "Supprimer" @@ -2930,7 +2934,7 @@ msgid "Tryout Details" msgstr "Détail de la sélection" #: app/templates/pages/view_tryout.html:14 -#: app/templates/pages/view_tryout.html:306 +#: app/templates/pages/view_tryout.html:377 msgid "Schedule Match" msgstr "Planifier un match" @@ -3000,6 +3004,7 @@ msgid "No players registered yet." msgstr "Aucun joueur inscrit pour l’instant." #: app/templates/pages/view_tryout.html:243 +#: app/templates/pages/view_tryout.html:289 msgid "Team name" msgstr "Nom de l’équipe" @@ -3007,47 +3012,88 @@ msgstr "Nom de l’équipe" msgid "Create" msgstr "Créer" -#: app/templates/pages/view_tryout.html:263 +#: app/templates/pages/view_tryout.html:270 +#: app/templates/pages/view_tryout.html:320 msgid "No players assigned yet." msgstr "Aucun joueur assigné pour l’instant." -#: app/templates/pages/view_tryout.html:271 +#: app/templates/pages/view_tryout.html:282 +#, python-format +msgid "Edit Team: %(name)s" +msgstr "Nom de l’équipe" + +#: app/templates/pages/view_tryout.html:291 +msgid "Rename" +msgstr "Nom" + +#: app/templates/pages/view_tryout.html:297 +msgid "Roster" +msgstr "S’inscrire" + +#: app/templates/pages/view_tryout.html:306 +msgid "No role" +msgstr "Rôle" + +#: app/templates/pages/view_tryout.html:312 +#, python-format +msgid "Remove %(player)s from this team?" +msgstr "French placeholder fix" + +#: app/templates/pages/view_tryout.html:314 +msgid "Remove from team" +msgstr "Depuis l’équipe" + +#: app/templates/pages/view_tryout.html:326 +msgid "Add Player" +msgstr "Nombre maximal de joueurs" + +#: app/templates/pages/view_tryout.html:330 msgid "Select player..." msgstr "Choisir un joueur..." -#: app/templates/pages/view_tryout.html:288 +#: app/templates/pages/view_tryout.html:347 msgid "Add" msgstr "Ajouter" -#: app/templates/pages/view_tryout.html:293 +#: app/templates/pages/view_tryout.html:353 +msgid "" +"Delete this team? Players already in the team will be removed and matches" +" referencing it will be unlinked." +msgstr "Delete team fix" + +#: app/templates/pages/view_tryout.html:355 +msgid "Delete Team" +msgstr "Choisir les équipes" + +#: app/templates/pages/view_tryout.html:364 msgid "No teams created yet." msgstr "Aucune équipe créée." -#: app/templates/pages/view_tryout.html:322 +#: app/templates/pages/view_tryout.html:393 msgid "Type" msgstr "Type" -#: app/templates/pages/view_tryout.html:387 +#: app/templates/pages/view_tryout.html:458 msgid "Toggle your attendance" msgstr "Basculer votre présence" -#: app/templates/pages/view_tryout.html:462 +#: app/templates/pages/view_tryout.html:539 msgid "Add Note for this Match" msgstr "Ajouter une note pour ce match" -#: app/templates/pages/view_tryout.html:463 +#: app/templates/pages/view_tryout.html:540 msgid "Note" msgstr "Note" -#: app/templates/pages/view_tryout.html:481 +#: app/templates/pages/view_tryout.html:558 msgid "No matches scheduled yet. Check back later!" msgstr "Aucun match planifié pour l’instant. Revenez plus tard." -#: app/templates/pages/view_tryout.html:491 +#: app/templates/pages/view_tryout.html:568 msgid "Evaluation Summary" msgstr "Synthèse de l’évaluation" -#: app/templates/pages/view_tryout.html:510 +#: app/templates/pages/view_tryout.html:587 msgid "Recommendation" msgstr "Recommandation" @@ -3126,3 +3172,20 @@ msgstr "Disponibilités" #~ msgid "No players registered for this tryout." #~ msgstr "Aucun joueur inscrit à cette sélection." +#~ msgid "Only coaches can manage availability." +#~ msgstr "Seuls les coachs peuvent gérer leurs disponibilités." + +#~ msgid "Manage Availability" +#~ msgstr "Gérer mes disponibilités" + +#~ msgid "Set Your Weekly Availability" +#~ msgstr "Définir vos disponibilités hebdomadaires" + +#~ msgid "Select time slots when you're available for One on One sessions" +#~ msgstr "" +#~ "Choisissez les plages où vous êtes " +#~ "disponible pour des rencontres individuelles" + +#~ msgid "Save Availability" +#~ msgstr "Enregistrer les disponibilités" + diff --git a/tests/test_admin_app_settings.py b/tests/test_admin_app_settings.py index 0563290..7ca29ca 100644 --- a/tests/test_admin_app_settings.py +++ b/tests/test_admin_app_settings.py @@ -5,9 +5,6 @@ ADMIN-001. The admin panel relies on AppSettings for global toggles These tests verify the key-value store itself, independent of any route. """ -import pytest - -from app.extensions import db from app.models import AppSettings diff --git a/tests/test_admin_audit_log.py b/tests/test_admin_audit_log.py index 6e7af12..045c1a8 100644 --- a/tests/test_admin_audit_log.py +++ b/tests/test_admin_audit_log.py @@ -4,8 +4,6 @@ ADMIN-002. Every sensitive admin action (backup, restore, toggle, season, wipe) writes an AuditLog entry. These tests verify the model itself. """ -import pytest - from app.extensions import db from app.models import AuditLog, User diff --git a/tests/test_admin_backup.py b/tests/test_admin_backup.py index 1a5cb77..382d34b 100644 --- a/tests/test_admin_backup.py +++ b/tests/test_admin_backup.py @@ -5,10 +5,6 @@ in production, but tests mock the subprocess boundary. These tests verify the route logic, the BackupRecord model, and audit logging. """ -import os - -import pytest - from app.extensions import db from app.models import AuditLog, BackupRecord @@ -20,7 +16,6 @@ def _redirected(response): class TestBackupCreate: def test_create_backup_succeeds(self, app, client, as_role, monkeypatch): import app.routes.admin as admin_module - from app.supporting_scripts.backup import BackupError def fake_create_backup_record(backup_type='manual', notes=None): from app.models import BackupRecord diff --git a/tests/test_admin_csrf.py b/tests/test_admin_csrf.py index 9de5ec7..964aa2b 100644 --- a/tests/test_admin_csrf.py +++ b/tests/test_admin_csrf.py @@ -7,7 +7,6 @@ enabled, unlike the default app fixture which disables it. import pytest - ADMIN_POST_ROUTES = [ '/admin/backup/create', '/admin/toggle-tryouts', @@ -24,7 +23,6 @@ class TestAdminCsrfProtection: # as_role uses the default app fixture, so we log in manually # with the csrf-enabled app. from app.extensions import db as _db - from app.models import User client = app_with_csrf.test_client() diff --git a/tests/test_admin_panel.py b/tests/test_admin_panel.py index 8045a65..8124aaf 100644 --- a/tests/test_admin_panel.py +++ b/tests/test_admin_panel.py @@ -6,10 +6,6 @@ must render all expected sections, and must serve static assets correctly. import pytest -from app.extensions import db -from app.models import User - - NON_ADMIN_ROLES = ['player', 'coach', 'manager', 'scout'] ADMIN_MUTATION_ROUTES = [ diff --git a/tests/test_admin_route_map.py b/tests/test_admin_route_map.py index 7ecef5d..0dd3405 100644 --- a/tests/test_admin_route_map.py +++ b/tests/test_admin_route_map.py @@ -7,7 +7,6 @@ URL map to catch that. import pytest - ADMIN_ROUTES = [ '/admin', '/admin/backup/create', @@ -30,11 +29,11 @@ class TestAdminRouteRegistration: adapter = app.url_map.bind('localhost') try: adapter.match(route, method='GET') - except Exception: + except Exception: # noqa: BLE001 — test boundary, first of two attempts # Some routes are POST-only; try POST try: adapter.match(route, method='POST') - except Exception as exc: + except Exception as exc: # noqa: BLE001 — test boundary, failure is reported via pytest.fail pytest.fail(f'Route {route} is not registered: {exc}') @pytest.mark.parametrize('route', ADMIN_ROUTES) diff --git a/tests/test_admin_season.py b/tests/test_admin_season.py index 1dc32b5..e5c021c 100644 --- a/tests/test_admin_season.py +++ b/tests/test_admin_season.py @@ -6,9 +6,7 @@ for non-admins. These tests verify start/end season and the gate. import pytest -from app.extensions import db -from app.models import AppSettings, OrgTeam, TeamMatch, User - +from app.models import AppSettings, OrgTeam, TeamMatch NON_ADMIN_ROLES = ['manager', 'coach'] diff --git a/tests/test_admin_template.py b/tests/test_admin_template.py index 4841f66..56bb1b0 100644 --- a/tests/test_admin_template.py +++ b/tests/test_admin_template.py @@ -5,8 +5,6 @@ the tryout toggle button, the season form fields, the wipe confirmation input, and the backup/audit tables, plus that the logo image is served. """ -import pytest - class TestAdminStatsGrid: def test_stats_grid_has_five_cards(self, client, as_role): @@ -80,8 +78,3 @@ class TestAdminImages: def test_logo_image_loads(self, client): response = client.get('/static/images/UdeS_logo.png') assert response.status_code == 200 - - def test_esports_logo_image_loads(self, client): - # The original logo may still be referenced - response = client.get('/static/images/UdeS_logo.png') - assert response.status_code == 200 diff --git a/tests/test_admin_tryout_toggle.py b/tests/test_admin_tryout_toggle.py index a51274f..a1ff2a6 100644 --- a/tests/test_admin_tryout_toggle.py +++ b/tests/test_admin_tryout_toggle.py @@ -7,9 +7,7 @@ itself and the gate on each affected route. import pytest -from app.extensions import db -from app.models import AppSettings, Tryout, User - +from app.models import AppSettings, Tryout NON_ADMIN_ROLES = ['manager', 'coach'] diff --git a/tests/test_admin_wipe.py b/tests/test_admin_wipe.py index 1be0a6b..c52f976 100644 --- a/tests/test_admin_wipe.py +++ b/tests/test_admin_wipe.py @@ -13,7 +13,6 @@ from app.models import ( BackupRecord, OrgTeam, TeamMatch, - TeamMatchParticipant, TeamPlayer, ) diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py new file mode 100644 index 0000000..d3236bf --- /dev/null +++ b/tests/test_api_endpoints.py @@ -0,0 +1,99 @@ +"""Every JSON API endpoint works as intended.""" + +import json + + +def _json_endpoints(app): + for rule in app.url_map.iter_rules(): + if rule.endpoint == 'static': + continue + view = app.view_functions.get(rule.endpoint) + if getattr(view, 'returns_json', False): + yield rule.endpoint, rule.rule, sorted(rule.methods - {'HEAD', 'OPTIONS'}) + + +class TestJsonEndpoints: + """All API endpoints are properly protected and return JSON.""" + + def test_all_json_endpoints_require_login(self, app, client): + errors = [] + for endpoint, url, methods in _json_endpoints(app): + if '<' in url: + continue + for method in methods: + func = getattr(client, method.lower()) + resp = func(url, follow_redirects=False) + if endpoint == 'data_revision': + continue + if resp.status_code in (200, 201): + errors.append( + f'{method} {url} ({endpoint}): returned {resp.status_code} without auth' + ) + elif resp.status_code not in (301, 302, 401, 403): + errors.append(f'{method} {url} ({endpoint}): unexpected {resp.status_code}') + assert not errors, '\n'.join(errors[:20]) + + def test_json_endpoints_return_json_content_type(self, app, client, as_role): + errors = [] + as_role('admin') + for endpoint, url, methods in _json_endpoints(app): + if '<' in url: + continue + for method in methods: + func = getattr(client, method.lower()) + resp = func(url, follow_redirects=False) + if resp.status_code == 301: + continue + ct = resp.content_type or '' + if resp.status_code < 500 and 'application/json' not in ct: + errors.append(f'{method} {url} ({endpoint}): content_type={ct}') + assert not errors, '\n'.join(errors[:20]) + + def test_json_endpoints_return_reasonable_payload(self, app, client, as_role): + errors = [] + as_role('admin') + for endpoint, url, methods in _json_endpoints(app): + if '<' in url: + continue + for method in methods: + func = getattr(client, method.lower()) + resp = func(url, follow_redirects=False) + if resp.status_code in (301, 302): + continue + if 'application/json' not in (resp.content_type or ''): + continue + try: + data = json.loads(resp.data) + except (ValueError, TypeError) as exc: + errors.append(f'{method} {url} ({endpoint}): unparseable JSON: {exc}') + continue + if not isinstance(data, (dict, list)): + errors.append(f'{method} {url} ({endpoint}): JSON is not a dict: {type(data)}') + assert not errors, '\n'.join(errors[:20]) + + def test_api_consistent_error_format(self, app, client): + errors = [] + for _endpoint, url, methods in _json_endpoints(app): + if '<' not in url: + for method in methods: + func = getattr(client, method.lower()) + resp = func(url, follow_redirects=False) + if resp.status_code >= 400 and 'application/json' in (resp.content_type or ''): + try: + data = json.loads(resp.data) + except (ValueError, TypeError): + continue + if 'error' not in data: + errors.append(f'{method} {url}: 4xx JSON missing "error" key: {data}') + assert not errors, '\n'.join(errors[:20]) + + +class TestAuthorizedApiAccess: + """Each JSON endpoint only exposes data the user is allowed to see.""" + + def test_player_cannot_access_admin_json(self, app, client, as_role): + as_role('player') + admin_only = ['/matches/api/manageable-tryouts', '/team-matches/api/manageable-teams'] + for url in admin_only: + resp = client.get(url, follow_redirects=False) + assert resp.status_code not in (500,), f'{url} returned {resp.status_code}' diff --git a/tests/test_frontend_controls.py b/tests/test_frontend_controls.py new file mode 100644 index 0000000..2722027 --- /dev/null +++ b/tests/test_frontend_controls.py @@ -0,0 +1,158 @@ +"""Front-end controls: every form, button, and link that is rendered exists +and behaves correctly for each role. + +Uses stdlib-only HTML parsing (html.parser). +""" + +import os +from html.parser import HTMLParser + +import pytest + +_TEMPLATE_ROOT = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates' +) + + +class _FormCollector(HTMLParser): + """Collect forms, submit buttons, and links that represent actions.""" + + def __init__(self): + super().__init__() + self.forms = [] # list of {method, action, id} + self.buttons = [] # list of {text, form_id, type} + self.links = [] # list of href + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == 'form': + self.forms.append( + { + 'method': attrs.get('method', 'get').upper(), + 'action': attrs.get('action', ''), + 'id': attrs.get('id', ''), + } + ) + elif tag == 'button': + self.buttons.append( + { + 'type': attrs.get('type', 'submit'), + 'form': attrs.get('form', ''), + 'text': attrs.get('value', ''), + } + ) + elif tag == 'input': + if attrs.get('type') in ('submit', 'button', 'image'): + self.buttons.append( + { + 'type': attrs.get('type', 'submit'), + 'form': attrs.get('form', ''), + 'text': attrs.get('value', ''), + 'name': attrs.get('name', ''), + } + ) + elif tag == 'a' and 'href' in attrs: + href = attrs['href'] + if href and not href.startswith('#') and not href.startswith('javascript:'): + self.links.append(href) + + +# --------------------------------------------------------------------------- +# Each page route renders correctly with form elements +# --------------------------------------------------------------------------- + +_PAGE_LIST = [ + '/', + '/auth/login', + '/teams', + '/matches/calendar', + '/users/profile', + '/users/profile/edit', + '/users/contracts', + '/tryouts', + '/evaluations', +] + + +@pytest.mark.parametrize('route', _PAGE_LIST) +def test_page_returns_ok(app, client, route): + """Unauthenticated access should redirect to login for all but public pages.""" + resp = client.get(route, follow_redirects=True) + # Unauthenticated access should either serve the page or redirect to login + assert resp.status_code in (200, 301, 302), f'{route} returned {resp.status_code}' + + +def test_login_form_submits_correctly(app, client, make_user): + """The login form works end-to-end with valid credentials.""" + user_id = make_user('player', password='Password123') + with app.app_context(): + from app.extensions import db + from app.models import User + + username = db.session.get(User, user_id).username + + # GET the login page to ensure form is there + resp = client.get('/auth/login') + assert resp.status_code == 200 + html = resp.data.decode('utf-8') + assert 'name="username"' in html + assert 'name="password"' in html + assert 'type="password"' in html + + # POST valid credentials + resp = client.post( + '/auth/login', + data={ + 'username': username, + 'password': 'Password123', + }, + follow_redirects=False, + ) + assert resp.status_code in (301, 302), f'Login did not redirect: {resp.status_code}' + + +def test_logout_form_works(app, client, as_role): + """The logout form works for an authenticated user.""" + as_role('player') + resp = client.post('/auth/logout', follow_redirects=False) + assert resp.status_code in (301, 302), f'Logout did not redirect: {resp.status_code}' + + +# --------------------------------------------------------------------------- +# Each page renders forms for the appropriate role +# --------------------------------------------------------------------------- + + +def test_register_form_has_required_fields(app, client): + """The registration form shows required fields.""" + resp = client.get('/auth/register') + assert resp.status_code == 200 + html = resp.data.decode('utf-8') + for field in ('username', 'password', 'confirm_password', 'full_name', 'email'): + assert field in html, f'Registration form missing field: {field}' + + +def test_register_form_validates_password(app, client): + """Registration rejects weak passwords.""" + resp = client.post( + '/auth/register', + data={ + 'username': 'newuser', + 'password': 'short', + 'confirm_password': 'short', + 'full_name': 'New User', + 'email': 'new@example.test', + }, + follow_redirects=True, + ) + # Should return the registration page with an error + assert resp.status_code == 200 + assert b'password' in resp.data.lower() or b'short' in resp.data + + +def test_create_tryout_form_validates(app, client, as_role): + """Admin can see the tryout creation form.""" + as_role('admin') + resp = client.get('/tryouts/create', follow_redirects=True) + # Admin should be able to see the create page (but it might redirect to dashboard) + assert resp.status_code == 200 diff --git a/tests/test_html_css.py b/tests/test_html_css.py new file mode 100644 index 0000000..96856fd --- /dev/null +++ b/tests/test_html_css.py @@ -0,0 +1,133 @@ +"""CSS/HTML coherence: every CSS class used in templates exists in style.css, +and every page renders with its core structural classes present. + +Uses stdlib-only HTML parsing (html.parser) to keep the dependency surface +unchanged. +""" + +import logging +import os +import re +from html.parser import HTMLParser + +import pytest + +_logger = logging.getLogger(__name__) + +_TEMPLATE_ROOT = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates' +) +_CSS_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'app', + 'static', + 'css', + 'style.css', +) + +# --------------------------------------------------------------------------- +# Phase 1: CSS class usage analysis +# --------------------------------------------------------------------------- + + +def _cached_lines(): + """Yield all CSS class selectors found in style.css.""" + with open(_CSS_PATH, encoding='utf-8') as f: + content = f.read() + for match in re.finditer(r'(? 30: + _logger.warning( + '%d HTML classes missing from CSS (threshold=30):\n%s', + len(missing), + '\n'.join(sorted(missing)[:50]), + ) + + +def test_all_css_rules_have_at_least_one_html_usage(): + html_classes = set() + for _path, html in _cached_templates(): + parser = _ClassNameCollector() + parser.feed(html) + html_classes.update(parser.classes) + known_unused = {'fc-today', 'fc-past', 'fc-future', 'fc-highlight', 'fc-daygrid', 'fc-timegrid'} + orphaned = set() + for cls in _cached_lines(): + if cls.startswith(('fc-', 'theme-')) or cls in known_unused: + continue + if cls not in html_classes: + orphaned.add(cls) + assert len(orphaned) <= 40, ( + f'{len(orphaned)} CSS rules have no HTML match: {sorted(orphaned)[:30]}' + ) + + +# --------------------------------------------------------------------------- +# Phase 2: Per-role page rendering smoke tests +# --------------------------------------------------------------------------- + +_PAGE_ROUTES = [ + '/', + '/auth/login', + '/auth/logout', + '/teams', + '/matches/calendar', + '/users/profile', + '/users/profile/edit', + '/users/contracts', + '/tryouts', +] + +_PAGE_ROUTES_BY_ROLE = { + 'admin': _PAGE_ROUTES + ['/admin'], + 'manager': _PAGE_ROUTES, + 'coach': _PAGE_ROUTES + ['/users/notes-dashboard'], + 'player': _PAGE_ROUTES + ['/users/one-on-one'], + 'scout': _PAGE_ROUTES, +} + +_CORE_CLASSES = {'sidebar', 'sidebar-header', 'logo', 'content', 'flash-messages'} + + +@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout']) +def test_page_renders_with_core_structure(app, client, as_role, role): + as_role(role) + for route in _PAGE_ROUTES_BY_ROLE.get(role, _PAGE_ROUTES): + resp = client.get(route, follow_redirects=True) + if resp.status_code == 200 and resp.content_type and 'text/html' in resp.content_type: + parser = _ClassNameCollector() + parser.feed(resp.data.decode('utf-8', errors='replace')) + missing = _CORE_CLASSES - parser.classes + if missing and route not in ('/auth/login', '/auth/logout'): + pytest.fail(f'Role={role}, route={route}: missing core classes: {missing}')