Author SHA1 Message Date
cedrick2711 e53bc3b80d first cleanup iter
CI - Security, Lint & Tests / validate (push) Failing after 25m59s
2026-09-09 08:26:47 -04:00
cedrick2711 101e3ae880 First cleanup iteration 2026-09-09 08:26:40 -04:00
59 changed files with 2319 additions and 4251 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

+2 -2
View File
@@ -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
+29
View File
@@ -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',
]
+702
View File
@@ -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.
+45
View File
@@ -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'
+128
View File
@@ -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}',
)
+102
View File
@@ -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',
)
+44
View File
@@ -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
+25 -1649
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

+4 -18
View File
@@ -324,9 +324,8 @@ def _create_database():
def _stop(*_args):
"""Stop the PostgreSQL server with a fast shutdown.
Registered as an ``atexit`` handler so it fires on normal exit. Signal
handling goes through ``_handle_stop_signal``, which also stops the
server but then re-raises so the process actually exits.
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
normal exit, and terminal close.
"""
global _started
global _stopped_system_service_name
@@ -350,19 +349,6 @@ def _stop(*_args):
_start_system_service(srv)
def _handle_stop_signal(signum, frame):
"""Stop PostgreSQL, then let the interpreter shut down normally.
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
system service, then returns without exiting, leaving the dev server
running against a stopped database and the terminal frozen. Re-raising
``KeyboardInterrupt`` hands control back to the framework, which exits;
the ``atexit`` registration then runs ``_stop`` again (idempotent).
"""
_stop()
raise KeyboardInterrupt
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -434,9 +420,9 @@ def start() -> str:
_started = True
atexit.register(_stop)
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _handle_stop_signal)
signal.signal(signal.SIGINT, _stop)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
return DATABASE_URL
+2 -9
View File
@@ -61,14 +61,7 @@ class OrgTeam(db.Model):
return [tp.player for tp in self.team_players]
def get_players_with_status(self):
rows = [
{
'player': tp.player,
'status': tp.status,
'position': tp.position,
'is_team_representative': tp.is_team_representative,
}
return [
{'player': tp.player, 'status': tp.status, 'position': tp.position}
for tp in self.team_players
]
# The responsible heads the roster; everyone else keeps roster order.
return sorted(rows, key=lambda row: not row['is_team_representative'])
-4
View File
@@ -12,10 +12,6 @@ class TeamPlayer(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter')
#: A player an admin named as their team's responsible ("joueur
#: responsable") — see app/permissions.py. Grants match-management
#: rights for that one team and nothing else.
is_team_representative = db.Column(db.Boolean, nullable=False, default=False)
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=utc_now_naive)
-3
View File
@@ -29,9 +29,6 @@ class Admin(User):
def can_manage_this_org_team(self, org_team):
return True
def can_designate_team_representative(self):
return True
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
-15
View File
@@ -103,20 +103,5 @@ class User(UserMixin, db.Model):
def can_manage_this_org_team(self, org_team):
return False
def can_manage_team_matches(self, org_team):
"""Whether the user may manage this team's matches and practices.
Broader than :meth:`can_manage_this_org_team`: a player named as the
team's responsible joins whoever administers it. The rule itself
lives in ``app.permissions``.
"""
from app.permissions import can_manage_team_matches
return can_manage_team_matches(self, org_team)
def can_designate_team_representative(self):
"""Whether the user may name a team's responsible. President only."""
return False
def get_visible_tryouts(self):
return []
-72
View File
@@ -158,78 +158,6 @@ def can_manage_org_team(user, org_team):
return bool(org_team) and user.can_manage_this_org_team(org_team)
# ---------------------------------------------------------------------------
# Team representatives ("joueurs responsables")
# ---------------------------------------------------------------------------
def is_team_representative(user, org_team):
"""Whether the user is the named responsible of this organisation team.
Args:
user: The acting user.
org_team: The team concerned.
Returns:
bool
"""
if not user or not org_team:
return False
from app.models import TeamPlayer
return (
TeamPlayer.query.filter_by(
player_id=user.id,
org_team_id=org_team.id,
is_team_representative=True,
).first()
is not None
)
def representative_org_teams(user):
"""Organisation teams the user is the responsible of, ordered by name.
Args:
user: The acting user.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import OrgTeam, TeamPlayer
if not user:
return []
return (
OrgTeam.query.join(TeamPlayer, TeamPlayer.org_team_id == OrgTeam.id)
.filter(
TeamPlayer.player_id == user.id,
TeamPlayer.is_team_representative.is_(True),
)
.order_by(OrgTeam.name)
.all()
)
def can_manage_team_matches(user, org_team):
"""Whether the user may manage this team's matches and practices.
The staff rule (see :func:`can_manage_org_team`) *plus* the player named
as the team's responsible. Deliberately narrower than
``can_manage_org_team``: a responsible schedules and edits matches, but
does not administer the roster.
Args:
user: The acting user.
org_team: The team concerned.
Returns:
bool
"""
return can_manage_org_team(user, org_team) or is_team_representative(user, org_team)
# ---------------------------------------------------------------------------
# Reach over players
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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,
)
+122 -296
View File
@@ -3,13 +3,11 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
import math
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from sqlalchemy import false, func, select
from sqlalchemy import func
from sqlalchemy.orm import aliased
from app.extensions import db
@@ -18,23 +16,53 @@ from app.models import (
EVALUATION_CRITERIA,
GAME_POSITIONS,
Admin,
Coach,
Evaluation,
Manager,
Player,
Scout,
Tryout,
TryoutRegistration,
User,
)
from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE
from app.pagination import paginate
from app.responders import respond_after_change
from app.time_utils import utc_now_naive
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}
@@ -43,286 +71,95 @@ def _users_by_id(user_ids):
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
def validate_score(score_value):
"""Turn a raw form value into a criterion score (1..10), or None.
A blank value means "not assessed". Anything else outside 1..10 is a
validation error, matching the single-player form's ``EvaluationSchema``
-- a coach must not be able to quietly turn 11 or 0 into "no score"
(QUA-003).
"""
if score_value is None or str(score_value).strip() == '':
return None
try:
score = int(score_value)
except (TypeError, ValueError) as exc:
raise ValidationError('A score must be a whole number from 1 to 10.') from exc
if not 1 <= score <= 10:
raise ValidationError('Scores run from 1 to 10.')
return score
def _apply_evaluation(evaluation, scores, comments, position):
"""Write validated scores, comments and position onto an Evaluation."""
evaluation.apply_scores(scores)
evaluation.comments = comments
evaluation.position_recommendation = position
def _visible_evaluations_query(user):
"""Evaluations the acting user is allowed to see, as a query.
Admins and scouts see everything. Managers and coaches see the
evaluations authored by managers and coaches inside the tryouts they
manage; players never reach the evaluation pages.
"""
if isinstance(user, (Admin, Scout)):
return Evaluation.query
tryout_ids = [t.id for t in user.get_visible_tryouts()]
if not tryout_ids:
return Evaluation.query.filter(false())
evaluator_alias = aliased(User, name='visible_evaluator')
return (
Evaluation.query
.join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(evaluator_alias.role.in_(['manager', 'coach']))
.filter(Evaluation.tryout_id.in_(tryout_ids))
)
def _visible_evaluation_ids(user):
"""A ``select`` of the ids of evaluations the acting user may see."""
ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery()
return select(ids.c.id)
def _average(values):
"""Mean of the non-None values, rounded to one decimal (or None)."""
values = [value for value in values if value is not None]
if not values:
return None
return round(sum(values) / len(values), 1)
def _aggregate(evaluations):
"""Per-criterion averages plus a combined overall average."""
criterion_averages = [
(label, _average([getattr(e, field) for e in evaluations]))
for field, label in EVALUATION_CRITERIA
]
return {
'criterion_averages': criterion_averages,
'combined_average': _average([e.overall_score for e in evaluations]),
'count': len(evaluations),
}
def _evaluation_view(evaluation, evaluator, tryout):
"""Shape an Evaluation for the templates (scores in criterion order)."""
return {
'evaluation': evaluation,
'evaluator': evaluator,
'tryout': tryout,
'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA],
'overall': evaluation.overall_score,
'position': evaluation.position_recommendation,
'comment': evaluation.comments,
'date': evaluation.created_at,
}
def _players_by_average(user):
"""Players with visible evaluations, best combined average first."""
visible_ids = _visible_evaluation_ids(user)
rows = (
db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.filter(Evaluation.id.in_(visible_ids))
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in rows)
ordered = [
(players_by_id[row.player_id], row.avg_score)
for row in rows
if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player)
]
ordered.sort(
key=lambda pair: (
pair[1] is None,
-(pair[1] if pair[1] is not None else 0),
pair[0].username.lower(),
)
)
return ordered
@evaluations_bp.route('')
@login_required
def list_evaluations():
"""List player evaluations grouped by player, with a per-player view."""
"""List all evaluations accessible to the current user."""
user = current_user
if isinstance(user, Player):
flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard'))
sort_column = request.args.get('sort', 'overall_score')
sort_column = request.args.get('sort', 'created_at')
sort_order = request.args.get('order', 'desc')
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'
visible_ids = _visible_evaluation_ids(user)
player_alias = aliased(User, name='eval_player')
evaluator_alias = aliased(User, name='eval_evaluator')
aggregate = (
db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
func.max(Evaluation.created_at).label('last_eval'),
sort_map = {
'tryout': Tryout.title,
'player': player_alias.username,
'evaluator': evaluator_alias.username,
'mecanics_score': Evaluation.mecanics_score,
'cohesion_score': Evaluation.cohesion_score,
'communication_score': Evaluation.communication_score,
'gamesense_score': Evaluation.gamesense_score,
'versatility_score': Evaluation.versatility_score,
'discipline_score': Evaluation.discipline_score,
'analysis_score': Evaluation.analysis_score,
'sport_ethics_score': Evaluation.sport_ethics_score,
'mental_score': Evaluation.mental_score,
'overall_score': Evaluation.overall_score,
'position_recommendation': Evaluation.position_recommendation,
'created_at': Evaluation.created_at,
}
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
if sort_order == 'asc':
sort_expr = sort_expr.asc()
else:
sort_expr = sort_expr.desc()
if isinstance(user, Admin):
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr, Evaluation.id)
)
.filter(Evaluation.id.in_(visible_ids))
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in aggregate)
grouped = []
for row in aggregate:
player = players_by_id.get(row.player_id)
if not player or not isinstance(player, Player):
continue
grouped.append(
{
'player': player,
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
'count': row.eval_count,
'last_eval': row.last_eval,
'evaluations': [],
}
)
def sort_value(entry):
if sort_column == 'player':
return entry['player'].username.lower()
if sort_column == 'count':
return entry['count']
if sort_column == 'created_at':
return entry['last_eval'] or utc_now_naive()
return entry['avg'] if entry['avg'] is not None else float('-inf')
grouped.sort(key=sort_value, reverse=(sort_order == 'desc'))
page = request.args.get('page', 1, type=int) or 1
page = max(1, page)
per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE
per_page = max(1, min(per_page, MAX_PER_PAGE))
total = len(grouped)
pages = math.ceil(total / per_page) if total else 1
page = min(page, pages)
start = (page - 1) * per_page
page_items = grouped[start:start + per_page]
players_on_page = {entry['player'].id: entry for entry in page_items}
if players_on_page:
evaluations = (
Evaluation.query
.filter(Evaluation.player_id.in_(players_on_page))
.filter(Evaluation.id.in_(visible_ids))
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
avg_scores = (
db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.all()
)
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
tryouts_by_id = (
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
if tryout_ids
else {}
players_by_id = _users_by_id(row.player_id for row in avg_scores)
player_scores = {}
for row in avg_scores:
p = players_by_id.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
else:
# Everyone still here evaluates: players were redirected above, and
# can_evaluate() is true for the four remaining roles. The former
# `else` branch listed evaluations *received* — a player's view,
# unreachable from this point (ARCH-007).
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(Evaluation.evaluator_id == user.id)
.order_by(sort_expr, Evaluation.id)
)
for evaluation in evaluations:
players_on_page[evaluation.player_id]['evaluations'].append(
_evaluation_view(
evaluation,
evaluators_by_id.get(evaluation.evaluator_id),
tryouts_by_id.get(evaluation.tryout_id),
)
)
pagination = {
'page': page,
'pages': pages,
'total': total,
'has_prev': page > 1,
'has_next': page < pages,
'prev_num': page - 1,
'next_num': page + 1,
}
player_scores = {}
return render_template(
'pages/evaluations.html',
players=page_items,
pagination=pagination,
evaluations=evaluations_page.items,
pagination=evaluations_page,
player_scores=player_scores,
sort_column=sort_column,
sort_order=sort_order,
evaluation_criteria=EVALUATION_CRITERIA,
first_player_id=grouped[0]['player'].id if grouped else None,
)
@evaluations_bp.route('/player/<int:player_id>')
@login_required
def player_evaluations(player_id):
"""One player's evaluations, with prev/next navigation between players."""
user = current_user
if not user.can_evaluate():
flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard'))
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only view evaluations for players.'), 'danger')
return redirect(url_for('evaluations.list_evaluations'))
ordered = _players_by_average(user)
index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None)
prev_player = ordered[index - 1][0] if index is not None and index > 0 else None
next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None
visible_ids = _visible_evaluation_ids(user)
evaluations = (
Evaluation.query
.filter(Evaluation.player_id == player.id)
.filter(Evaluation.id.in_(visible_ids))
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
.all()
)
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
tryouts_by_id = (
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
if tryout_ids
else {}
)
evaluation_views = [
_evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id))
for e in evaluations
]
aggregates = _aggregate(evaluations)
return render_template(
'pages/player_evaluations.html',
player=player,
evaluations=evaluation_views,
prev_player=prev_player,
next_player=next_player,
criterion_averages=aggregates['criterion_averages'],
combined_average=aggregates['combined_average'],
evaluation_count=aggregates['count'],
evaluation_criteria=EVALUATION_CRITERIA,
)
@@ -514,38 +351,27 @@ def batch_evaluate(tryout_id):
if request.method == 'POST':
saved = 0
try:
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
except ValidationError:
db.session.rollback()
flash(_('Scores must be whole numbers from 1 to 10.'), 'danger')
return render_template(
'pages/batch_evaluate.html',
tryout=tryout,
players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS,
)
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success')
+7 -3
View File
@@ -224,6 +224,7 @@ def api_events():
for match in matches:
tryout = tryouts_by_id[match.tryout_id]
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# 'description' used to be participants_str + '<br>' + description.
# Building presentation markup inside a JSON field is what carried
# the stored XSS: the browser dropped it straight into innerHTML,
@@ -254,6 +255,7 @@ def api_events():
'id': f'match_{match.id}',
'title': match.title,
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
@@ -299,6 +301,7 @@ def api_events():
'id': f'team_match_{team_match.id}',
'title': team_match.title,
'type': 'team_match',
'color': '#3b82f6',
'extendedProps': {
'location': team_match.location or 'TBD',
'status': team_match.status,
@@ -331,6 +334,7 @@ def api_events():
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
@@ -645,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,
@@ -667,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(
+7 -8
View File
@@ -23,7 +23,7 @@ from app.models import (
TeamPlayer,
)
from app.pagination import paginate
from app.permissions import coach_org_teams, representative_org_teams, visible_org_teams
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.responders import respond_after_change
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
@@ -36,11 +36,10 @@ team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches'
def can_manage_team_match(team):
"""Whether the current user can manage matches for this team.
Everyone who administers the team, plus the player named as its
responsible. Narrower than administering the team itself: a responsible
schedules and edits matches, but does not touch the roster.
Same rule as administering the team itself, so it is the same call.
This function used to restate it, and the restatement drifted.
"""
return current_user.can_manage_team_matches(team)
return can_manage_org_team(current_user, team)
def season_locked():
@@ -301,13 +300,13 @@ def delete_match(match_id):
@login_required
def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for."""
if not current_user.can_schedule_matches():
return jsonify([])
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = coach_org_teams(current_user)
elif isinstance(current_user, Player):
# A responsible schedules matches for their own team(s) only.
teams = representative_org_teams(current_user)
else:
return jsonify([])
+3 -35
View File
@@ -26,7 +26,7 @@ from app.models import (
Tryout,
User,
)
from app.permissions import representative_org_teams, visible_org_teams
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
@@ -40,12 +40,10 @@ def list_teams():
"""List all organization teams visible to the current user."""
can_manage = current_user.can_manage_teams()
# A player reaches this page only as a team's responsible; anyone else
# is sent to their own view.
if isinstance(current_user, Player) and not representative_org_teams(current_user):
if isinstance(current_user, Player):
flash(_('Use My Team(s) to view your teams.'), 'info')
return redirect(url_for('teams.my_teams'))
if not isinstance(current_user, (Admin, Coach, Manager, Player)):
if not isinstance(current_user, (Admin, Coach, Manager)):
flash(_('You do not have permission to view teams.'), 'danger')
return redirect(url_for('main.dashboard'))
@@ -565,36 +563,6 @@ def toggle_player_status(team_id, player_id):
)
@teams_bp.route('/<int:team_id>/toggle_representative/<int:player_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_player_representative(team_id, player_id):
"""Name or un-name a player as their team's responsible (president only).
The responsible gains match-management rights for this one team. The
flag itself stays a president's decision: this is deliberately the one
roster action a responsible cannot perform on their own team.
"""
db.get_or_404(OrgTeam, team_id)
if not current_user.can_designate_team_representative():
return jsonify({'error': 'Permission denied'}), 403
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
return jsonify({'error': 'Player not found on this team'}), 404
tp.is_team_representative = not tp.is_team_representative
db.session.commit()
return jsonify(
{
'success': True,
'player_id': player_id,
'is_team_representative': tp.is_team_representative,
'player_name': tp.player.username,
}
)
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required
def add_team_note(team_id):
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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__)
+15 -189
View File
@@ -1043,10 +1043,6 @@ a:hover { color: var(--primary-dark); }
.fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--fc-border-color: var(--gray-200);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: var(--gray-50);
--fc-today-bg-color: var(--info-light);
}
.fc-toolbar {
@@ -1059,29 +1055,24 @@ a:hover { color: var(--primary-dark); }
color: var(--gray-900);
}
.fc .fc-button {
background: var(--gray-50) !important;
border: 1px solid var(--gray-200) !important;
color: var(--gray-700) !important;
.fc-button {
background: var(--primary) !important;
border: none !important;
border-radius: var(--radius-sm) !important;
padding: 6px 12px !important;
font-size: 0.85rem !important;
}
.fc .fc-button:hover {
background: var(--gray-100) !important;
color: var(--gray-900) !important;
.fc-button:hover {
background: var(--primary-dark) !important;
}
.fc .fc-button-primary:not(:disabled).fc-button-active,
.fc .fc-button-primary:not(:disabled):active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
.fc-button-primary:not(:disabled).fc-button-active,
.fc-button-primary:not(:disabled):active {
background: var(--primary-dark) !important;
}
.fc-daygrid-day,
.fc-timegrid-slot {
.fc-daygrid-day {
transition: var(--transition);
}
@@ -1089,111 +1080,16 @@ a:hover { color: var(--primary-dark); }
background: var(--gray-50);
}
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
its inline event colors) */
#calendar .fc-event {
border: none !important;
border-radius: var(--radius-sm) !important;
padding: 3px 6px !important;
font-size: 0.8rem !important;
background-color: var(--fc-event-bg, var(--primary)) !important;
color: #fff !important;
box-shadow: var(--shadow);
.fc-event {
border-radius: var(--radius-sm);
padding: 2px 4px;
font-size: 0.8rem;
}
#calendar .fc-event-body {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
.fc-event-title {
font-weight: 600;
}
#calendar .fc-event-body i {
flex: 0 0 auto;
font-size: 0.7rem;
opacity: 0.9;
}
#calendar .fc-event-text {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 4px;
min-width: 0;
}
#calendar .fc-event-time {
font-size: 0.72rem;
font-weight: 700;
}
#calendar .fc-event-title {
font-weight: 600 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Event type colors (semantic, light theme) */
#calendar .fc-event--team { --fc-event-bg: #10b981; }
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
#calendar .fc-now-indicator {
border-color: var(--danger);
}
/* Dark theme: the calendar chrome reacts to the light/dark switch */
[data-theme="dark"] .fc {
--fc-border-color: var(--border-color);
--fc-neutral-bg-color: var(--bg-secondary);
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .fc-toolbar-title {
color: var(--text-primary);
}
[data-theme="dark"] .fc .fc-button {
background: var(--bg-tertiary) !important;
border-color: var(--border-color) !important;
color: var(--text-secondary) !important;
}
[data-theme="dark"] .fc .fc-button:hover {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
[data-theme="dark"] .fc-daygrid-day:hover {
background: var(--bg-tertiary);
}
[data-theme="dark"] .fc-daygrid-day-number,
[data-theme="dark"] .fc-col-header-cell-cushion,
[data-theme="dark"] .fc-timegrid-axis-cushion,
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
color: var(--text-secondary);
}
/* Pastel event chips with dark text keep contrast on a dark background. */
[data-theme="dark"] #calendar .fc-event {
color: var(--gray-900) !important;
}
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
.btn-group {
display: flex;
gap: 4px;
@@ -2226,73 +2122,3 @@ a:hover { color: var(--primary-dark); }
grid-template-columns: 1fr;
}
}
/* Evaluation comments - separate grid so long notes stay readable */
.eval-comments {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin-top: 16px;
}
.eval-comments-title {
grid-column: 1 / -1;
margin: 0 0 4px;
font-size: 0.95rem;
font-weight: 600;
}
.eval-comment {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius-sm);
padding: 12px 14px;
}
.eval-comment-text {
margin: 0 0 10px;
white-space: pre-wrap;
word-break: break-word;
color: var(--gray-800);
}
.eval-comment-meta {
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--gray-500);
}
.eval-comment-author strong {
color: var(--gray-700);
font-weight: 600;
}
[data-theme="dark"] .eval-comment {
background: var(--bg-tertiary);
border-color: var(--border-color);
}
[data-theme="dark"] .eval-comment-text {
color: var(--text-primary);
}
[data-theme="dark"] .eval-comment-meta {
color: var(--text-muted);
}
[data-theme="dark"] .eval-comment-author strong {
color: var(--text-secondary);
}
.eval-comments-empty {
margin: 8px 0 0;
color: var(--gray-500);
font-size: 0.9rem;
}
[data-theme="dark"] .eval-comments-empty {
color: var(--text-muted);
}
+2 -47
View File
@@ -137,18 +137,12 @@ document.addEventListener('DOMContentLoaded', function() {
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: ''
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
},
events: '/matches/api/events',
eventClick: function(info) {
showEventModal(info.event);
},
eventClassNames: function(arg) {
return [eventClassName(arg.event)];
},
eventContent: function(arg) {
return renderEventContent(arg);
},
dateClick: function(info) {
if (canScheduleMatches) {
var dateStr = info.dateStr;
@@ -164,11 +158,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
},
slotMinTime: '12:00:00',
slotMaxTime: '24:00:00',
slotEventOverlap: true,
dayMaxEvents: false,
nowIndicator: true,
height: 'auto'
slotMaxTime: '24:00:00'
});
calendar.render();
@@ -187,41 +177,6 @@ function changeView(viewName) {
}
}
function eventClassName(event) {
var props = event.extendedProps || {};
if (props.type === 'team_match') { return 'fc-event--team-match'; }
if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; }
return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim';
}
function eventIcon(type, matchType) {
if (type === 'team_match') { return 'fa-trophy'; }
if (type === 'one_on_one') { return 'fa-user-friends'; }
if (matchType === 'team_vs_team') { return 'fa-shield-alt'; }
if (matchType === 'player_vs_player') { return 'fa-users'; }
return 'fa-gamepad';
}
function renderEventContent(arg) {
// Built with DOM nodes + textContent: the title comes from a JSON endpoint
// and has never been HTML-escaped, so it must not reach innerHTML.
var props = arg.event.extendedProps || {};
var wrap = makeEl('div', 'fc-event-body');
var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type));
icon.setAttribute('aria-hidden', 'true');
wrap.appendChild(icon);
var text = makeEl('span', 'fc-event-text');
if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) {
text.appendChild(makeEl('span', 'fc-event-time', arg.timeText));
}
text.appendChild(makeEl('span', 'fc-event-title', arg.event.title));
wrap.appendChild(text);
return { domNodes: [wrap] };
}
// --- Create Event Modal ---
function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr;
+54 -122
View File
@@ -5,7 +5,7 @@
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
{% block content %}
{% set sort_column = sort_column | default('overall_score') %}
{% set sort_column = sort_column | default('created_at') %}
{% set sort_order = sort_order | default('desc') %}
{% macro sort_link(column, label) %}
@@ -22,111 +22,76 @@
</th>
{% endmacro %}
<div class="view-toggle mb-3">
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-primary">
<i class="fas fa-table"></i> {{ _('Table') }}
</a>
{% if first_player_id %}
<a href="{{ url_for('evaluations.player_evaluations', player_id=first_player_id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-user"></i> {{ _('By player') }}
</a>
{% endif %}
{% if current_user.role == 'admin' and player_scores %}
<div class="stats-grid mb-4">
{% for pid, data in player_scores.items() %}
<div class="stat-card stat-card-sm">
<div class="stat-icon bg-info">
<i class="fas fa-user"></i>
</div>
<div class="stat-info">
<h3>{{ data.player.username[:12] }}</h3>
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clipboard-list"></i> {{ _('Player Evaluations') }}</h3>
<h3><i class="fas fa-clipboard-list"></i>
{% if current_user.role == 'player' %}
My Evaluations
{% else %}
All Evaluations
{% endif %}
</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th></th>
{{ sort_link('tryout', 'Tryout') }}
{{ sort_link('player', 'Player') }}
{{ sort_link('overall_score', 'Average') }}
{{ sort_link('count', 'Evaluations') }}
<th>{{ _('Open') }}</th>
{{ sort_link('evaluator', 'Evaluator') }}
{{ sort_link('mecanics_score', 'Mecanics') }}
{{ sort_link('cohesion_score', 'Cohesion') }}
{{ sort_link('communication_score', 'Communication') }}
{{ sort_link('gamesense_score', 'Gamesense') }}
{{ sort_link('versatility_score', 'Versatility') }}
{{ sort_link('discipline_score', 'Discipline') }}
{{ sort_link('analysis_score', 'Analysis') }}
{{ sort_link('sport_ethics_score', 'Sport Ethics') }}
{{ sort_link('mental_score', 'Mental') }}
{{ sort_link('overall_score', 'Overall') }}
{{ sort_link('position_recommendation', 'Position') }}
{{ sort_link('created_at', 'Date') }}
</tr>
</thead>
<tbody>
{% for entry in players %}
<tr class="eval-row" data-action="toggle-player-evals" data-target="evals-{{ entry.player.id }}">
<td><i class="fas fa-chevron-right eval-chevron"></i></td>
<td>
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="player-link">
{{ entry.player.username }}
</a>
</td>
<td><span class="score">{{ entry.avg | round(1) if entry.avg is not none else '-' }}</span></td>
<td>{{ entry.count }}</td>
<td>
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
<i class="fas fa-external-link-alt"></i>
</a>
</td>
</tr>
<tr class="eval-detail hidden" id="evals-{{ entry.player.id }}">
<td colspan="5">
<div class="eval-detail-inner">
<table class="table table-sm">
<thead>
<tr>
<th>{{ _('Evaluator') }}</th>
<th>{{ _('Tryout') }}</th>
{% for field, label in evaluation_criteria %}
<th>{{ label }}</th>
{% endfor %}
<th>{{ _('Overall') }}</th>
<th>{{ _('Position') }}</th>
<th>{{ _('Date') }}</th>
</tr>
</thead>
<tbody>
{% for ev in entry.evaluations %}
<tr>
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
{% for score in ev.scores %}
<td>{{ score if score is not none else '-' }}</td>
{% endfor %}
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
<td>{{ ev.position or '-' }}</td>
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if entry.evaluations %}
{% set comment_evals = entry.evaluations | selectattr('comment') | list %}
<div class="eval-comments">
<h4 class="eval-comments-title"><i class="fas fa-comments"></i> {{ _('Comments') }}</h4>
{% if comment_evals %}
{% for ev in comment_evals %}
<article class="eval-comment">
<p class="eval-comment-text">{{ ev.comment }}</p>
<footer class="eval-comment-meta">
<span class="eval-comment-author">{{ _('Evaluator') }}&nbsp;: <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} &middot; {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
</footer>
</article>
{% endfor %}
{% else %}
<p class="eval-comments-empty">{{ _('No comments') }}</p>
{% endif %}
</div>
{% endif %}
</div>
</td>
{% for eval in evaluations %}
<tr>
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td>
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td>
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td>
<td>{{ eval.mecanics_score or '-' }}</td>
<td>{{ eval.cohesion_score or '-' }}</td>
<td>{{ eval.communication_score or '-' }}</td>
<td>{{ eval.gamesense_score or '-' }}</td>
<td>{{ eval.versatility_score or '-' }}</td>
<td>{{ eval.discipline_score or '-' }}</td>
<td>{{ eval.analysis_score or '-' }}</td>
<td>{{ eval.sport_ethics_score or '-' }}</td>
<td>{{ eval.mental_score or '-' }}</td>
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td>
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center">
<td colspan="15" class="text-center">
<div class="empty-state">
<i class="fas fa-clipboard"></i>
<h3>{{ _('No evaluations yet') }}</h3>
@@ -141,36 +106,3 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
.view-toggle { display: flex; gap: 8px; }
.eval-row { cursor: pointer; }
.eval-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
.eval-detail-inner { padding: 8px 4px; }
.eval-detail-inner .table { margin-bottom: 0; }
.player-nav { display: flex; gap: 8px; align-items: center; }
.is-disabled { opacity: 0.5; pointer-events: none; }
</style>
<script nonce="{{ csp_nonce }}">
function togglePlayerEvals(element, event) {
if (event.target.closest('a')) {
return;
}
var detail = document.getElementById(element.getAttribute('data-target'));
if (!detail) {
return;
}
var chevron = element.querySelector('.eval-chevron');
var isHidden = detail.classList.contains('hidden');
if (isHidden) {
detail.classList.remove('hidden');
if (chevron) chevron.classList.add('fa-rotate-90');
} else {
detail.classList.add('hidden');
if (chevron) chevron.classList.remove('fa-rotate-90');
}
}
registerActions({ 'toggle-player-evals': togglePlayerEvals });
</script>
{% endblock %}
-13
View File
@@ -10,16 +10,6 @@
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
<div class="card-actions">
{% if current_user.can_manage_team_matches(team) %}
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
<i class="fas fa-plus"></i> {{ _('Match') }}
</a>
<a href="{{ url_for('team_matches.create_match', team_id=team.id, type='practice') }}" class="btn btn-sm btn-info" title="{{ _('Schedule Practice') }}">
<i class="fas fa-dumbbell"></i> {{ _('Practice') }}
</a>
{% endif %}
</div>
</div>
<!-- Staff bar -->
<div class="team-staff-bar">
@@ -71,9 +61,6 @@
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
{% if entry.is_team_representative %}
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
{% endif %}
</div>
</td>
<td>
-134
View File
@@ -1,134 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ player.username }} - {{ _('Evaluations') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Player Evaluations') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('evaluations.list_evaluations') }}">{{ _('Evaluations') }}</a> / {{ player.username }}</span>{% endblock %}
{% block content %}
<div class="view-toggle mb-3">
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-outline">
<i class="fas fa-table"></i> {{ _('Table') }}
</a>
<a href="{{ url_for('evaluations.player_evaluations', player_id=player.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-user"></i> {{ _('By player') }}
</a>
</div>
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-user"></i> {{ player.username }}</h3>
<div class="player-nav">
{% if prev_player %}
<a href="{{ url_for('evaluations.player_evaluations', player_id=prev_player.id) }}" class="btn btn-sm btn-secondary" title="{{ prev_player.username }}">
<i class="fas fa-chevron-left"></i> {{ prev_player.username }}
</a>
{% else %}
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-left"></i></span>
{% endif %}
{% if next_player %}
<a href="{{ url_for('evaluations.player_evaluations', player_id=next_player.id) }}" class="btn btn-sm btn-secondary" title="{{ next_player.username }}">
{{ next_player.username }} <i class="fas fa-chevron-right"></i>
</a>
{% else %}
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-right"></i></span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="stats-grid mb-4">
<div class="stat-card">
<div class="stat-icon bg-info"><i class="fas fa-star"></i></div>
<div class="stat-info">
<h3>{{ combined_average | round(1) if combined_average is not none else '-' }}</h3>
<p>{{ _('Combined average') }} &middot; {{ evaluation_count }} {{ _('evaluations') }}</p>
</div>
</div>
{% for label, avg in criterion_averages %}
<div class="stat-card stat-card-sm">
<div class="stat-icon bg-info"><i class="fas fa-chart-bar"></i></div>
<div class="stat-info">
<h3>{{ avg | round(1) if avg is not none else '-' }}</h3>
<p>{{ label }}</p>
</div>
</div>
{% endfor %}
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Evaluator') }}</th>
<th>{{ _('Tryout') }}</th>
{% for field, label in evaluation_criteria %}
<th>{{ label }}</th>
{% endfor %}
<th>{{ _('Overall') }}</th>
<th>{{ _('Position') }}</th>
<th>{{ _('Date') }}</th>
</tr>
</thead>
<tbody>
{% for ev in evaluations %}
<tr>
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
{% for score in ev.scores %}
<td>{{ score if score is not none else '-' }}</td>
{% endfor %}
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
<td>{{ ev.position or '-' }}</td>
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">
<div class="empty-state">
<i class="fas fa-clipboard"></i>
<h3>{{ _('No evaluations for this player yet') }}</h3>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if evaluations %}
{% set comment_evals = evaluations | selectattr('comment') | list %}
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3>
</div>
<div class="card-body">
{% if comment_evals %}
<div class="eval-comments">
{% for ev in comment_evals %}
<article class="eval-comment">
<p class="eval-comment-text">{{ ev.comment }}</p>
<footer class="eval-comment-meta">
<span class="eval-comment-author">{{ _('Evaluator') }}&nbsp;: <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} &middot; {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
</footer>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-comments"></i>
<h3>{{ _('No comments') }}</h3>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
.view-toggle { display: flex; gap: 8px; }
.player-nav { display: flex; gap: 8px; align-items: center; }
.is-disabled { opacity: 0.5; pointer-events: none; }
</style>
{% endblock %}
+1 -1
View File
@@ -93,7 +93,7 @@
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
</td>
<td class="eval-actions">
{% set can_manage_this = current_user.can_manage_team_matches(m.org_team) %}
{% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
{% if can_manage_this %}
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
<i class="fas fa-edit"></i>
+1 -44
View File
@@ -127,7 +127,7 @@
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="{{ _('View Team Matches') }}">
<i class="fas fa-futbol"></i> {{ _('Matches') }}
</a>
{% if current_user.can_manage_team_matches(team) %}
{% if current_user.can_schedule_matches() and (current_user.role in ['admin', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %}
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
<i class="fas fa-plus"></i> {{ _('Match') }}
</a>
@@ -159,9 +159,6 @@
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
{% if entry.is_team_representative %}
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
{% endif %}
</div>
</td>
<td>
@@ -183,16 +180,6 @@
<td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %}
<td>
{% if current_user.can_designate_team_representative() %}
<button type="button"
class="btn btn-sm representative-toggle-btn {% if entry.is_team_representative %}btn-warning{% else %}btn-outline{% endif %}"
data-team-id="{{ team.id }}"
data-player-id="{{ entry.player.id }}"
data-action="toggle-representative"
title="{{ _('Name as team representative') }}">
<i class="fas fa-star"></i>
</button>
{% endif %}
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
@@ -354,35 +341,6 @@ function toggleStatus(btn) {
});
}
function toggleRepresentative(btn) {
var teamId = btn.getAttribute('data-team-id');
var playerId = btn.getAttribute('data-player-id');
fetch('/teams/' + teamId + '/toggle_representative/' + playerId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
if (data.is_team_representative) {
btn.classList.remove('btn-outline');
btn.classList.add('btn-warning');
} else {
btn.classList.remove('btn-warning');
btn.classList.add('btn-outline');
}
window.TT.refreshPage(window.location.pathname);
}
})
.catch(function(error) {
console.error('Error:', error);
});
}
var currentEditTeamId = null;
document.addEventListener('DOMContentLoaded', function() {
@@ -458,7 +416,6 @@ registerActions({
'hide-create-form': hideCreateForm,
'hide-edit-form': hideEditForm,
'toggle-status': toggleStatus,
'toggle-representative': toggleRepresentative,
});
</script>
<style>
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
-15
View File
@@ -128,18 +128,3 @@ Générer la migration initiale depuis les modèles « pour avancer en
attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une
production estampillée sur un état qu'elle n'a pas, et la dérive préservée
sous une couche de plus.
## Ajouts de colonnes faits à la main
En attendant Alembic, une colonne ajoutée à un modèle doit être rattrapée sur
les bases existantes par un script, en plus du modèle (pour les installations
neuves, via `create_all()`). `migrations/add_tryout_coaches.py` a ouvert la
voie ; `migrations/add_team_representative.py` ajoute
`team_players.is_team_representative` de la même façon : lecture du catalogue,
`ALTER TABLE ... ADD COLUMN` idempotent, rien si la colonne est déjà là.
Le script trouve sa base tout seul : `DATABASE_URL` s'il est défini (production,
CI), sinon les valeurs `LOCAL_PG*` de `app/.env` — le serveur de développement
est réutilisé s'il répond, sinon (re)démarré puis arrêté à la fin du script :
python migrations/add_team_representative.py
-28
View File
@@ -64,31 +64,6 @@ voulue, elle n'est écrite nulle part ailleurs qu'ici.
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
| Recruteur, Joueur | toujours faux |
### `can_manage_team_matches(team)`
Plus étroite que `can_manage_this_org_team`, et c'est voulu : elle répond à
« peut-on gérer les matchs et les entraînements de cette équipe ? ».
| Rôle | Règle |
|---|---|
| Président, Gérant | toujours vrai |
| Coach | rattaché à l'équipe (m2m **ou** colonne héritée) |
| Joueur responsable | vrai pour **sa seule** équipe |
| Recruteur, Joueur | faux |
### Le joueur responsable
Un **joueur responsable** est un joueur qu'un **président** désigne, depuis la
page Équipes, sur la ligne de son équipe (étoile à côté du bouton de retrait).
Il peut planifier, modifier et supprimer les **matchs et entraînements** de
cette équipe, et rien d'autre : ni l'effectif, ni les notes, ni le statut
partant/remplaçant, qui restent réservés au personnel.
Le drapeau vit sur `team_players.is_team_representative` (voir
`docs/database-schema.md`). La désignation est la décision du président —
`can_designate_team_representative()`, vrai seulement pour `Admin` — pour
qu'un responsable ne puisse pas se reproduire lui-même.
## Le piège : deux façons d'être rattaché
C'est le point le plus coûteux de tout le modèle d'autorisation, et il est
@@ -142,9 +117,6 @@ contexte de requête.
| `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle |
| `visible_org_teams(user)` | Ce qu'elle a le droit de voir |
| `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe |
| `can_manage_team_matches(user, team)` | Peut-elle gérer les matchs et entraînements de cette équipe |
| `is_team_representative(user, team)` | Est-elle le responsable désigné de cette équipe |
| `representative_org_teams(user)` | Les équipes dont elle est le responsable |
| `coach_player_ids(coach)` | Les joueurs qu'il encadre |
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
-176
View File
@@ -1,176 +0,0 @@
"""Migration: add the `is_team_representative` flag to `team_players`.
A team's responsible ("joueur responsable") is a player an admin names to
manage that team's matches and practices. The flag lives on the roster row,
beside `status` and `position`.
`db.create_all()` creates missing tables and never alters an existing one, so
a database that already has `team_players` needs this script. Fresh installs
get the column from the model.
Where it connects
-----------------
The script finds its own database, so there is no URL to remember:
1. If `DATABASE_URL` is already set, it is used and nothing else is touched.
That is the production and CI path.
2. Otherwise the local values from `app/.env` are used. If the development
server in `instance/pgdata/` answers a connection it is reused as-is; if it
does not, it is (re)started and stopped again when this script exits. This
is the `LOCAL_PG=true` block of `app/.env.example`.
3. If neither is available the script explains what to set and stops.
So a laptop with the `LOCAL_PG=true` block runs the migration with a single
command:
python migrations/add_team_representative.py
Usage: python migrations/add_team_representative.py
"""
import os
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
# Loaded before anything else reads the environment: app.local_postgres reads
# LOCAL_PG_PORT / LOCAL_PG_USER / LOCAL_PG_DATABASE at import time, and without
# this they would fall back to that module's defaults instead of app/.env.
from dotenv import load_dotenv
_ENV_FILE = os.path.join(PROJECT_ROOT, 'app', '.env')
load_dotenv(_ENV_FILE if os.path.exists(_ENV_FILE) else None)
from sqlalchemy import text
from app.extensions import db
def _local_database():
"""The development URL and port, read from the LOCAL_PG_* variables."""
from app.local_postgres import DATABASE_URL, PORT
return DATABASE_URL, PORT
def _postgres_answers(url):
"""Whether `url` is a working PostgreSQL connection right now.
A bare TCP probe is not enough here: a server killed part-way through its
startup can leave the port listening while every connection is refused,
and trusting that evidence fails the migration for a reason that looks
nothing like its cause.
"""
from sqlalchemy import create_engine
from app.app import normalise_database_url
try:
engine = create_engine(
normalise_database_url(url), connect_args={'connect_timeout': 2}
)
except Exception:
return False
try:
with engine.connect():
return True
except Exception:
return False
finally:
engine.dispose()
def _stop_local_server():
"""Fast-stop the local development server, if a postmaster is alive.
Called only before (re)starting it: `pg_ctl status` calls a half-dead
postmaster "running", so `start()` would leave the broken one in place.
"""
from app.local_postgres import DATADIR, _find_binary
pg_ctl = _find_binary('pg_ctl')
if not pg_ctl:
return
subprocess.run(
[str(pg_ctl), 'stop', '-m', 'fast', '-D', str(DATADIR)],
capture_output=True,
text=True,
)
def resolve_database_url():
"""Pick the database to migrate.
Returns:
tuple[str, str]: the connection URL and a note saying where it came
from, for the log line.
Raises:
SystemExit: when neither a URL nor a usable local server is available.
"""
explicit = os.environ.get('DATABASE_URL')
if explicit:
return explicit, 'DATABASE_URL'
url, port = _local_database()
if _postgres_answers(url):
return url, f'local PostgreSQL already running on port {port}'
if os.getenv('LOCAL_PG', '').strip().lower() == 'true':
from app.local_postgres import start
_stop_local_server()
return start(), f'local PostgreSQL started on port {port}'
raise SystemExit(
'No database to migrate.\n'
' Set DATABASE_URL to a connection string, or set LOCAL_PG=true in\n'
' app/.env to use the local development server (see app/.env.example,\n'
' DEVELOPMENT block).'
)
def main():
# A migration must never start the Discord bot: two instances fight over
# the same gateway session, and neither belongs to a script whose only job
# is to run an ALTER.
os.environ['ENABLE_DISCORD_BOT'] = 'false'
url, source = resolve_database_url()
os.environ['DATABASE_URL'] = url
# Imported here, after the environment is settled, so create_app() reads
# the URL this script resolved rather than a missing one.
from app.app import create_app
print(f'[INFO] Database: {source}.')
app = create_app()
with app.app_context():
already_there = db.session.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'team_players' "
"AND column_name = 'is_team_representative'"
)
).scalar()
if already_there:
print("Column 'team_players.is_team_representative' already exists. Skipping.")
return
db.session.execute(
text(
"ALTER TABLE team_players "
"ADD COLUMN is_team_representative BOOLEAN NOT NULL DEFAULT FALSE"
)
)
db.session.commit()
print("Added 'team_players.is_team_representative'.")
if __name__ == '__main__':
main()
-143
View File
@@ -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()
-3
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
-2
View File
@@ -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()
-4
View File
@@ -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 = [
+2 -3
View File
@@ -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)
+1 -3
View File
@@ -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']
-7
View File
@@ -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
+1 -3
View File
@@ -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']
-1
View File
@@ -13,7 +13,6 @@ from app.models import (
BackupRecord,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
)
+99
View File
@@ -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}'
-58
View File
@@ -179,61 +179,3 @@ class TestThroughTheForm:
evaluation = Evaluation.query.one()
assert evaluation.mecanics_score is None
assert evaluation.overall_score == 4
class TestBatchEvaluate:
"""The batch page drives the same two helpers the single-player form does,
through ``{field}_{player_id}`` names. The helpers were lost once, in a
refactor, and the page 500'd on every POST until someone noticed."""
def _submit(self, client, setup, **fields):
data = {'player_ids': [str(setup['player_id'])]}
data.update(fields)
return client.post(
f'/evaluations/{setup["tryout_id"]}/batch',
data=data,
follow_redirects=True,
)
def test_a_valid_batch_evaluation_is_stored(self, app, client, evaluation_setup):
pid = evaluation_setup['player_id']
self._submit(
client,
evaluation_setup,
**{
f'mecanics_score_{pid}': '8',
f'mental_score_{pid}': '6',
f'comments_{pid}': 'Solid.',
},
)
with app.app_context():
evaluation = Evaluation.query.one()
assert evaluation.mecanics_score == 8
assert evaluation.overall_score == 7
assert evaluation.comments == 'Solid.'
assert evaluation.position_recommendation is None
def test_an_out_of_range_batch_score_stores_nothing(self, app, client, evaluation_setup):
pid = evaluation_setup['player_id']
self._submit(
client,
evaluation_setup,
**{f'mecanics_score_{pid}': '11', f'mental_score_{pid}': '6'},
)
with app.app_context():
assert Evaluation.query.count() == 0
def test_a_second_batch_submission_replaces_the_first(self, app, client, evaluation_setup):
pid = evaluation_setup['player_id']
self._submit(
client, evaluation_setup, **{f'mecanics_score_{pid}': '8', f'mental_score_{pid}': '4'}
)
self._submit(
client, evaluation_setup, **{f'mecanics_score_{pid}': '4', f'mental_score_{pid}': '4'}
)
with app.app_context():
evaluation = Evaluation.query.one()
assert evaluation.overall_score == 4
+158
View File
@@ -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
+133
View File
@@ -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}')
-248
View File
@@ -1,248 +0,0 @@
"""Player-specific evaluation views (the "By player" toggle and its page).
These pages regroup the flat evaluation table by player: one row per player in
the table view, and a dedicated page per player with left/right navigation and
per-criterion averages. Visibility follows the viewer's role — managers and
coaches see staff-authored evaluations inside their own tryouts.
"""
from datetime import date
import pytest
from app.extensions import db
from app.models import Evaluation, Tryout
@pytest.fixture
def staff_setup(app, as_role, make_user):
"""A coach who manages one tryout, plus staff, a player, and a foreign tryout."""
coach_id = as_role('coach', username='viewer_coach')
manager_id = make_user('manager', username='other_manager')
admin_id = make_user('admin', username='the_admin')
player_id = make_user('player', username='the_player')
outsider_id = make_user('coach', username='outsider_coach')
with app.app_context():
tryout = Tryout(
title='Spring',
game='Valorant',
date=date(2030, 4, 1),
created_by=coach_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
other_tryout = Tryout(
title='Other',
game='Valorant',
date=date(2030, 5, 1),
created_by=outsider_id,
)
db.session.add(other_tryout)
db.session.commit()
return {
'coach_id': coach_id,
'manager_id': manager_id,
'admin_id': admin_id,
'player_id': player_id,
'tryout_id': tryout.id,
'other_tryout_id': other_tryout.id,
}
class TestVisibility:
def test_coach_sees_staff_evaluations_only_in_their_tryout(self, app, client, staff_setup):
with app.app_context():
db.session.add(
Evaluation(
tryout_id=staff_setup['tryout_id'],
player_id=staff_setup['player_id'],
evaluator_id=staff_setup['manager_id'],
overall_score=7,
comments='manager in own tryout',
)
)
db.session.add(
Evaluation(
tryout_id=staff_setup['tryout_id'],
player_id=staff_setup['player_id'],
evaluator_id=staff_setup['admin_id'],
overall_score=5,
comments='admin authored',
)
)
db.session.add(
Evaluation(
tryout_id=staff_setup['other_tryout_id'],
player_id=staff_setup['player_id'],
evaluator_id=staff_setup['manager_id'],
overall_score=9,
comments='manager other tryout',
)
)
db.session.commit()
body = client.get(f"/evaluations/player/{staff_setup['player_id']}").get_data(as_text=True)
assert 'manager in own tryout' in body
assert 'admin authored' not in body
assert 'manager other tryout' not in body
def test_admin_sees_every_evaluation(self, app, client, as_role, make_user):
admin_id = as_role('admin', username='boss')
coach_id = make_user('coach', username='some_coach')
player_id = make_user('player', username='star')
with app.app_context():
tryout = Tryout(
title='T',
game='Valorant',
date=date(2030, 1, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
db.session.add(
Evaluation(
tryout_id=tryout.id,
player_id=player_id,
evaluator_id=coach_id,
overall_score=6,
comments='coach comment',
)
)
db.session.add(
Evaluation(
tryout_id=tryout.id,
player_id=player_id,
evaluator_id=admin_id,
overall_score=4,
comments='admin comment',
)
)
db.session.commit()
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
assert 'coach comment' in body
assert 'admin comment' in body
def test_a_player_cannot_open_the_player_page(self, client, as_role):
player_id = as_role('player')
response = client.get(f'/evaluations/player/{player_id}', follow_redirects=False)
assert response.status_code in (301, 302)
class TestAggregates:
def test_combined_and_per_criterion_averages(self, app, client, as_role, make_user):
coach_id = as_role('coach', username='agg_coach')
coach2_id = make_user('coach', username='agg_coach2')
player_id = make_user('player', username='agg_player')
with app.app_context():
tryout = Tryout(
title='Agg',
game='Valorant',
date=date(2030, 1, 1),
created_by=coach_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
first = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach_id)
first.apply_scores({'mecanics_score': 8, 'mental_score': 6})
second = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach2_id)
second.apply_scores({'mecanics_score': 4, 'mental_score': 8})
db.session.add_all([first, second])
db.session.commit()
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
# overall: (7 + 6) / 2 = 6.5; mecanics: 6.0; mental: 7.0.
assert '6.5' in body
assert '6.0' in body
assert '7.0' in body
class TestNavigation:
def test_players_are_ordered_best_first_with_arrows(self, app, client, as_role, make_user):
coach_id = as_role('coach', username='order_coach')
high = make_user('player', username='zzz_high')
mid = make_user('player', username='aaa_mid')
low = make_user('player', username='mmm_low')
with app.app_context():
tryout = Tryout(
title='Order',
game='Valorant',
date=date(2030, 1, 1),
created_by=coach_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
for player_id, score in [(high, 8), (mid, 5), (low, 3)]:
db.session.add(
Evaluation(
tryout_id=tryout.id,
player_id=player_id,
evaluator_id=coach_id,
overall_score=score,
)
)
db.session.commit()
best = client.get(f'/evaluations/player/{high}').get_data(as_text=True)
middle = client.get(f'/evaluations/player/{mid}').get_data(as_text=True)
worst = client.get(f'/evaluations/player/{low}').get_data(as_text=True)
# Best: no previous (left arrow disabled), next is the middle player.
assert 'aria-disabled="true"' in best
assert f'/evaluations/player/{mid}' in best
assert f'/evaluations/player/{low}' not in best
# Middle: both arrows active, pointing at the neighbours.
assert f'/evaluations/player/{high}' in middle
assert f'/evaluations/player/{low}' in middle
assert 'aria-disabled="true"' not in middle
# Worst: no next (right arrow disabled), previous is the middle player.
assert 'aria-disabled="true"' in worst
assert f'/evaluations/player/{mid}' in worst
assert f'/evaluations/player/{high}' not in worst
class TestTableView:
def test_table_groups_by_player_and_keeps_the_comment(self, app, client, as_role, make_user):
coach_id = as_role('coach', username='table_coach')
player_id = make_user('player', username='table_player')
with app.app_context():
tryout = Tryout(
title='T',
game='Valorant',
date=date(2030, 1, 1),
created_by=coach_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
db.session.add(
Evaluation(
tryout_id=tryout.id,
player_id=player_id,
evaluator_id=coach_id,
overall_score=7,
comments='a distinctive comment',
)
)
db.session.commit()
body = client.get('/evaluations').get_data(as_text=True)
assert 'table_player' in body
assert f'/evaluations/player/{player_id}' in body
assert 'a distinctive comment' in body
-348
View File
@@ -1,348 +0,0 @@
"""Team representative ("joueur responsable") — the rule and the routes.
A player an admin names as their team's responsible may create, edit and
delete that team's matches and practices, and nothing else. Designation
itself is the president's decision, never the responsible's.
"""
from datetime import date, time
import pytest
from app.extensions import db
from app.models import AppSettings, OrgTeam, TeamMatch, TeamPlayer, User
from app.permissions import (
can_manage_team_matches,
is_team_representative,
representative_org_teams,
)
def _redirected(response):
return response.status_code in (301, 302)
def _build_world(app, make_user):
"""Two teams, a responsible on the first, a plain player on it too."""
with app.app_context():
admin_id = make_user('admin')
rep_id = make_user('player')
plain_id = make_user('player')
team = OrgTeam(name='Varsity', created_by=admin_id)
other = OrgTeam(name='JV', created_by=admin_id)
db.session.add_all([team, other])
db.session.flush()
# Added plain-first on purpose: the roster must still show the
# responsible at the top, not last, and a test whose world added the
# responsible first would pass without the ordering ever running.
db.session.add_all(
[
TeamPlayer(player_id=plain_id, org_team_id=team.id),
TeamPlayer(player_id=rep_id, org_team_id=team.id, is_team_representative=True),
]
)
db.session.commit()
# create/delete matches are gated on the season for non-presidents,
# and the responsible is one of them.
AppSettings.set_bool('season_active', True)
return {
'admin': admin_id,
'rep': rep_id,
'plain': plain_id,
'team': team.id,
'other': other.id,
'rep_username': db.session.get(User, rep_id).username,
'plain_username': db.session.get(User, plain_id).username,
}
def _make_match(app, world, title, team_key='team'):
with app.app_context():
match = TeamMatch(
org_team_id=world[team_key],
title=title,
date=date(2026, 10, 1),
start_time=time(10, 0),
end_time=time(11, 0),
created_by=world['admin'],
)
db.session.add(match)
db.session.commit()
return match.id
def _login_as(client, login, username):
"""Switch the test client to another existing account."""
client.post('/auth/logout', follow_redirects=False)
response = login(username)
assert _redirected(response), f'could not log in as {username}'
class TestTheRule:
"""The permission itself, without a request in the way."""
def test_a_rep_manages_their_team(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
team = db.session.get(OrgTeam, world['team'])
assert is_team_representative(rep, team) is True
assert can_manage_team_matches(rep, team) is True
assert rep.can_manage_team_matches(team) is True
def test_a_rep_manages_no_other_team(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
other = db.session.get(OrgTeam, world['other'])
assert is_team_representative(rep, other) is False
assert can_manage_team_matches(rep, other) is False
def test_a_rep_is_not_a_roster_manager(self, app, make_user):
"""Match rights only: the roster stays staff-only."""
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
team = db.session.get(OrgTeam, world['team'])
assert rep.can_manage_this_org_team(team) is False
assert rep.can_manage_teams() is False
assert rep.can_schedule_matches() is False
def test_a_plain_player_manages_nothing(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
plain = db.session.get(User, world['plain'])
team = db.session.get(OrgTeam, world['team'])
assert can_manage_team_matches(plain, team) is False
def test_representative_org_teams_is_scoped(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
assert [team.id for team in representative_org_teams(rep)] == [world['team']]
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
def test_only_the_president_may_designate(self, app, make_user, role):
with app.app_context():
user = db.session.get(User, make_user(role))
assert user.can_designate_team_representative() == (role == 'admin')
class TestMatchManagement:
"""The routes the feature exists for: matches and practices."""
def test_a_rep_schedules_a_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(
f'/team-matches/{world["team"]}/create',
data={'title': 'Rep Match', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
assert TeamMatch.query.filter_by(title='Rep Match').count() == 1
def test_a_rep_schedules_a_practice(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(
f'/team-matches/{world["team"]}/create?type=practice',
data={'title': 'Evening Scrim', 'date': '2026-10-02', 'start_time': '18:00'},
follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
match = TeamMatch.query.filter_by(title='Evening Scrim').first()
assert match is not None
assert match.opponent is None
def test_a_rep_edits_a_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
match_id = _make_match(app, world, title='Before')
_login_as(client, login, world['rep_username'])
response = client.post(
f'/team-matches/{match_id}/edit',
data={'title': 'After', 'date': '2026-10-03', 'start_time': '12:00'},
follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
assert db.session.get(TeamMatch, match_id).title == 'After'
def test_a_rep_deletes_a_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
match_id = _make_match(app, world, title='Doomed')
_login_as(client, login, world['rep_username'])
response = client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
assert _redirected(response)
with app.app_context():
assert db.session.get(TeamMatch, match_id) is None
def test_a_rep_does_not_schedule_for_another_team(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
client.post(
f'/team-matches/{world["other"]}/create',
data={'title': 'Sneaky', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
with app.app_context():
assert TeamMatch.query.filter_by(title='Sneaky').count() == 0
def test_a_rep_does_not_delete_another_teams_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
match_id = _make_match(app, world, title='Elsewhere', team_key='other')
_login_as(client, login, world['rep_username'])
client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
with app.app_context():
assert db.session.get(TeamMatch, match_id) is not None
def test_a_plain_player_does_not_schedule(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['plain_username'])
client.post(
f'/team-matches/{world["team"]}/create',
data={'title': 'Nope', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
with app.app_context():
assert TeamMatch.query.filter_by(title='Nope').count() == 0
def test_a_rep_does_not_touch_the_roster(self, app, client, login, make_user):
"""Decision 3: matches only, never the roster."""
world = _build_world(app, make_user)
newcomer_id = make_user('player')
_login_as(client, login, world['rep_username'])
client.post(
f'/teams/{world["team"]}/add_player',
data={'player_id': str(newcomer_id), 'status': 'starter'},
follow_redirects=False,
)
with app.app_context():
added = TeamPlayer.query.filter_by(
player_id=newcomer_id, org_team_id=world['team']
).first()
assert added is None
def test_a_rep_does_not_toggle_a_roster_status(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(f'/teams/{world["team"]}/toggle_status/{world["plain"]}', json={})
assert response.status_code == 403
class TestDesignation:
"""Only the president names a responsible."""
@pytest.mark.parametrize('role', ['manager', 'coach', 'player', 'scout'])
def test_a_non_president_cannot_designate(self, app, client, as_role, make_user, role):
world = _build_world(app, make_user)
target_id = make_user('player')
as_role(role)
response = client.post(f'/teams/{world["team"]}/toggle_representative/{target_id}', json={})
assert response.status_code == 403
def test_the_president_designates_a_rep(self, app, client, as_role, make_user):
world = _build_world(app, make_user)
as_role('admin')
response = client.post(
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
)
assert response.status_code == 200
assert response.get_json()['is_team_representative'] is True
with app.app_context():
row = TeamPlayer.query.filter_by(
player_id=world['plain'], org_team_id=world['team']
).one()
assert row.is_team_representative is True
def test_a_rep_cannot_promote_another_player(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
)
assert response.status_code == 403
with app.app_context():
row = TeamPlayer.query.filter_by(
player_id=world['plain'], org_team_id=world['team']
).one()
assert row.is_team_representative is False
class TestReachingThePage:
"""Reuse of the teams page, scoped (decision 4)."""
def test_a_plain_player_is_sent_to_their_own_view(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['plain_username'])
response = client.get('/teams', follow_redirects=False)
assert _redirected(response)
assert '/teams/my-teams' in response.headers['Location']
def test_a_rep_settles_on_the_teams_page(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
assert client.get('/teams').status_code == 200
def test_the_page_offers_matches_but_not_the_roster(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
html = client.get('/teams').get_data(as_text=True)
assert f'/team-matches/{world["team"]}/create' in html
assert f'/teams/{world["team"]}/add_player' not in html
def test_the_my_teams_page_offers_the_schedule_buttons(self, client, login, app, make_user):
"""A player's nav only has "My Team(s)", so the rep schedules there."""
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
html = client.get('/teams/my-teams').get_data(as_text=True)
assert f'/team-matches/{world["team"]}/create' in html
assert 'create?type=practice' in html
def test_the_my_teams_page_is_silent_for_a_plain_player(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['plain_username'])
html = client.get('/teams/my-teams').get_data(as_text=True)
assert f'/team-matches/{world["team"]}/create' not in html
class TestRosterOrder:
"""The responsible heads the roster, on both pages that render it."""
def test_the_responsible_is_listed_first(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
team = db.session.get(OrgTeam, world['team'])
roster = team.get_players_with_status()
assert [row['player'].id for row in roster] == [world['rep'], world['plain']]
assert roster[0]['is_team_representative'] is True
assert roster[1]['is_team_representative'] is False