perf: servir les statiques par nginx, et dire ce que le bot n a pas livre

PERF-006. Le bloc location /static/ etait commente : 59 Ko de CSS et de JS
passaient par Waitress a chaque page. L activer tel quel aurait ete une
regression : ces URL ne changent jamais, donc un cache de 30 jours sert une
feuille de style vieille d un mois apres chaque deploiement, sans moyen de
l invalider. url_for('static') estampille maintenant chaque URL du mtime du
fichier ; c est ce qui rend le immutable vrai et pas seulement rapide.

Deux pieges nginx consignes dans le fichier : un add_header dans un location
annule tous les add_header herites du server (nosniff disparaissait du
JavaScript), et un statique manquant doit renvoyer 404 plutot que retomber
sur Flask, sinon un deploiement casse se cache derriere une page qui marche.

PERF-005. Les objets utilisateur Discord sont mis en cache. A etre precis
sur le gain : un envoi coute deux appels reseau, resoudre puis envoyer, et
seul le premier est economise — un premier match a vingt joueurs fait
toujours vingt resolutions. Ce qui est gagne l est entre notifications, la
ou le bot ecrit aux memes personnes soir apres soir.

Chaque message dit desormais ce qu il est devenu, avec le destinataire et
la raison. Les trois echecs ne se ressemblent pas et ne se lisent plus
pareil : une boite fermee est definitive et ne se retente pas, une erreur
HTTP est passagere, un identifiant sans proprietaire est un compte a
corriger. Le lot quotidien annonce son propre deficit.

Piege trouve en ecrivant les tests : configure_logging met propagate=False
sur le logger 'app', et le handler de caplog est sur la racine. Les
assertions sur les journaux passaient seules et echouaient dans la suite
complete, ou une application avait deja ete construite — elles lisaient un
journal vide, pas un bot silencieux.

417 tests.
This commit is contained in:
GGThed
2026-08-11 11:56:48 -04:00
parent 47ff544848
commit d0a9e75fe6
6 changed files with 741 additions and 162 deletions
+34
View File
@@ -147,6 +147,11 @@ def create_app(config=None):
"""
app = Flask(__name__)
# Cache-busting stamps for static files, filled lazily by the url_defaults
# hook below. Per application instance, so the test suite does not carry
# one app's mtimes into the next.
_static_stamps = {}
# --- defaults from the environment ------------------------------------
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
@@ -244,6 +249,35 @@ def create_app(config=None):
# unconditionally so templates can carry nonce="" beforehand.
g.csp_nonce = secrets.token_urlsafe(16)
@app.url_defaults
def version_static_urls(endpoint, values):
"""Stamp every static URL with the file's modification time.
Without this, nginx cannot be allowed to cache style.css and main.js:
their URLs never change, so a 30-day expiry means a 30-day-old stylesheet
with no way to invalidate it short of telling people to hard-refresh.
With it, a deployed file gets a new URL and the old entry simply stops
being asked for — which is what makes the `immutable` in nginx.conf
true rather than merely fast (PERF-006).
The stamp is computed once per file per process. The process restarts
on deploy, which is exactly when a file can have changed.
"""
if endpoint != 'static' or 'filename' not in values:
return
filename = values['filename']
stamp = _static_stamps.get(filename)
if stamp is None:
try:
stamp = str(int(os.stat(os.path.join(app.static_folder, filename)).st_mtime))
except OSError:
# A missing file is the template's problem, not this hook's:
# let the URL build and let the 404 say so.
stamp = ''
_static_stamps[filename] = stamp
if stamp:
values['v'] = stamp
@app.context_processor
def inject_csp_nonce():
return {
+275 -151
View File
@@ -20,7 +20,7 @@ from zoneinfo import ZoneInfo
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from discord import Intents
from discord import Forbidden, HTTPException, Intents, NotFound
from discord.ext import commands
from dotenv import load_dotenv
@@ -44,6 +44,23 @@ PENDING_MAX_AGE_DAYS = 30
#: 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
@@ -75,6 +92,7 @@ class TeamTryoutsBot(commands.Bot):
self.message_queue = Queue() # Thread-safe queue for messages from Flask
self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
self._user_cache = {} # Discord snowflake -> User, bounded (PERF-005)
def _load_pending(self):
"""Load pending requests from the JSON file.
@@ -217,6 +235,88 @@ class TeamTryoutsBot(commands.Bot):
except Exception as e:
logger.error(f'Error starting scheduler: {e}')
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 process_queue(self):
"""Process messages from the queue (runs continuously)."""
while True:
@@ -266,11 +366,7 @@ class TeamTryoutsBot(commands.Bot):
return
# Fetch the user who reacted
try:
user = await self.fetch_user(payload.user_id)
except Exception:
return
user = await self._resolve_user(payload.user_id, context=' (reaction handler)')
if user is None:
return
@@ -336,47 +432,50 @@ class TeamTryoutsBot(commands.Bot):
request_id: int,
) -> int:
"""Send a One on One request DM to a coach with reactions."""
try:
user_id = int(coach_discord_id)
except (ValueError, TypeError):
logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'")
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:
user = await self.fetch_user(user_id)
if not user:
return None
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 user.send(message)
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()
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
return msg.id
except Exception as e:
logger.error(f"Error sending One on One DM: {e}")
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,
@@ -397,59 +496,63 @@ class TeamTryoutsBot(commands.Bot):
event_time: Time string.
reference_id: ID of the MatchParticipant or TryoutRegistration record.
"""
# Look up the DB user to get their Discord user ID
from app.models import User as DBUser
db_user = DBUser.query.get(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"
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:
# Look up the DB user to get their Discord user ID
from app.models import User as DBUser
db_user = DBUser.query.get(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
discord_uid = int(db_user.discord_user_id)
user = await self.fetch_user(discord_uid)
if not user:
logger.warning(f"Could not fetch Discord user {discord_uid}")
return None
event_name = "Match" if event_type == 'match' else "Tryout"
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 user.send(message)
await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI)
# 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()
logger.info(
f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}"
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 msg.id
return None
except Exception as e:
logger.error(f"Error sending schedule notification: {e}")
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):
"""Handle coach approving a One on One request."""
@@ -693,11 +796,6 @@ class TeamTryoutsBot(commands.Bot):
logger.warning(f"Player has no Discord user ID for request {request.id}")
return
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
@@ -722,9 +820,11 @@ class TeamTryoutsBot(commands.Bot):
"Please try selecting a different time slot."
)
await player_user.send(message)
logger.info(
f"Sent One on One notification to player {player_full_name} (request {request.id})"
await self._send_dm(
player_discord_id,
message,
purpose=f'one-on-one response (request {request.id})',
recipient=player_full_name,
)
except Exception as e:
@@ -757,13 +857,20 @@ class TeamTryoutsBot(commands.Bot):
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
# 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:
await self.send_match_reminder(participant.player, match)
attempted += 1
delivered += await self.send_match_reminder(participant.player, match)
# Find tryouts for tomorrow
tryouts = Tryout.query.filter(Tryout.date == tomorrow).all()
@@ -771,7 +878,8 @@ class TeamTryoutsBot(commands.Bot):
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
for reg in registrations:
if reg.player.discord_user_id:
await self.send_tryout_reminder(reg.player, tryout)
attempted += 1
delivered += await self.send_tryout_reminder(reg.player, tryout)
# Find One on One sessions for tomorrow (only approved ones)
one_on_ones = (
@@ -783,42 +891,59 @@ class TeamTryoutsBot(commands.Bot):
)
for session in one_on_ones:
if session.player and session.player.discord_user_id:
await self.send_one_on_one_reminder(session.player, session)
attempted += 1
delivered += await self.send_one_on_one_reminder(session.player, session)
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)
except Exception as e:
logger.error(f"Error sending daily reminders: {e}")
async def send_match_reminder(self, player, match):
"""Send match reminder to player."""
try:
player_user = await self.fetch_user(int(player.discord_user_id))
message = (
"🔔 **Match Reminder**\n\n"
f"Your match **{match.title}** is scheduled for tomorrow:\n"
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
f"**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."
)
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending match reminder: {e}")
"""Send match reminder to player. True if it was delivered."""
message = (
"🔔 **Match Reminder**\n\n"
f"Your match **{match.title}** is scheduled for tomorrow:\n"
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
f"**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."""
try:
player_user = await self.fetch_user(int(player.discord_user_id))
message = (
"🔔 **Tryout Reminder**\n\n"
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
f"**Location:** {tryout.location or 'TBD'}\n\n"
"Please confirm your attendance in the app."
)
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending tryout reminder: {e}")
"""Send tryout reminder to player. True if it was delivered."""
message = (
"🔔 **Tryout Reminder**\n\n"
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
f"**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,
@@ -841,11 +966,6 @@ class TeamTryoutsBot(commands.Bot):
logger.warning("Cannot send response DM: no player_discord_id")
return False
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return False
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
@@ -870,31 +990,35 @@ class TeamTryoutsBot(commands.Bot):
"Please try selecting a different time slot."
)
await player_user.send(message)
logger.info(
f"Sent One on One response DM to player {player_full_name} (approved={approved})"
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 True
return sent is not None
except Exception as e:
logger.error(f"Error sending One on One response DM: {e}")
return False
async def send_one_on_one_reminder(self, player, session):
"""Send One on One reminder to player."""
try:
player_user = await self.fetch_user(int(player.discord_user_id))
message = (
"🔔 **One on One Reminder**\n\n"
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
f"**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!"
)
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending One on One reminder: {e}")
"""Send One on One reminder to player. True if it was delivered."""
message = (
"🔔 **One on One Reminder**\n\n"
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
f"**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
+34 -8
View File
@@ -143,15 +143,41 @@ http {
}
# ---------------------------------------------------------------------
# Static Files (served directly by Nginx for performance)
# Uncomment and adjust path if you want Nginx to serve static files
# Static Files (PERF-006)
#
# 59 KB of CSS and JS on every page load, previously proxied through
# Waitress. Nginx serves them from disk instead.
#
# ADJUST THIS ONE PATH to the deployment's checkout, absolute, forward
# slashes even on Windows. Nginx resolves a relative path against its
# own install prefix, not against this file. The trailing slash on both
# the location and the alias is required: without it /static/css/x.css
# resolves one directory too high.
#
# `immutable` is safe here and only here: url_for('static', …) appends
# ?v=<mtime> (see version_static_urls in app/app.py), so a deployed file
# is requested under a new URL and the cached copy of the old one is
# never asked for again. Removing that stamp and leaving this block
# gives every visitor a month-old stylesheet.
# ---------------------------------------------------------------------
# location /static/ {
# alias C:/path/to/team-tryouts/static/;
# expires 30d;
# add_header Cache-Control "public, immutable";
# access_log off;
# }
location /static/ {
alias C:/team-tryouts/app/static/;
expires 30d;
access_log off;
# These three are repeated on purpose. In nginx, add_header is
# inherited from the enclosing block ONLY when the current block
# declares none of its own — one add_header here silently drops
# every security header set at server level. Dropping nosniff on
# the JavaScript is the one that matters.
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# A missing static file must 404, not fall through to Flask: the
# fallthrough would hide a broken deploy behind a working page.
try_files $uri =404;
}
# ---------------------------------------------------------------------
# Rate Limiting