Compare commits
2
Commits
32c112ceea
...
cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53bc3b80d | ||
|
|
101e3ae880 |
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
+2
-2
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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),
|
||||
}
|
||||
Binary file not shown.
@@ -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'
|
||||
@@ -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}',
|
||||
)
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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
|
||||
+27
-1651
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
-143
@@ -1,143 +0,0 @@
|
||||
"""Development HTTPS server for testing production settings locally.
|
||||
|
||||
Generates a self-signed certificate (if not present) and runs the
|
||||
application via Waitress wrapped in a TLS socket. This simulates the
|
||||
production environment where Nginx handles TLS termination.
|
||||
|
||||
Usage:
|
||||
python run_https.py
|
||||
|
||||
The server will listen on https://localhost:8443
|
||||
Accept the self-signed certificate warning in your browser to proceed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from waitress.server import create_server
|
||||
|
||||
from app.app import create_app
|
||||
|
||||
CERT_FILE = 'certs/localhost.pem'
|
||||
KEY_FILE = 'certs/localhost-key.pem'
|
||||
|
||||
|
||||
def generate_self_signed_cert():
|
||||
"""Generate a self-signed certificate for local HTTPS testing.
|
||||
|
||||
Uses OpenSSL to create a key and certificate valid for 365 days.
|
||||
Skips generation if certificate files already exist.
|
||||
"""
|
||||
if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
|
||||
print('[OK] Self-signed certificate already exists.')
|
||||
return
|
||||
|
||||
os.makedirs('certs', exist_ok=True)
|
||||
|
||||
print('[INFO] Generating self-signed certificate for localhost...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
'openssl',
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-keyout',
|
||||
KEY_FILE,
|
||||
'-out',
|
||||
CERT_FILE,
|
||||
'-days',
|
||||
'365',
|
||||
'-nodes',
|
||||
'-subj',
|
||||
'/CN=localhost',
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
print('[OK] Certificate generated: certs/localhost.pem')
|
||||
except FileNotFoundError:
|
||||
print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
|
||||
print(' winget install OpenSSL.OpenSSL')
|
||||
print(' OR download from https://slproweb.com/products/Win32OpenSSL.html')
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'[ERROR] Certificate generation failed: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the HTTPS development server."""
|
||||
port = int(os.getenv('HTTPS_PORT', 8443))
|
||||
host = os.getenv('HOST', '127.0.0.1')
|
||||
|
||||
# Ensure certificate exists
|
||||
generate_self_signed_cert()
|
||||
|
||||
# Create the Flask application
|
||||
app = create_app()
|
||||
|
||||
# Create SSL context
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(CERT_FILE, KEY_FILE)
|
||||
|
||||
# Create a TCP socket, wrap it with TLS, then pass to Waitress
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((host, port))
|
||||
sock.listen(5)
|
||||
|
||||
# Wrap the socket with TLS
|
||||
ssl_sock = context.wrap_socket(sock, server_side=True)
|
||||
|
||||
# =====================================================================
|
||||
# WSGI Middleware: Tell Flask the connection is HTTPS
|
||||
#
|
||||
# Waitress doesn't know the underlying socket is TLS, so Flask sees
|
||||
# wsgi.url_scheme = "http". Without this middleware, force_https()
|
||||
# would cause an infinite redirect loop (ERR_TOO_MANY_REDIRECTS).
|
||||
# =====================================================================
|
||||
class ForceHTTPSMiddleware:
|
||||
"""WSGI middleware that sets url_scheme to 'https'.
|
||||
|
||||
Since we're wrapping the TCP socket with SSL before passing it
|
||||
to Waitress, Flask's request.is_secure returns False because
|
||||
Waitress reports wsgi.url_scheme='http'. This middleware fixes
|
||||
that so Flask correctly identifies the connection as HTTPS.
|
||||
"""
|
||||
|
||||
def __init__(self, wsgi_app):
|
||||
self.wsgi_app = wsgi_app
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
environ['wsgi.url_scheme'] = 'https'
|
||||
environ['HTTPS'] = 'on'
|
||||
return self.wsgi_app(environ, start_response)
|
||||
|
||||
# Wrap the Flask app with the HTTPS middleware
|
||||
app.wsgi_app = ForceHTTPSMiddleware(app.wsgi_app)
|
||||
|
||||
print('\n╔══════════════════════════════════════════════════════╗')
|
||||
print('║ TEAM TRYOUTS - Development HTTPS Server ║')
|
||||
print('╠══════════════════════════════════════════════════════╣')
|
||||
print(f'║ URL: https://{host}:{port} ║')
|
||||
print('║ Cert: self-signed (accept browser warning) ║')
|
||||
print('║ Press Ctrl+C to stop ║')
|
||||
print('╚══════════════════════════════════════════════════════╝\n')
|
||||
|
||||
# Create Waitress server with the SSL-wrapped socket
|
||||
server = create_server(
|
||||
app,
|
||||
sockets=[ssl_sock],
|
||||
threads=4,
|
||||
channel_timeout=30,
|
||||
)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.models import (
|
||||
BackupRecord,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}'
|
||||
@@ -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': '[email protected]',
|
||||
},
|
||||
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
|
||||
@@ -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'(?<!\w)\.\w[a-zA-Z0-9_-]*', content):
|
||||
yield match.group(0).lstrip('.')
|
||||
|
||||
|
||||
def _cached_templates():
|
||||
for root, _dirs, files in os.walk(_TEMPLATE_ROOT):
|
||||
for name in files:
|
||||
if name.endswith('.html'):
|
||||
path = os.path.join(root, name)
|
||||
with open(path, encoding='utf-8') as f:
|
||||
yield path, f.read()
|
||||
|
||||
|
||||
class _ClassNameCollector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.classes = set()
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
for name, value in attrs:
|
||||
if name == 'class' and value and '{{' not in value and '{%' not in value:
|
||||
self.classes.update(value.split())
|
||||
|
||||
|
||||
def test_all_css_classes_in_html_have_a_matching_rule():
|
||||
css_classes = set(_cached_lines())
|
||||
missing = set()
|
||||
for path, html in _cached_templates():
|
||||
parser = _ClassNameCollector()
|
||||
parser.feed(html)
|
||||
for cls in parser.classes:
|
||||
if cls not in css_classes and not cls.startswith(('fc-', 'fa-')) and cls:
|
||||
missing.add(f'{os.path.relpath(path, _TEMPLATE_ROOT)}: .{cls}')
|
||||
if len(missing) > 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}')
|
||||
Reference in New Issue
Block a user