Compare commits
2
Commits
dev
..
e53bc3b80d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53bc3b80d | ||
|
|
101e3ae880 |
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
+2
-2
@@ -550,7 +550,7 @@ def create_app(config=None):
|
|||||||
# not down, and a 503 here would take the site out of the load
|
# not down, and a 503 here would take the site out of the load
|
||||||
# balancer for it (OPS-012).
|
# balancer for it (OPS-012).
|
||||||
if app.config['ENABLE_DISCORD_BOT']:
|
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()
|
health_data['discord_bot'] = bot_status()
|
||||||
|
|
||||||
@@ -750,7 +750,7 @@ def create_app(config=None):
|
|||||||
# Start the Discord bot for notifications
|
# Start the Discord bot for notifications
|
||||||
if app.config['ENABLE_DISCORD_BOT']:
|
if app.config['ENABLE_DISCORD_BOT']:
|
||||||
try:
|
try:
|
||||||
from app.discord_bot import start_bot
|
from app.discord import start_bot
|
||||||
|
|
||||||
start_bot(flask_app=app)
|
start_bot(flask_app=app)
|
||||||
except Exception: # the site must come up even if the bot cannot
|
except Exception: # the site must come up even if the bot cannot
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Discord notification subsystem.
|
||||||
|
|
||||||
|
Split from a single 1654-line module (app/discord_bot.py) into a package with
|
||||||
|
one concern per module (ARCH-003).
|
||||||
|
|
||||||
|
Public API — these are the functions that Flask routes and services import:
|
||||||
|
start_bot, bot_status, get_bot → .bot
|
||||||
|
send_one_on_one_dm, send_schedule_notification,
|
||||||
|
send_one_on_one_response → .messaging
|
||||||
|
purge_pending_for_participants → .pending
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.discord.bot import bot_status, get_bot, start_bot
|
||||||
|
from app.discord.messaging import (
|
||||||
|
send_one_on_one_dm,
|
||||||
|
send_one_on_one_response,
|
||||||
|
send_schedule_notification,
|
||||||
|
)
|
||||||
|
from app.discord.pending import purge_pending_for_participants
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'bot_status',
|
||||||
|
'get_bot',
|
||||||
|
'purge_pending_for_participants',
|
||||||
|
'send_one_on_one_dm',
|
||||||
|
'send_one_on_one_response',
|
||||||
|
'send_schedule_notification',
|
||||||
|
'start_bot',
|
||||||
|
]
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
"""Discord bot lifecycle, class definition, and supervisor."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.discord.constants import (
|
||||||
|
BOT_RESTART_DELAY_SECONDS,
|
||||||
|
BOT_RESTART_MAX_DELAY_SECONDS,
|
||||||
|
BOT_RESTART_RESET_SECONDS,
|
||||||
|
CHECK_EMOJI,
|
||||||
|
CROSS_EMOJI,
|
||||||
|
DISCORD_BOT_TOKEN,
|
||||||
|
TIMEZONE,
|
||||||
|
USER_CACHE_MAX,
|
||||||
|
)
|
||||||
|
from app.discord.pending import (
|
||||||
|
forget_pending,
|
||||||
|
load_pending,
|
||||||
|
save_pending,
|
||||||
|
)
|
||||||
|
from app.discord.queue import message_queue
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
from discord import (
|
||||||
|
Forbidden,
|
||||||
|
HTTPException,
|
||||||
|
Intents,
|
||||||
|
LoginFailure,
|
||||||
|
NotFound,
|
||||||
|
PrivilegedIntentsRequired,
|
||||||
|
)
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
bot_instance = None
|
||||||
|
bot_thread = None
|
||||||
|
bot_flask_app = None
|
||||||
|
_stop_bot = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
class TeamTryoutsBot(commands.Bot):
|
||||||
|
"""Unified Discord bot for Team Tryouts notifications."""
|
||||||
|
|
||||||
|
def __init__(self, flask_app=None):
|
||||||
|
intents = Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
intents.dm_messages = True
|
||||||
|
intents.dm_reactions = True
|
||||||
|
intents.reactions = True
|
||||||
|
intents.guilds = True
|
||||||
|
super().__init__(command_prefix='!', intents=intents)
|
||||||
|
self.flask_app = flask_app
|
||||||
|
self.pending_requests = load_pending()
|
||||||
|
self.message_queue = message_queue
|
||||||
|
self.scheduler = AsyncIOScheduler()
|
||||||
|
self.timezone = ZoneInfo(TIMEZONE)
|
||||||
|
self._user_cache = {}
|
||||||
|
self.reminders_scheduled = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle hooks
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def setup_hook(self):
|
||||||
|
self.loop.create_task(self.process_queue())
|
||||||
|
|
||||||
|
async def on_error(self, event_method, *args, **kwargs):
|
||||||
|
_logger.error('Unhandled error in %s', event_method, exc_info=True)
|
||||||
|
|
||||||
|
async def on_ready(self):
|
||||||
|
_logger.info('Bot logged in as %s', self.user)
|
||||||
|
await self.start_scheduler()
|
||||||
|
|
||||||
|
async def start_scheduler(self):
|
||||||
|
if self.reminders_scheduled:
|
||||||
|
_logger.debug('Reminders already scheduled, skipping')
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._send_daily_reminders_impl,
|
||||||
|
CronTrigger(hour=18, minute=0, timezone=self.timezone),
|
||||||
|
id='daily_match_check',
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
self.scheduler.start()
|
||||||
|
self.reminders_scheduled = True
|
||||||
|
_logger.info('Daily reminder scheduled for 18:00 %s', TIMEZONE)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('Failed to start the daily reminder scheduler')
|
||||||
|
self.reminders_scheduled = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _reply(self, channel, message):
|
||||||
|
try:
|
||||||
|
await channel.send(message)
|
||||||
|
except (HTTPException, Forbidden) as exc:
|
||||||
|
_logger.warning('Could not reply to channel: %s', exc)
|
||||||
|
|
||||||
|
async def _commit_or_report(self, channel, *, operation):
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
try:
|
||||||
|
operation()
|
||||||
|
db.session.commit()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
db.session.rollback()
|
||||||
|
_logger.exception('DB commit failed for %s', operation.__name__)
|
||||||
|
await self._reply(channel, 'Something went wrong saving that. Please try again.')
|
||||||
|
|
||||||
|
def _forget_pending(self, message_id):
|
||||||
|
self.pending_requests = forget_pending(self.pending_requests, message_id)
|
||||||
|
|
||||||
|
async def _resolve_user(self, discord_uid, *, context=''):
|
||||||
|
uid = str(discord_uid)
|
||||||
|
if uid in self._user_cache:
|
||||||
|
return self._user_cache[uid]
|
||||||
|
try:
|
||||||
|
user = await self.fetch_user(int(uid))
|
||||||
|
except (NotFound, HTTPException) as exc:
|
||||||
|
_logger.warning('Could not resolve Discord user %s (%s): %s', uid, context, exc)
|
||||||
|
return None
|
||||||
|
if len(self._user_cache) >= USER_CACHE_MAX:
|
||||||
|
self._user_cache.pop(next(iter(self._user_cache)))
|
||||||
|
self._user_cache[uid] = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def _send_dm(self, discord_uid, message, *, purpose, recipient=''):
|
||||||
|
user = await self._resolve_user(discord_uid, context=purpose)
|
||||||
|
if not user:
|
||||||
|
_logger.warning(
|
||||||
|
'Could not send DM (%s): user not found', purpose, recipient or discord_uid
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sent = await user.send(message)
|
||||||
|
_logger.info(
|
||||||
|
'DM sent (%s) to %s (%s)', purpose, recipient or user.display_name, discord_uid
|
||||||
|
)
|
||||||
|
return sent
|
||||||
|
except Forbidden:
|
||||||
|
_logger.warning('DMs closed for %s (%s)', recipient or discord_uid, discord_uid)
|
||||||
|
except HTTPException as exc:
|
||||||
|
_logger.warning('Failed to DM %s: %s', discord_uid, exc)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('DM to %s failed unexpectedly', discord_uid)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _dispatch(self, item):
|
||||||
|
kind = item['type']
|
||||||
|
data = item['data']
|
||||||
|
_logger.debug('Dispatching %s', kind)
|
||||||
|
try:
|
||||||
|
if kind == 'one_on_one_request':
|
||||||
|
await self._send_one_on_one_dm(**data)
|
||||||
|
elif kind == 'schedule_addition':
|
||||||
|
await self._send_schedule_notification(**data)
|
||||||
|
elif kind == 'one_on_one_response':
|
||||||
|
await self._send_one_on_one_response_dm(**data)
|
||||||
|
elif kind == 'purge_pending':
|
||||||
|
pids = data.get('participant_ids', [])
|
||||||
|
self.pending_requests = {
|
||||||
|
mid: e
|
||||||
|
for mid, e in self.pending_requests.items()
|
||||||
|
if e.get('type') != 'attendance' or e.get('reference_id') not in pids
|
||||||
|
}
|
||||||
|
save_pending(self.pending_requests)
|
||||||
|
else:
|
||||||
|
_logger.warning('Unknown queue item type: %s', kind)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('Error dispatching %s', kind)
|
||||||
|
|
||||||
|
async def process_queue(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = self.message_queue.get(timeout=5)
|
||||||
|
except __import__('queue').Empty:
|
||||||
|
continue
|
||||||
|
await self._dispatch(item)
|
||||||
|
|
||||||
|
def is_dm_channel(self, channel) -> bool:
|
||||||
|
return channel.type.name == 'private'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Reaction handling
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def on_raw_reaction_add(self, payload):
|
||||||
|
if payload.user_id == self.user.id:
|
||||||
|
return
|
||||||
|
message_id = payload.message_id
|
||||||
|
entry = self.pending_requests.get(str(message_id))
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
channel = self.get_channel(payload.channel_id)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
reacting_user = payload.member or await self._resolve_user(
|
||||||
|
payload.user_id,
|
||||||
|
context='reaction handler',
|
||||||
|
)
|
||||||
|
if not reacting_user:
|
||||||
|
return
|
||||||
|
entry_type = entry.get('type')
|
||||||
|
reference_id = entry.get('reference_id')
|
||||||
|
emoji = str(payload.emoji)
|
||||||
|
if emoji == CHECK_EMOJI:
|
||||||
|
if entry_type == 'one_on_one':
|
||||||
|
await self.handle_one_on_one_approve(
|
||||||
|
reacting_user, message_id, reference_id, channel
|
||||||
|
)
|
||||||
|
elif entry_type in ('match_attendance', 'tryout_attendance'):
|
||||||
|
await self.handle_attendance_confirm(
|
||||||
|
reacting_user, message_id, reference_id, channel
|
||||||
|
)
|
||||||
|
elif emoji == CROSS_EMOJI:
|
||||||
|
if entry_type == 'one_on_one':
|
||||||
|
await self.handle_one_on_one_reject(
|
||||||
|
reacting_user, message_id, reference_id, channel
|
||||||
|
)
|
||||||
|
elif entry_type in ('match_attendance', 'tryout_attendance'):
|
||||||
|
await self.handle_attendance_decline(
|
||||||
|
reacting_user, message_id, reference_id, channel
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# One-on-One handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_one_on_one_dm(
|
||||||
|
self,
|
||||||
|
coach_name='',
|
||||||
|
coach_discord_id='',
|
||||||
|
player_name='',
|
||||||
|
team_name='',
|
||||||
|
date_str='',
|
||||||
|
start_time='',
|
||||||
|
end_time='',
|
||||||
|
points='',
|
||||||
|
request_id=None,
|
||||||
|
):
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': 'One on One Request',
|
||||||
|
'color': 3447003,
|
||||||
|
'fields': [
|
||||||
|
{'name': 'Player', 'value': player_name, 'inline': True},
|
||||||
|
{'name': 'Team', 'value': team_name or 'Unknown', 'inline': True},
|
||||||
|
{'name': 'Date', 'value': date_str, 'inline': True},
|
||||||
|
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
|
||||||
|
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
|
||||||
|
{
|
||||||
|
'name': 'Quick Reply',
|
||||||
|
'value': f'React with {CHECK_EMOJI} to approve, {CROSS_EMOJI} to reject.',
|
||||||
|
'inline': False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'footer': {'text': f'Request #{request_id}'},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
sent = await self._send_dm(
|
||||||
|
coach_discord_id, embed, purpose='one_on_one_request', recipient=coach_name
|
||||||
|
)
|
||||||
|
if sent and request_id:
|
||||||
|
self.pending_requests[str(sent.id)] = {
|
||||||
|
'type': 'one_on_one',
|
||||||
|
'reference_id': request_id,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
save_pending(self.pending_requests)
|
||||||
|
|
||||||
|
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import OneOnOneRequest, User
|
||||||
|
|
||||||
|
req = db.session.get(OneOnOneRequest, request_id)
|
||||||
|
if not req:
|
||||||
|
await self._reply(channel, 'This request no longer exists.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
|
if str(coach.id) != str(req.coach.discord_user_id):
|
||||||
|
await self._reply(channel, 'This request is not for you.')
|
||||||
|
return
|
||||||
|
if req.status != 'pending':
|
||||||
|
await self._reply(channel, 'This request has already been handled.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _approve():
|
||||||
|
req.status = 'approved'
|
||||||
|
req.responded_at = utc_now_naive()
|
||||||
|
|
||||||
|
await self._commit_or_report(channel, operation=_approve)
|
||||||
|
await self._reply(channel, f'One on One request from {req.player.full_name} approved!')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
player = db.session.get(User, req.player_id)
|
||||||
|
if player and player.discord_user_id:
|
||||||
|
await self._send_one_on_one_response_dm(
|
||||||
|
player_discord_id=player.discord_user_id,
|
||||||
|
player_full_name=player.full_name,
|
||||||
|
coach_full_name=getattr(coach, 'display_name', 'Coach'),
|
||||||
|
date_str=req.date.strftime('%A, %B %d, %Y'),
|
||||||
|
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
|
||||||
|
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
|
||||||
|
points=req.points or 'No specific points provided',
|
||||||
|
approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import OneOnOneRequest, User
|
||||||
|
|
||||||
|
req = db.session.get(OneOnOneRequest, request_id)
|
||||||
|
if not req:
|
||||||
|
await self._reply(channel, 'This request no longer exists.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
|
if str(coach.id) != str(req.coach.discord_user_id):
|
||||||
|
await self._reply(channel, 'This request is not for you.')
|
||||||
|
return
|
||||||
|
if req.status != 'pending':
|
||||||
|
await self._reply(channel, 'This request has already been handled.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
|
refusal_note = None
|
||||||
|
try:
|
||||||
|
msg = await channel.fetch_message(message_id)
|
||||||
|
if msg and msg.reference and msg.reference.resolved:
|
||||||
|
reply_to = msg.reference.resolved
|
||||||
|
if reply_to.author.id == coach.id:
|
||||||
|
refusal_note = reply_to.content if isinstance(reply_to.content, str) else None
|
||||||
|
except (NotFound, HTTPException) as exc:
|
||||||
|
_logger.warning('Could not read rejection reply: %s', exc)
|
||||||
|
|
||||||
|
async def _reject():
|
||||||
|
req.status = 'rejected'
|
||||||
|
req.responded_at = utc_now_naive()
|
||||||
|
if refusal_note:
|
||||||
|
req.coach_rejection_message = refusal_note
|
||||||
|
|
||||||
|
await self._commit_or_report(channel, operation=_reject)
|
||||||
|
await self._reply(channel, f'One on One request from {req.player.full_name} rejected.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
player = db.session.get(User, req.player_id)
|
||||||
|
if player and player.discord_user_id:
|
||||||
|
await self._send_one_on_one_response_dm(
|
||||||
|
player_discord_id=player.discord_user_id,
|
||||||
|
player_full_name=player.full_name,
|
||||||
|
coach_full_name=getattr(coach, 'display_name', 'Coach'),
|
||||||
|
date_str=req.date.strftime('%A, %B %d, %Y'),
|
||||||
|
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
|
||||||
|
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
|
||||||
|
points=req.points or 'No specific points provided',
|
||||||
|
approved=False,
|
||||||
|
refusal_note=refusal_note,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Attendance handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attendance_record(event_type, reference_id):
|
||||||
|
from app.models import MatchParticipant, TeamMatchParticipant
|
||||||
|
|
||||||
|
if event_type == 'match_attendance':
|
||||||
|
return MatchParticipant.query.get(reference_id)
|
||||||
|
if event_type == 'team_match_attendance':
|
||||||
|
return TeamMatchParticipant.query.get(reference_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reacting_user_owns(player_id, reacting_user):
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
player = User.query.get(player_id)
|
||||||
|
if not player:
|
||||||
|
return False
|
||||||
|
return str(player.discord_user_id) == str(reacting_user.id)
|
||||||
|
|
||||||
|
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
||||||
|
entry = self.pending_requests.get(str(message_id))
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
event_type = entry.get('type', '')
|
||||||
|
record = self._attendance_record(event_type, reference_id)
|
||||||
|
if not record:
|
||||||
|
await self._reply(channel, 'This event no longer exists.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
|
if not self._reacting_user_owns(record.player_id, player):
|
||||||
|
await self._reply(channel, 'You cannot confirm attendance for someone else.')
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _confirm():
|
||||||
|
record.attendance_confirmed = True
|
||||||
|
record.is_confirmed = True
|
||||||
|
|
||||||
|
await self._commit_or_report(channel, operation=_confirm)
|
||||||
|
await self._reply(channel, 'Attendance confirmed! ✅')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
|
||||||
|
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
|
||||||
|
entry = self.pending_requests.get(str(message_id))
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
event_type = entry.get('type', '')
|
||||||
|
record = self._attendance_record(event_type, reference_id)
|
||||||
|
if not record:
|
||||||
|
await self._reply(channel, 'This event no longer exists.')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
return
|
||||||
|
if not self._reacting_user_owns(record.player_id, player):
|
||||||
|
await self._reply(channel, 'You cannot decline attendance for someone else.')
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _decline():
|
||||||
|
record.attendance_confirmed = False
|
||||||
|
record.is_confirmed = False
|
||||||
|
|
||||||
|
await self._commit_or_report(channel, operation=_decline)
|
||||||
|
await self._reply(channel, 'Attendance declined. ❌')
|
||||||
|
self._forget_pending(message_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schedule notification
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_schedule_notification(
|
||||||
|
self,
|
||||||
|
user_id=None,
|
||||||
|
event_type='match',
|
||||||
|
event_title='',
|
||||||
|
event_date='',
|
||||||
|
event_time='',
|
||||||
|
reference_id=None,
|
||||||
|
change_kind='scheduled',
|
||||||
|
):
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user or not user.discord_user_id:
|
||||||
|
_logger.warning('Cannot notify user %s: no Discord user id', user_id)
|
||||||
|
return
|
||||||
|
title = '🎯 Match Updated' if change_kind == 'modified' else '🎯 New Match Scheduled'
|
||||||
|
desc = f'**{event_title}**\n📅 {event_date}\n⏰ {event_time}'
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': title,
|
||||||
|
'color': 7506394 if change_kind == 'modified' else 3447003,
|
||||||
|
'description': desc,
|
||||||
|
'fields': [
|
||||||
|
{
|
||||||
|
'name': 'Quick RSVP',
|
||||||
|
'value': f'React with {CHECK_EMOJI} to confirm, {CROSS_EMOJI} to decline.',
|
||||||
|
'inline': False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
sent = await self._send_dm(
|
||||||
|
user.discord_user_id,
|
||||||
|
embed,
|
||||||
|
purpose=f'{change_kind}_{event_type}',
|
||||||
|
recipient=user.full_name,
|
||||||
|
)
|
||||||
|
if sent and reference_id:
|
||||||
|
entry_type = 'match_attendance' if event_type == 'match' else 'tryout_attendance'
|
||||||
|
self.pending_requests[str(sent.id)] = {
|
||||||
|
'type': entry_type,
|
||||||
|
'reference_id': reference_id,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
save_pending(self.pending_requests)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# One-on-One response DM
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_one_on_one_response_dm(
|
||||||
|
self,
|
||||||
|
player_discord_id='',
|
||||||
|
player_full_name='',
|
||||||
|
coach_full_name='',
|
||||||
|
date_str='',
|
||||||
|
start_time='',
|
||||||
|
end_time='',
|
||||||
|
points='',
|
||||||
|
approved=True,
|
||||||
|
refusal_note=None,
|
||||||
|
):
|
||||||
|
status = '✅ Approved' if approved else '❌ Rejected'
|
||||||
|
color = 3066993 if approved else 15158332
|
||||||
|
desc = f'Your One on One request has been **{"approved" if approved else "rejected"}**.'
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': f'One on One {status}',
|
||||||
|
'color': color,
|
||||||
|
'description': desc,
|
||||||
|
'fields': [
|
||||||
|
{'name': 'Coach', 'value': coach_full_name, 'inline': True},
|
||||||
|
{'name': 'Date', 'value': date_str, 'inline': True},
|
||||||
|
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
|
||||||
|
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if refusal_note:
|
||||||
|
embed['embeds'][0]['fields'].append(
|
||||||
|
{'name': 'Reason', 'value': refusal_note, 'inline': False}
|
||||||
|
)
|
||||||
|
await self._send_dm(
|
||||||
|
player_discord_id, embed, purpose='one_on_one_response', recipient=player_full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Daily reminders
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def send_daily_reminders(self):
|
||||||
|
"""Public entry point, called from tests."""
|
||||||
|
await self._send_daily_reminders_impl()
|
||||||
|
|
||||||
|
async def _send_daily_reminders_impl(self):
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Match, OneOnOneRequest, TeamMatch, Tryout, User
|
||||||
|
|
||||||
|
tomorrow = (datetime.now(self.timezone) + timedelta(days=1)).date()
|
||||||
|
_logger.info('Running daily reminder check for %s', tomorrow)
|
||||||
|
for match in Match.query.filter(Match.date == tomorrow).all():
|
||||||
|
for participant in match.participants:
|
||||||
|
await self.send_match_reminder(participant.player, match)
|
||||||
|
for tm in TeamMatch.query.filter(TeamMatch.date == tomorrow).all():
|
||||||
|
for participant in tm.participants:
|
||||||
|
await self.send_match_reminder(participant.player, tm)
|
||||||
|
for tryout in Tryout.query.all():
|
||||||
|
if any(m.date == tomorrow for m in tryout.matches):
|
||||||
|
for reg in tryout.registrations:
|
||||||
|
await self.send_tryout_reminder(reg.player, tryout)
|
||||||
|
sessions = OneOnOneRequest.query.filter(
|
||||||
|
OneOnOneRequest.status == 'pending', OneOnOneRequest.date == tomorrow
|
||||||
|
).all()
|
||||||
|
for session in sessions:
|
||||||
|
player = db.session.get(User, session.player_id)
|
||||||
|
await self.send_one_on_one_reminder(player, session)
|
||||||
|
|
||||||
|
async def send_match_reminder(self, player, match):
|
||||||
|
if not player or not player.discord_user_id:
|
||||||
|
return
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': '⏰ Match Tomorrow!',
|
||||||
|
'color': 15844367,
|
||||||
|
'description': f'**{match.title}**\n📅 {match.date.strftime("%A, %B %d, %Y")}\n⏰ {match.start_time.strftime("%I:%M %p") if match.start_time else "TBD"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await self._send_dm(
|
||||||
|
player.discord_user_id, embed, purpose='match_reminder', recipient=player.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_tryout_reminder(self, player, tryout):
|
||||||
|
if not player or not player.discord_user_id:
|
||||||
|
return
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': '⏰ Tryout Tomorrow!',
|
||||||
|
'color': 15844367,
|
||||||
|
'description': f'**{tryout.name}**\n📅 {tryout.date.strftime("%A, %B %d, %Y")}\n⏰ {tryout.time.strftime("%I:%M %p") if tryout.time else "TBD"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await self._send_dm(
|
||||||
|
player.discord_user_id, embed, purpose='tryout_reminder', recipient=player.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_one_on_one_reminder(self, player, session):
|
||||||
|
if not player or not player.discord_user_id:
|
||||||
|
return
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': '⏰ One on One Tomorrow!',
|
||||||
|
'color': 15844367,
|
||||||
|
'description': f'📅 {session.date.strftime("%A, %B %d, %Y")}\n⏰ {session.start_time.strftime("%I:%M %p") if session.start_time else "TBD"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await self._send_dm(
|
||||||
|
player.discord_user_id, embed, purpose='one_on_one_reminder', recipient=player.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Legacy alias
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def notify_player_about_one_on_one_direct(
|
||||||
|
self, player, coach, coach_name, coach_discord_id, date_str, start_time, end_time, points
|
||||||
|
):
|
||||||
|
embed = {
|
||||||
|
'embeds': [
|
||||||
|
{
|
||||||
|
'title': 'One on One Scheduled',
|
||||||
|
'color': 3447003,
|
||||||
|
'fields': [
|
||||||
|
{'name': 'Coach', 'value': coach_name, 'inline': True},
|
||||||
|
{'name': 'Date', 'value': date_str, 'inline': True},
|
||||||
|
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
|
||||||
|
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await self._send_dm(
|
||||||
|
player.discord_user_id, embed, purpose='one_on_one_direct', recipient=player.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Module-level API
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_bot(flask_app=None):
|
||||||
|
global bot_instance, bot_flask_app
|
||||||
|
if flask_app is not None:
|
||||||
|
bot_flask_app = flask_app
|
||||||
|
if bot_instance is None:
|
||||||
|
bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_flask_app)
|
||||||
|
elif flask_app is not None and bot_instance.flask_app is None:
|
||||||
|
bot_instance.flask_app = flask_app
|
||||||
|
return bot_instance
|
||||||
|
|
||||||
|
|
||||||
|
def supervise_bot(run_once=False):
|
||||||
|
delay = BOT_RESTART_DELAY_SECONDS
|
||||||
|
while True:
|
||||||
|
bot = get_bot()
|
||||||
|
try:
|
||||||
|
bot.run(DISCORD_BOT_TOKEN, reconnect=True)
|
||||||
|
except LoginFailure:
|
||||||
|
_logger.critical('Discord bot token is invalid. Bot will not start.')
|
||||||
|
return
|
||||||
|
except PrivilegedIntentsRequired as exc:
|
||||||
|
_logger.critical('Missing privileged intent: %s. Bot will not start.', exc)
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 — supervisor boundary, restart on any failure
|
||||||
|
_logger.error('Discord bot stopped unexpectedly: %s', exc)
|
||||||
|
if run_once:
|
||||||
|
_logger.info('Bot run_once completed.')
|
||||||
|
return
|
||||||
|
bot_age = time.time() - getattr(bot, '_started_at', time.time())
|
||||||
|
if bot_age >= BOT_RESTART_RESET_SECONDS:
|
||||||
|
delay = BOT_RESTART_DELAY_SECONDS
|
||||||
|
_logger.warning('Discord bot disconnected. Restarting in %s seconds.', delay)
|
||||||
|
if _stop_bot.wait(delay):
|
||||||
|
_logger.info('Discord bot supervisor asked to stop.')
|
||||||
|
return
|
||||||
|
delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def start_bot(flask_app=None):
|
||||||
|
global bot_thread, bot_flask_app
|
||||||
|
if flask_app is not None:
|
||||||
|
bot_flask_app = flask_app
|
||||||
|
get_bot(flask_app=flask_app)
|
||||||
|
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||||||
|
_stop_bot.clear()
|
||||||
|
bot_thread = threading.Thread(target=supervise_bot, daemon=True)
|
||||||
|
bot_thread.start()
|
||||||
|
_logger.info('TeamTryoutsBot started in background thread')
|
||||||
|
elif not DISCORD_BOT_TOKEN:
|
||||||
|
_logger.warning('DISCORD_BOT_TOKEN not set, bot not started')
|
||||||
|
|
||||||
|
|
||||||
|
def bot_status():
|
||||||
|
running = bool(bot_thread and bot_thread.is_alive())
|
||||||
|
instance = bot_instance
|
||||||
|
connected = bool(instance and not instance.is_closed() and instance.is_ready())
|
||||||
|
return {
|
||||||
|
'configured': bool(DISCORD_BOT_TOKEN),
|
||||||
|
'running': running,
|
||||||
|
'connected': connected,
|
||||||
|
'pending': len(instance.pending_requests) if instance else 0,
|
||||||
|
'reminders_scheduled': bool(instance and instance.reminders_scheduled),
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
|||||||
|
"""Constants used across the Discord notification subsystem."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#: Discord bot token. Loaded once at import time so the bot can decide whether
|
||||||
|
#: to start before the application factory runs.
|
||||||
|
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Emoji
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CHECK_EMOJI = '✅' # Green checkmark
|
||||||
|
CROSS_EMOJI = '❌' # Red X
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#: Entries older than this are dropped when the pending file is loaded.
|
||||||
|
PENDING_MAX_AGE_DAYS = 30
|
||||||
|
|
||||||
|
#: How late the daily reminder may still be sent.
|
||||||
|
REMINDER_GRACE_SECONDS = 3600
|
||||||
|
|
||||||
|
#: First wait before restarting a bot that stopped (OPS-004).
|
||||||
|
BOT_RESTART_DELAY_SECONDS = 5
|
||||||
|
|
||||||
|
#: Ceiling on that wait.
|
||||||
|
BOT_RESTART_MAX_DELAY_SECONDS = 300
|
||||||
|
|
||||||
|
#: A connection that lasted this long resets the back-off.
|
||||||
|
BOT_RESTART_RESET_SECONDS = 600
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Caching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#: How many Discord user objects to keep resolved (PERF-005).
|
||||||
|
USER_CACHE_MAX = 512
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timezone
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TIMEZONE = 'America/Toronto'
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Public API wrappers that enqueue notifications for the bot thread.
|
||||||
|
|
||||||
|
These are the functions that Flask routes and services import. They run in the
|
||||||
|
request thread and hand the actual Discord work to the bot's async loop via the
|
||||||
|
shared queue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.discord.queue import enqueue
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_one_on_one_dm(
|
||||||
|
coach_name: str,
|
||||||
|
coach_discord_id: str,
|
||||||
|
player_name: str,
|
||||||
|
team_name: str,
|
||||||
|
date_str: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
points: str,
|
||||||
|
request_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Queue a One on One request DM to be sent by the bot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coach_name: Display name of the coach.
|
||||||
|
coach_discord_id: Discord snowflake of the coach.
|
||||||
|
player_name: Display name of the requesting player.
|
||||||
|
team_name: The player's team, or None/empty.
|
||||||
|
date_str, start_time, end_time: Already formatted for display.
|
||||||
|
points: Free-text discussion points.
|
||||||
|
request_id: OneOnOnRequest primary key for reaction routing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if queued, False on failure.
|
||||||
|
"""
|
||||||
|
return enqueue(
|
||||||
|
'one_on_one_request',
|
||||||
|
{
|
||||||
|
'coach_name': coach_name,
|
||||||
|
'coach_discord_id': coach_discord_id,
|
||||||
|
'player_name': player_name,
|
||||||
|
'team_name': team_name,
|
||||||
|
'date_str': date_str,
|
||||||
|
'start_time': start_time,
|
||||||
|
'end_time': end_time,
|
||||||
|
'points': points,
|
||||||
|
'request_id': request_id,
|
||||||
|
},
|
||||||
|
description=f'the One on One request DM for request {request_id}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_schedule_notification(
|
||||||
|
user_id: int,
|
||||||
|
event_type: str,
|
||||||
|
event_title: str,
|
||||||
|
event_date: str,
|
||||||
|
event_time: str,
|
||||||
|
reference_id: int,
|
||||||
|
change_kind: str = 'scheduled',
|
||||||
|
) -> bool:
|
||||||
|
"""Queue a schedule notification DM to be sent by the bot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The player's database id (resolved to a snowflake in the bot).
|
||||||
|
event_type: 'match' or 'tryout'.
|
||||||
|
event_title: Display title of the event.
|
||||||
|
event_date: Formatted date string.
|
||||||
|
event_time: Formatted time range, or 'TBD'.
|
||||||
|
reference_id: The participant id (or fallback match/tryout id) for
|
||||||
|
reaction routing.
|
||||||
|
change_kind: 'scheduled' for new events, 'modified' for changes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if queued, False on failure.
|
||||||
|
"""
|
||||||
|
return enqueue(
|
||||||
|
'schedule_addition',
|
||||||
|
{
|
||||||
|
'user_id': user_id,
|
||||||
|
'event_type': event_type,
|
||||||
|
'event_title': event_title,
|
||||||
|
'event_date': event_date,
|
||||||
|
'event_time': event_time,
|
||||||
|
'reference_id': reference_id,
|
||||||
|
'change_kind': change_kind,
|
||||||
|
},
|
||||||
|
description=f'the {change_kind} notification for event {event_title}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_one_on_one_response(
|
||||||
|
player_discord_id: str,
|
||||||
|
player_full_name: str,
|
||||||
|
coach_full_name: str,
|
||||||
|
date_str: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
points: str,
|
||||||
|
approved: bool,
|
||||||
|
refusal_note: str = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Queue a One on One response DM to be sent to the player by the bot.
|
||||||
|
|
||||||
|
Called from Flask routes when a coach accepts/rejects via the web app.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if queued, False on failure.
|
||||||
|
"""
|
||||||
|
return enqueue(
|
||||||
|
'one_on_one_response',
|
||||||
|
{
|
||||||
|
'player_discord_id': player_discord_id,
|
||||||
|
'player_full_name': player_full_name,
|
||||||
|
'coach_full_name': coach_full_name,
|
||||||
|
'date_str': date_str,
|
||||||
|
'start_time': start_time,
|
||||||
|
'end_time': end_time,
|
||||||
|
'points': points,
|
||||||
|
'approved': approved,
|
||||||
|
'refusal_note': refusal_note,
|
||||||
|
},
|
||||||
|
description=f'the One on One response DM for {player_full_name}',
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Persistence of pending reaction-request mappings across bot restarts.
|
||||||
|
|
||||||
|
The bot uses reaction messages (✅ / ❌) on DMs to let a coach approve or reject
|
||||||
|
a one-on-one request without visiting the site. The mapping from Discord
|
||||||
|
message_id → {request_type, primary_key} is stored in a JSON file so that a
|
||||||
|
bot restart does not orphan reactions that were already sent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.discord.constants import PENDING_MAX_AGE_DAYS
|
||||||
|
from app.discord.queue import enqueue
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: File for persisting pending requests across bot restarts.
|
||||||
|
PENDING_FILE = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
'discord_pending.json',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_pending() -> dict:
|
||||||
|
"""Load pending requests from the JSON file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict mapping message_id (int) → entry dict.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(PENDING_FILE):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(PENDING_FILE, encoding='utf-8') as f:
|
||||||
|
entries = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
_logger.warning('Could not read pending file, starting empty.', exc_info=True)
|
||||||
|
return {}
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
return {}
|
||||||
|
cleaned = drop_expired(entries)
|
||||||
|
if len(cleaned) < len(entries):
|
||||||
|
save_pending(cleaned)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def drop_expired(entries: dict) -> dict:
|
||||||
|
"""Remove entries whose creation timestamp is too old.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entries: dict mapping message_id → entry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered dict.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC).timestamp()
|
||||||
|
cutoff = now - PENDING_MAX_AGE_DAYS * 86400
|
||||||
|
return {mid: e for mid, e in entries.items() if e.get('created_at', 0) >= cutoff}
|
||||||
|
|
||||||
|
|
||||||
|
def save_pending(entries: dict) -> None:
|
||||||
|
"""Persist pending entries to the JSON file."""
|
||||||
|
try:
|
||||||
|
with open(PENDING_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(entries, f, indent=2)
|
||||||
|
except OSError:
|
||||||
|
_logger.exception('Could not write pending file')
|
||||||
|
|
||||||
|
|
||||||
|
def forget_pending(entries: dict, message_id: int) -> dict:
|
||||||
|
"""Remove one message from the pending map and persist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entries: The current pending map.
|
||||||
|
message_id: The message whose entry to remove.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated dict (already persisted).
|
||||||
|
"""
|
||||||
|
entries.pop(str(message_id), None)
|
||||||
|
save_pending(entries)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def purge_pending_for_participants(participant_ids: list[int]) -> None:
|
||||||
|
"""Queue a purge of stale participant pending entries.
|
||||||
|
|
||||||
|
Called after a match edit deletes participants. The bot thread removes
|
||||||
|
matching ``schedule_addition`` pending entries so that a reaction on an
|
||||||
|
old message cannot produce a false confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant_ids: MatchParticipant primary keys that no longer exist.
|
||||||
|
"""
|
||||||
|
if not participant_ids:
|
||||||
|
return
|
||||||
|
enqueue(
|
||||||
|
'purge_pending',
|
||||||
|
{'participant_ids': participant_ids},
|
||||||
|
description=f'purge of {len(participant_ids)} stale participant records',
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Shared queue that bridges Flask request threads to the bot's async loop.
|
||||||
|
|
||||||
|
Module-level, not per-instance: the supervisor replaces the bot object on every
|
||||||
|
restart (discord.py cannot reuse a client whose run() has returned). A queue
|
||||||
|
living on the old object would be discarded with it, taking every pending
|
||||||
|
notification along.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from queue import Empty, Queue # noqa: F401 — re-exported for bot.py
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: The shared queue instance.
|
||||||
|
message_queue: Queue = Queue()
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue(kind: str, data: dict, *, description: str) -> bool:
|
||||||
|
"""Hand one notification to the bot thread. Never raises.
|
||||||
|
|
||||||
|
The three public senders were the same fifteen lines three times, each with
|
||||||
|
its own blanket catch. There is one now, and it is the only place where
|
||||||
|
breadth is a property of the caller rather than of the failure: this runs
|
||||||
|
in a Flask request thread, and the work the notification announces is
|
||||||
|
already committed. The meeting is booked whether or not Discord hears about
|
||||||
|
it, so a failure here must not become a 500 on a page whose job is done.
|
||||||
|
|
||||||
|
What it is *not* is somewhere to lose a defect: the traceback is logged
|
||||||
|
and the caller is told False.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kind: Event type name ('one_on_one_request', 'schedule_addition', …).
|
||||||
|
data: Payload dict.
|
||||||
|
description: Human-readable label for the log message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message_queue.put({'type': kind, 'data': data})
|
||||||
|
except Exception: # a notification must never fail the request that caused it # noqa: BLE001
|
||||||
|
_logger.exception('Could not queue %s', description)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
+25
-1649
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
+4
-18
@@ -324,9 +324,8 @@ def _create_database():
|
|||||||
def _stop(*_args):
|
def _stop(*_args):
|
||||||
"""Stop the PostgreSQL server with a fast shutdown.
|
"""Stop the PostgreSQL server with a fast shutdown.
|
||||||
|
|
||||||
Registered as an ``atexit`` handler so it fires on normal exit. Signal
|
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
||||||
handling goes through ``_handle_stop_signal``, which also stops the
|
normal exit, and terminal close.
|
||||||
server but then re-raises so the process actually exits.
|
|
||||||
"""
|
"""
|
||||||
global _started
|
global _started
|
||||||
global _stopped_system_service_name
|
global _stopped_system_service_name
|
||||||
@@ -350,19 +349,6 @@ def _stop(*_args):
|
|||||||
_start_system_service(srv)
|
_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
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -434,9 +420,9 @@ def start() -> str:
|
|||||||
_started = True
|
_started = True
|
||||||
atexit.register(_stop)
|
atexit.register(_stop)
|
||||||
with contextlib.suppress(ValueError, OSError):
|
with contextlib.suppress(ValueError, OSError):
|
||||||
signal.signal(signal.SIGINT, _handle_stop_signal)
|
signal.signal(signal.SIGINT, _stop)
|
||||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
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)
|
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||||
return DATABASE_URL
|
return DATABASE_URL
|
||||||
|
|||||||
@@ -61,14 +61,7 @@ class OrgTeam(db.Model):
|
|||||||
return [tp.player for tp in self.team_players]
|
return [tp.player for tp in self.team_players]
|
||||||
|
|
||||||
def get_players_with_status(self):
|
def get_players_with_status(self):
|
||||||
rows = [
|
return [
|
||||||
{
|
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
||||||
'player': tp.player,
|
|
||||||
'status': tp.status,
|
|
||||||
'position': tp.position,
|
|
||||||
'is_team_representative': tp.is_team_representative,
|
|
||||||
}
|
|
||||||
for tp in self.team_players
|
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'])
|
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ class TeamPlayer(db.Model):
|
|||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
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)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
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)
|
position = db.Column(db.String(50), nullable=True)
|
||||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class Admin(User):
|
|||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_designate_team_representative(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
|
|||||||
@@ -103,20 +103,5 @@ class User(UserMixin, db.Model):
|
|||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return False
|
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):
|
def get_visible_tryouts(self):
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -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)
|
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
|
# Reach over players
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -211,7 +211,7 @@ def restore_backup(backup_id):
|
|||||||
|
|
||||||
# Create a safety backup of the current state before restoring.
|
# Create a safety backup of the current state before restoring.
|
||||||
try:
|
try:
|
||||||
safety = _create_backup_record(
|
_create_backup_record(
|
||||||
backup_type='pre_restore',
|
backup_type='pre_restore',
|
||||||
notes='Automatic safety backup before restoring ' + record.filename,
|
notes='Automatic safety backup before restoring ' + record.filename,
|
||||||
)
|
)
|
||||||
|
|||||||
+95
-269
@@ -3,13 +3,11 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from sqlalchemy import false, func, select
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -18,23 +16,53 @@ from app.models import (
|
|||||||
EVALUATION_CRITERIA,
|
EVALUATION_CRITERIA,
|
||||||
GAME_POSITIONS,
|
GAME_POSITIONS,
|
||||||
Admin,
|
Admin,
|
||||||
Coach,
|
|
||||||
Evaluation,
|
Evaluation,
|
||||||
Manager,
|
|
||||||
Player,
|
Player,
|
||||||
Scout,
|
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
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.responders import respond_after_change
|
||||||
from app.time_utils import utc_now_naive
|
|
||||||
from app.validators import EvaluationSchema
|
from app.validators import EvaluationSchema
|
||||||
|
|
||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
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):
|
def _users_by_id(user_ids):
|
||||||
"""Load a set of users once for aggregate/list views."""
|
"""Load a set of users once for aggregate/list views."""
|
||||||
wanted = {user_id for user_id in user_ids if user_id}
|
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()}
|
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('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
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
|
user = current_user
|
||||||
|
|
||||||
if isinstance(user, Player):
|
if isinstance(user, Player):
|
||||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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')
|
sort_order = request.args.get('order', 'desc')
|
||||||
if sort_order not in ('asc', 'desc'):
|
if sort_order not in ('asc', 'desc'):
|
||||||
sort_order = '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 = (
|
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)
|
||||||
|
)
|
||||||
|
avg_scores = (
|
||||||
db.session.query(
|
db.session.query(
|
||||||
Evaluation.player_id,
|
Evaluation.player_id,
|
||||||
func.count(Evaluation.id).label('eval_count'),
|
func.count(Evaluation.id).label('eval_count'),
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
func.max(Evaluation.created_at).label('last_eval'),
|
|
||||||
)
|
)
|
||||||
.filter(Evaluation.id.in_(visible_ids))
|
|
||||||
.group_by(Evaluation.player_id)
|
.group_by(Evaluation.player_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
||||||
players_by_id = _users_by_id(row.player_id for row in aggregate)
|
player_scores = {}
|
||||||
grouped = []
|
for row in avg_scores:
|
||||||
for row in aggregate:
|
p = players_by_id.get(row.player_id)
|
||||||
player = players_by_id.get(row.player_id)
|
if p:
|
||||||
if not player or not isinstance(player, Player):
|
player_scores[p.id] = {
|
||||||
continue
|
'player': p,
|
||||||
grouped.append(
|
|
||||||
{
|
|
||||||
'player': player,
|
|
||||||
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
|
|
||||||
'count': row.eval_count,
|
'count': row.eval_count,
|
||||||
'last_eval': row.last_eval,
|
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
||||||
'evaluations': [],
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
|
player_scores = {}
|
||||||
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())
|
|
||||||
.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 {}
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/evaluations.html',
|
'pages/evaluations.html',
|
||||||
players=page_items,
|
evaluations=evaluations_page.items,
|
||||||
pagination=pagination,
|
pagination=evaluations_page,
|
||||||
|
player_scores=player_scores,
|
||||||
sort_column=sort_column,
|
sort_column=sort_column,
|
||||||
sort_order=sort_order,
|
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,7 +351,6 @@ def batch_evaluate(tryout_id):
|
|||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
saved = 0
|
saved = 0
|
||||||
try:
|
|
||||||
for entry in players:
|
for entry in players:
|
||||||
pid = entry['player'].id
|
pid = entry['player'].id
|
||||||
scores = {
|
scores = {
|
||||||
@@ -536,16 +372,6 @@ def batch_evaluate(tryout_id):
|
|||||||
_apply_evaluation(evaluation, scores, comments, position)
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
db.session.add(evaluation)
|
db.session.add(evaluation)
|
||||||
saved += 1
|
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ def api_events():
|
|||||||
|
|
||||||
for match in matches:
|
for match in matches:
|
||||||
tryout = tryouts_by_id[match.tryout_id]
|
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.
|
# 'description' used to be participants_str + '<br>' + description.
|
||||||
# Building presentation markup inside a JSON field is what carried
|
# Building presentation markup inside a JSON field is what carried
|
||||||
# the stored XSS: the browser dropped it straight into innerHTML,
|
# the stored XSS: the browser dropped it straight into innerHTML,
|
||||||
@@ -254,6 +255,7 @@ def api_events():
|
|||||||
'id': f'match_{match.id}',
|
'id': f'match_{match.id}',
|
||||||
'title': match.title,
|
'title': match.title,
|
||||||
'type': 'match',
|
'type': 'match',
|
||||||
|
'color': match_color,
|
||||||
'extendedProps': {
|
'extendedProps': {
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'status': match.status,
|
'status': match.status,
|
||||||
@@ -299,6 +301,7 @@ def api_events():
|
|||||||
'id': f'team_match_{team_match.id}',
|
'id': f'team_match_{team_match.id}',
|
||||||
'title': team_match.title,
|
'title': team_match.title,
|
||||||
'type': 'team_match',
|
'type': 'team_match',
|
||||||
|
'color': '#3b82f6',
|
||||||
'extendedProps': {
|
'extendedProps': {
|
||||||
'location': team_match.location or 'TBD',
|
'location': team_match.location or 'TBD',
|
||||||
'status': team_match.status,
|
'status': team_match.status,
|
||||||
@@ -331,6 +334,7 @@ def api_events():
|
|||||||
'id': f'one_on_one_{ooo.id}',
|
'id': f'one_on_one_{ooo.id}',
|
||||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||||
'type': 'one_on_one',
|
'type': 'one_on_one',
|
||||||
|
'color': '#8b5cf6',
|
||||||
'extendedProps': {
|
'extendedProps': {
|
||||||
'location': 'Discord / Voice Chat',
|
'location': 'Discord / Voice Chat',
|
||||||
'status': 'approved',
|
'status': 'approved',
|
||||||
@@ -645,14 +649,14 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
# Purge Discord pending entries for participants that no longer exist.
|
# Purge Discord pending entries for participants that no longer exist.
|
||||||
if removed_ids:
|
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)
|
purge_pending_for_participants(removed_ids)
|
||||||
|
|
||||||
# Decide whether and what to notify.
|
# Decide whether and what to notify.
|
||||||
if time_changed:
|
if time_changed:
|
||||||
# All current participants get the "modified" message.
|
# 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(
|
notify_participants(
|
||||||
title=match.title,
|
title=match.title,
|
||||||
date=match.date,
|
date=match.date,
|
||||||
@@ -667,7 +671,7 @@ def edit_match(match_id):
|
|||||||
new_player_ids = desired - set(existing.keys())
|
new_player_ids = desired - set(existing.keys())
|
||||||
new_pairs = [
|
new_pairs = [
|
||||||
(pid, nid)
|
(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
|
if pid in new_player_ids
|
||||||
]
|
]
|
||||||
notify_participants(
|
notify_participants(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from app.models import (
|
|||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
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.responders import respond_after_change
|
||||||
from app.routes.matches import default_end_time
|
from app.routes.matches import default_end_time
|
||||||
from app.services.scheduling import notify_participants, zip_participants
|
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):
|
def can_manage_team_match(team):
|
||||||
"""Whether the current user can manage matches for this team.
|
"""Whether the current user can manage matches for this team.
|
||||||
|
|
||||||
Everyone who administers the team, plus the player named as its
|
Same rule as administering the team itself, so it is the same call.
|
||||||
responsible. Narrower than administering the team itself: a responsible
|
This function used to restate it, and the restatement drifted.
|
||||||
schedules and edits matches, but does not touch the roster.
|
|
||||||
"""
|
"""
|
||||||
return current_user.can_manage_team_matches(team)
|
return can_manage_org_team(current_user, team)
|
||||||
|
|
||||||
|
|
||||||
def season_locked():
|
def season_locked():
|
||||||
@@ -301,13 +300,13 @@ def delete_match(match_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def api_manageable_teams():
|
def api_manageable_teams():
|
||||||
"""API endpoint returning teams the current user can schedule matches for."""
|
"""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)):
|
if isinstance(current_user, (Admin, Manager)):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||||
elif isinstance(current_user, Coach):
|
elif isinstance(current_user, Coach):
|
||||||
teams = coach_org_teams(current_user)
|
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:
|
else:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
|
|||||||
+3
-35
@@ -26,7 +26,7 @@ from app.models import (
|
|||||||
Tryout,
|
Tryout,
|
||||||
User,
|
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.responders import respond_after_change
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||||
@@ -40,12 +40,10 @@ def list_teams():
|
|||||||
"""List all organization teams visible to the current user."""
|
"""List all organization teams visible to the current user."""
|
||||||
can_manage = current_user.can_manage_teams()
|
can_manage = current_user.can_manage_teams()
|
||||||
|
|
||||||
# A player reaches this page only as a team's responsible; anyone else
|
if isinstance(current_user, Player):
|
||||||
# is sent to their own view.
|
|
||||||
if isinstance(current_user, Player) and not representative_org_teams(current_user):
|
|
||||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||||
return redirect(url_for('teams.my_teams'))
|
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')
|
flash(_('You do not have permission to view teams.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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'])
|
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_team_note(team_id):
|
def add_team_note(team_id):
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
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_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app.extensions import db
|
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 import Admin, Coach, Contract, Manager, Player, User
|
||||||
from app.models.admin_settings import AppSettings
|
from app.models.admin_settings import AppSettings
|
||||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||||
|
from app.responders import respond_after_change
|
||||||
from app.routes.users._shared import (
|
from app.routes.users._shared import (
|
||||||
ALLOWED_CONTRACT_EXTENSIONS,
|
ALLOWED_CONTRACT_EXTENSIONS,
|
||||||
ALLOWED_SIGNED_EXTENSIONS,
|
ALLOWED_SIGNED_EXTENSIONS,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from flask_login import current_user, login_required
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Coach,
|
Coach,
|
||||||
@@ -32,6 +31,7 @@ from app.permissions import (
|
|||||||
coach_player_ids,
|
coach_player_ids,
|
||||||
coach_tryouts,
|
coach_tryouts,
|
||||||
)
|
)
|
||||||
|
from app.responders import respond_after_change
|
||||||
from app.routes.users.blueprint import users_bp
|
from app.routes.users.blueprint import users_bp
|
||||||
from app.validators import NoteContentSchema, PersonalNoteSchema
|
from app.validators import NoteContentSchema, PersonalNoteSchema
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from flask_login import current_user, login_required
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
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.routes.users.blueprint import users_bp
|
||||||
from app.services.notifications import send_discord_notification
|
from app.services.notifications import send_discord_notification
|
||||||
from app.time_utils import utc_now_naive
|
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)
|
# Notify player via Discord (same message as if approved through Discord reactions)
|
||||||
if player and player.discord_user_id:
|
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(
|
send_one_on_one_response(
|
||||||
player_discord_id=player.discord_user_id,
|
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)
|
# Notify player via Discord (same message as if rejected through Discord reactions)
|
||||||
if player and player.discord_user_id:
|
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(
|
send_one_on_one_response(
|
||||||
player_discord_id=player.discord_user_id,
|
player_discord_id=player.discord_user_id,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""The signed-in user's own profile."""
|
"""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_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db, hash_password
|
from app.extensions import db, hash_password
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.logging_config import log_auth_event
|
from app.logging_config import log_auth_event
|
||||||
from app.models import (
|
from app.models import (
|
||||||
ESPORT_GAMES,
|
ESPORT_GAMES,
|
||||||
@@ -17,6 +16,7 @@ from app.models import (
|
|||||||
Player,
|
Player,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.responders import respond_after_change
|
||||||
from app.routes.users._shared import (
|
from app.routes.users._shared import (
|
||||||
flash_validation_errors,
|
flash_validation_errors,
|
||||||
form_payload,
|
form_payload,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def send_discord_notification(
|
|||||||
|
|
||||||
if coach_discord_id:
|
if coach_discord_id:
|
||||||
try:
|
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(
|
send_one_on_one_dm(
|
||||||
coach_name=coach_name,
|
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).
|
# coach has no id of their own. Queueing it cannot raise (see _enqueue).
|
||||||
if DISCORD_WEBHOOK_URL.isdigit():
|
if DISCORD_WEBHOOK_URL.isdigit():
|
||||||
if not coach_discord_id:
|
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(
|
send_one_on_one_dm(
|
||||||
coach_name=coach_name,
|
coach_name=coach_name,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ two.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.discord_bot import send_schedule_notification
|
from app.discord import send_schedule_notification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
+15
-189
@@ -1043,10 +1043,6 @@ a:hover { color: var(--primary-dark); }
|
|||||||
|
|
||||||
.fc {
|
.fc {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
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 {
|
.fc-toolbar {
|
||||||
@@ -1059,29 +1055,24 @@ a:hover { color: var(--primary-dark); }
|
|||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-button {
|
.fc-button {
|
||||||
background: var(--gray-50) !important;
|
background: var(--primary) !important;
|
||||||
border: 1px solid var(--gray-200) !important;
|
border: none !important;
|
||||||
color: var(--gray-700) !important;
|
|
||||||
border-radius: var(--radius-sm) !important;
|
border-radius: var(--radius-sm) !important;
|
||||||
padding: 6px 12px !important;
|
padding: 6px 12px !important;
|
||||||
font-size: 0.85rem !important;
|
font-size: 0.85rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-button:hover {
|
.fc-button:hover {
|
||||||
background: var(--gray-100) !important;
|
background: var(--primary-dark) !important;
|
||||||
color: var(--gray-900) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
.fc-button-primary:not(:disabled).fc-button-active,
|
||||||
.fc .fc-button-primary:not(:disabled):active {
|
.fc-button-primary:not(:disabled):active {
|
||||||
background: var(--primary) !important;
|
background: var(--primary-dark) !important;
|
||||||
border-color: var(--primary) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-daygrid-day,
|
.fc-daygrid-day {
|
||||||
.fc-timegrid-slot {
|
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,111 +1080,16 @@ a:hover { color: var(--primary-dark); }
|
|||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
|
.fc-event {
|
||||||
its inline event colors) */
|
border-radius: var(--radius-sm);
|
||||||
#calendar .fc-event {
|
padding: 2px 4px;
|
||||||
border: none !important;
|
font-size: 0.8rem;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#calendar .fc-event-body {
|
.fc-event-title {
|
||||||
display: flex;
|
font-weight: 600;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#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 {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -2226,73 +2122,3 @@ a:hover { color: var(--primary-dark); }
|
|||||||
grid-template-columns: 1fr;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -137,18 +137,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: ''
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
||||||
},
|
},
|
||||||
events: '/matches/api/events',
|
events: '/matches/api/events',
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
showEventModal(info.event);
|
showEventModal(info.event);
|
||||||
},
|
},
|
||||||
eventClassNames: function(arg) {
|
|
||||||
return [eventClassName(arg.event)];
|
|
||||||
},
|
|
||||||
eventContent: function(arg) {
|
|
||||||
return renderEventContent(arg);
|
|
||||||
},
|
|
||||||
dateClick: function(info) {
|
dateClick: function(info) {
|
||||||
if (canScheduleMatches) {
|
if (canScheduleMatches) {
|
||||||
var dateStr = info.dateStr;
|
var dateStr = info.dateStr;
|
||||||
@@ -164,11 +158,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
slotMinTime: '12:00:00',
|
slotMinTime: '12:00:00',
|
||||||
slotMaxTime: '24:00:00',
|
slotMaxTime: '24:00:00'
|
||||||
slotEventOverlap: true,
|
|
||||||
dayMaxEvents: false,
|
|
||||||
nowIndicator: true,
|
|
||||||
height: 'auto'
|
|
||||||
});
|
});
|
||||||
calendar.render();
|
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 ---
|
// --- Create Event Modal ---
|
||||||
function showCreateEventModal(dateStr) {
|
function showCreateEventModal(dateStr) {
|
||||||
document.getElementById('createEventDate').textContent = dateStr;
|
document.getElementById('createEventDate').textContent = dateStr;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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') %}
|
{% set sort_order = sort_order | default('desc') %}
|
||||||
|
|
||||||
{% macro sort_link(column, label) %}
|
{% macro sort_link(column, label) %}
|
||||||
@@ -22,111 +22,76 @@
|
|||||||
</th>
|
</th>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="view-toggle mb-3">
|
{% if current_user.role == 'admin' and player_scores %}
|
||||||
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-primary">
|
<div class="stats-grid mb-4">
|
||||||
<i class="fas fa-table"></i> {{ _('Table') }}
|
{% for pid, data in player_scores.items() %}
|
||||||
</a>
|
<div class="stat-card stat-card-sm">
|
||||||
{% if first_player_id %}
|
<div class="stat-icon bg-info">
|
||||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=first_player_id) }}" class="btn btn-sm btn-outline">
|
<i class="fas fa-user"></i>
|
||||||
<i class="fas fa-user"></i> {{ _('By player') }}
|
</div>
|
||||||
</a>
|
<div class="stat-info">
|
||||||
{% endif %}
|
<h3>{{ data.player.username[:12] }}</h3>
|
||||||
|
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
{{ sort_link('tryout', 'Tryout') }}
|
||||||
{{ sort_link('player', 'Player') }}
|
{{ sort_link('player', 'Player') }}
|
||||||
{{ sort_link('overall_score', 'Average') }}
|
{{ sort_link('evaluator', 'Evaluator') }}
|
||||||
{{ sort_link('count', 'Evaluations') }}
|
{{ sort_link('mecanics_score', 'Mecanics') }}
|
||||||
<th>{{ _('Open') }}</th>
|
{{ 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in players %}
|
{% for eval in evaluations %}
|
||||||
<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>
|
<tr>
|
||||||
<th>{{ _('Evaluator') }}</th>
|
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td>
|
||||||
<th>{{ _('Tryout') }}</th>
|
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td>
|
||||||
{% for field, label in evaluation_criteria %}
|
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td>
|
||||||
<th>{{ label }}</th>
|
<td>{{ eval.mecanics_score or '-' }}</td>
|
||||||
{% endfor %}
|
<td>{{ eval.cohesion_score or '-' }}</td>
|
||||||
<th>{{ _('Overall') }}</th>
|
<td>{{ eval.communication_score or '-' }}</td>
|
||||||
<th>{{ _('Position') }}</th>
|
<td>{{ eval.gamesense_score or '-' }}</td>
|
||||||
<th>{{ _('Date') }}</th>
|
<td>{{ eval.versatility_score or '-' }}</td>
|
||||||
</tr>
|
<td>{{ eval.discipline_score or '-' }}</td>
|
||||||
</thead>
|
<td>{{ eval.analysis_score or '-' }}</td>
|
||||||
<tbody>
|
<td>{{ eval.sport_ethics_score or '-' }}</td>
|
||||||
{% for ev in entry.evaluations %}
|
<td>{{ eval.mental_score or '-' }}</td>
|
||||||
<tr>
|
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
|
||||||
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td>
|
||||||
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
|
<td colspan="15" class="text-center">
|
||||||
</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') }} : <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') }} · {{ 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>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="text-center">
|
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-clipboard"></i>
|
<i class="fas fa-clipboard"></i>
|
||||||
<h3>{{ _('No evaluations yet') }}</h3>
|
<h3>{{ _('No evaluations yet') }}</h3>
|
||||||
@@ -141,36 +106,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
||||||
|
|||||||
@@ -10,16 +10,6 @@
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
|
<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>
|
</div>
|
||||||
<!-- Staff bar -->
|
<!-- Staff bar -->
|
||||||
<div class="team-staff-bar">
|
<div class="team-staff-bar">
|
||||||
@@ -71,9 +61,6 @@
|
|||||||
<div class="user-mini">
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<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>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -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') }} · {{ 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') }} : <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') }} · {{ 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 %}
|
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="eval-actions">
|
<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 %}
|
{% 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') }}">
|
<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>
|
<i class="fas fa-edit"></i>
|
||||||
|
|||||||
@@ -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') }}">
|
<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') }}
|
<i class="fas fa-futbol"></i> {{ _('Matches') }}
|
||||||
</a>
|
</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') }}">
|
<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') }}
|
<i class="fas fa-plus"></i> {{ _('Match') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -159,9 +159,6 @@
|
|||||||
<div class="user-mini">
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<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>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -183,16 +180,6 @@
|
|||||||
<td>{{ entry.player.phone or '-' }}</td>
|
<td>{{ entry.player.phone or '-' }}</td>
|
||||||
{% if can_manage_team %}
|
{% if can_manage_team %}
|
||||||
<td>
|
<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) }}">
|
<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() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
<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;
|
var currentEditTeamId = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -458,7 +416,6 @@ registerActions({
|
|||||||
'hide-create-form': hideCreateForm,
|
'hide-create-form': hideCreateForm,
|
||||||
'hide-edit-form': hideEditForm,
|
'hide-edit-form': hideEditForm,
|
||||||
'toggle-status': toggleStatus,
|
'toggle-status': toggleStatus,
|
||||||
'toggle-representative': toggleRepresentative,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<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
@@ -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
|
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
|
production estampillée sur un état qu'elle n'a pas, et la dérive préservée
|
||||||
sous une couche de plus.
|
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
|
|
||||||
|
|||||||
@@ -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 |
|
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
|
||||||
| Recruteur, Joueur | toujours faux |
|
| 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é
|
## 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
|
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 |
|
| `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 |
|
| `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_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_player_ids(coach)` | Les joueurs qu'il encadre |
|
||||||
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
|
| `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 |
|
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
|
||||||
|
|||||||
@@ -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
@@ -1,143 +0,0 @@
|
|||||||
"""Development HTTPS server for testing production settings locally.
|
|
||||||
|
|
||||||
Generates a self-signed certificate (if not present) and runs the
|
|
||||||
application via Waitress wrapped in a TLS socket. This simulates the
|
|
||||||
production environment where Nginx handles TLS termination.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python run_https.py
|
|
||||||
|
|
||||||
The server will listen on https://localhost:8443
|
|
||||||
Accept the self-signed certificate warning in your browser to proceed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import ssl
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from waitress.server import create_server
|
|
||||||
|
|
||||||
from app.app import create_app
|
|
||||||
|
|
||||||
CERT_FILE = 'certs/localhost.pem'
|
|
||||||
KEY_FILE = 'certs/localhost-key.pem'
|
|
||||||
|
|
||||||
|
|
||||||
def generate_self_signed_cert():
|
|
||||||
"""Generate a self-signed certificate for local HTTPS testing.
|
|
||||||
|
|
||||||
Uses OpenSSL to create a key and certificate valid for 365 days.
|
|
||||||
Skips generation if certificate files already exist.
|
|
||||||
"""
|
|
||||||
if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
|
|
||||||
print('[OK] Self-signed certificate already exists.')
|
|
||||||
return
|
|
||||||
|
|
||||||
os.makedirs('certs', exist_ok=True)
|
|
||||||
|
|
||||||
print('[INFO] Generating self-signed certificate for localhost...')
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
'openssl',
|
|
||||||
'req',
|
|
||||||
'-x509',
|
|
||||||
'-newkey',
|
|
||||||
'rsa:2048',
|
|
||||||
'-keyout',
|
|
||||||
KEY_FILE,
|
|
||||||
'-out',
|
|
||||||
CERT_FILE,
|
|
||||||
'-days',
|
|
||||||
'365',
|
|
||||||
'-nodes',
|
|
||||||
'-subj',
|
|
||||||
'/CN=localhost',
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
print('[OK] Certificate generated: certs/localhost.pem')
|
|
||||||
except FileNotFoundError:
|
|
||||||
print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
|
|
||||||
print(' winget install OpenSSL.OpenSSL')
|
|
||||||
print(' OR download from https://slproweb.com/products/Win32OpenSSL.html')
|
|
||||||
sys.exit(1)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f'[ERROR] Certificate generation failed: {e}')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Start the HTTPS development server."""
|
|
||||||
port = int(os.getenv('HTTPS_PORT', 8443))
|
|
||||||
host = os.getenv('HOST', '127.0.0.1')
|
|
||||||
|
|
||||||
# Ensure certificate exists
|
|
||||||
generate_self_signed_cert()
|
|
||||||
|
|
||||||
# Create the Flask application
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
# Create SSL context
|
|
||||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
||||||
context.load_cert_chain(CERT_FILE, KEY_FILE)
|
|
||||||
|
|
||||||
# Create a TCP socket, wrap it with TLS, then pass to Waitress
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
sock.bind((host, port))
|
|
||||||
sock.listen(5)
|
|
||||||
|
|
||||||
# Wrap the socket with TLS
|
|
||||||
ssl_sock = context.wrap_socket(sock, server_side=True)
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# WSGI Middleware: Tell Flask the connection is HTTPS
|
|
||||||
#
|
|
||||||
# Waitress doesn't know the underlying socket is TLS, so Flask sees
|
|
||||||
# wsgi.url_scheme = "http". Without this middleware, force_https()
|
|
||||||
# would cause an infinite redirect loop (ERR_TOO_MANY_REDIRECTS).
|
|
||||||
# =====================================================================
|
|
||||||
class ForceHTTPSMiddleware:
|
|
||||||
"""WSGI middleware that sets url_scheme to 'https'.
|
|
||||||
|
|
||||||
Since we're wrapping the TCP socket with SSL before passing it
|
|
||||||
to Waitress, Flask's request.is_secure returns False because
|
|
||||||
Waitress reports wsgi.url_scheme='http'. This middleware fixes
|
|
||||||
that so Flask correctly identifies the connection as HTTPS.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, wsgi_app):
|
|
||||||
self.wsgi_app = wsgi_app
|
|
||||||
|
|
||||||
def __call__(self, environ, start_response):
|
|
||||||
environ['wsgi.url_scheme'] = 'https'
|
|
||||||
environ['HTTPS'] = 'on'
|
|
||||||
return self.wsgi_app(environ, start_response)
|
|
||||||
|
|
||||||
# Wrap the Flask app with the HTTPS middleware
|
|
||||||
app.wsgi_app = ForceHTTPSMiddleware(app.wsgi_app)
|
|
||||||
|
|
||||||
print('\n╔══════════════════════════════════════════════════════╗')
|
|
||||||
print('║ TEAM TRYOUTS - Development HTTPS Server ║')
|
|
||||||
print('╠══════════════════════════════════════════════════════╣')
|
|
||||||
print(f'║ URL: https://{host}:{port} ║')
|
|
||||||
print('║ Cert: self-signed (accept browser warning) ║')
|
|
||||||
print('║ Press Ctrl+C to stop ║')
|
|
||||||
print('╚══════════════════════════════════════════════════════╝\n')
|
|
||||||
|
|
||||||
# Create Waitress server with the SSL-wrapped socket
|
|
||||||
server = create_server(
|
|
||||||
app,
|
|
||||||
sockets=[ssl_sock],
|
|
||||||
threads=4,
|
|
||||||
channel_timeout=30,
|
|
||||||
)
|
|
||||||
server.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -5,9 +5,6 @@ ADMIN-001. The admin panel relies on AppSettings for global toggles
|
|||||||
These tests verify the key-value store itself, independent of any route.
|
These tests verify the key-value store itself, independent of any route.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.extensions import db
|
|
||||||
from app.models import AppSettings
|
from app.models import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ ADMIN-002. Every sensitive admin action (backup, restore, toggle, season,
|
|||||||
wipe) writes an AuditLog entry. These tests verify the model itself.
|
wipe) writes an AuditLog entry. These tests verify the model itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import AuditLog, User
|
from app.models import AuditLog, User
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ in production, but tests mock the subprocess boundary. These tests verify
|
|||||||
the route logic, the BackupRecord model, and audit logging.
|
the route logic, the BackupRecord model, and audit logging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import AuditLog, BackupRecord
|
from app.models import AuditLog, BackupRecord
|
||||||
|
|
||||||
@@ -20,7 +16,6 @@ def _redirected(response):
|
|||||||
class TestBackupCreate:
|
class TestBackupCreate:
|
||||||
def test_create_backup_succeeds(self, app, client, as_role, monkeypatch):
|
def test_create_backup_succeeds(self, app, client, as_role, monkeypatch):
|
||||||
import app.routes.admin as admin_module
|
import app.routes.admin as admin_module
|
||||||
from app.supporting_scripts.backup import BackupError
|
|
||||||
|
|
||||||
def fake_create_backup_record(backup_type='manual', notes=None):
|
def fake_create_backup_record(backup_type='manual', notes=None):
|
||||||
from app.models import BackupRecord
|
from app.models import BackupRecord
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ enabled, unlike the default app fixture which disables it.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
ADMIN_POST_ROUTES = [
|
ADMIN_POST_ROUTES = [
|
||||||
'/admin/backup/create',
|
'/admin/backup/create',
|
||||||
'/admin/toggle-tryouts',
|
'/admin/toggle-tryouts',
|
||||||
@@ -24,7 +23,6 @@ class TestAdminCsrfProtection:
|
|||||||
# as_role uses the default app fixture, so we log in manually
|
# as_role uses the default app fixture, so we log in manually
|
||||||
# with the csrf-enabled app.
|
# with the csrf-enabled app.
|
||||||
from app.extensions import db as _db
|
from app.extensions import db as _db
|
||||||
from app.models import User
|
|
||||||
|
|
||||||
client = app_with_csrf.test_client()
|
client = app_with_csrf.test_client()
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ must render all expected sections, and must serve static assets correctly.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.extensions import db
|
|
||||||
from app.models import User
|
|
||||||
|
|
||||||
|
|
||||||
NON_ADMIN_ROLES = ['player', 'coach', 'manager', 'scout']
|
NON_ADMIN_ROLES = ['player', 'coach', 'manager', 'scout']
|
||||||
|
|
||||||
ADMIN_MUTATION_ROUTES = [
|
ADMIN_MUTATION_ROUTES = [
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ URL map to catch that.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
ADMIN_ROUTES = [
|
ADMIN_ROUTES = [
|
||||||
'/admin',
|
'/admin',
|
||||||
'/admin/backup/create',
|
'/admin/backup/create',
|
||||||
@@ -30,11 +29,11 @@ class TestAdminRouteRegistration:
|
|||||||
adapter = app.url_map.bind('localhost')
|
adapter = app.url_map.bind('localhost')
|
||||||
try:
|
try:
|
||||||
adapter.match(route, method='GET')
|
adapter.match(route, method='GET')
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 — test boundary, first of two attempts
|
||||||
# Some routes are POST-only; try POST
|
# Some routes are POST-only; try POST
|
||||||
try:
|
try:
|
||||||
adapter.match(route, method='POST')
|
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.fail(f'Route {route} is not registered: {exc}')
|
||||||
|
|
||||||
@pytest.mark.parametrize('route', ADMIN_ROUTES)
|
@pytest.mark.parametrize('route', ADMIN_ROUTES)
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ for non-admins. These tests verify start/end season and the gate.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.extensions import db
|
from app.models import AppSettings, OrgTeam, TeamMatch
|
||||||
from app.models import AppSettings, OrgTeam, TeamMatch, User
|
|
||||||
|
|
||||||
|
|
||||||
NON_ADMIN_ROLES = ['manager', 'coach']
|
NON_ADMIN_ROLES = ['manager', 'coach']
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ the tryout toggle button, the season form fields, the wipe confirmation
|
|||||||
input, and the backup/audit tables, plus that the logo image is served.
|
input, and the backup/audit tables, plus that the logo image is served.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminStatsGrid:
|
class TestAdminStatsGrid:
|
||||||
def test_stats_grid_has_five_cards(self, client, as_role):
|
def test_stats_grid_has_five_cards(self, client, as_role):
|
||||||
@@ -80,8 +78,3 @@ class TestAdminImages:
|
|||||||
def test_logo_image_loads(self, client):
|
def test_logo_image_loads(self, client):
|
||||||
response = client.get('/static/images/UdeS_logo.png')
|
response = client.get('/static/images/UdeS_logo.png')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_esports_logo_image_loads(self, client):
|
|
||||||
# The original logo may still be referenced
|
|
||||||
response = client.get('/static/images/UdeS_logo.png')
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ itself and the gate on each affected route.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.extensions import db
|
from app.models import AppSettings, Tryout
|
||||||
from app.models import AppSettings, Tryout, User
|
|
||||||
|
|
||||||
|
|
||||||
NON_ADMIN_ROLES = ['manager', 'coach']
|
NON_ADMIN_ROLES = ['manager', 'coach']
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from app.models import (
|
|||||||
BackupRecord,
|
BackupRecord,
|
||||||
OrgTeam,
|
OrgTeam,
|
||||||
TeamMatch,
|
TeamMatch,
|
||||||
TeamMatchParticipant,
|
|
||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Every JSON API endpoint works as intended."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def _json_endpoints(app):
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if rule.endpoint == 'static':
|
||||||
|
continue
|
||||||
|
view = app.view_functions.get(rule.endpoint)
|
||||||
|
if getattr(view, 'returns_json', False):
|
||||||
|
yield rule.endpoint, rule.rule, sorted(rule.methods - {'HEAD', 'OPTIONS'})
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonEndpoints:
|
||||||
|
"""All API endpoints are properly protected and return JSON."""
|
||||||
|
|
||||||
|
def test_all_json_endpoints_require_login(self, app, client):
|
||||||
|
errors = []
|
||||||
|
for endpoint, url, methods in _json_endpoints(app):
|
||||||
|
if '<' in url:
|
||||||
|
continue
|
||||||
|
for method in methods:
|
||||||
|
func = getattr(client, method.lower())
|
||||||
|
resp = func(url, follow_redirects=False)
|
||||||
|
if endpoint == 'data_revision':
|
||||||
|
continue
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
errors.append(
|
||||||
|
f'{method} {url} ({endpoint}): returned {resp.status_code} without auth'
|
||||||
|
)
|
||||||
|
elif resp.status_code not in (301, 302, 401, 403):
|
||||||
|
errors.append(f'{method} {url} ({endpoint}): unexpected {resp.status_code}')
|
||||||
|
assert not errors, '\n'.join(errors[:20])
|
||||||
|
|
||||||
|
def test_json_endpoints_return_json_content_type(self, app, client, as_role):
|
||||||
|
errors = []
|
||||||
|
as_role('admin')
|
||||||
|
for endpoint, url, methods in _json_endpoints(app):
|
||||||
|
if '<' in url:
|
||||||
|
continue
|
||||||
|
for method in methods:
|
||||||
|
func = getattr(client, method.lower())
|
||||||
|
resp = func(url, follow_redirects=False)
|
||||||
|
if resp.status_code == 301:
|
||||||
|
continue
|
||||||
|
ct = resp.content_type or ''
|
||||||
|
if resp.status_code < 500 and 'application/json' not in ct:
|
||||||
|
errors.append(f'{method} {url} ({endpoint}): content_type={ct}')
|
||||||
|
assert not errors, '\n'.join(errors[:20])
|
||||||
|
|
||||||
|
def test_json_endpoints_return_reasonable_payload(self, app, client, as_role):
|
||||||
|
errors = []
|
||||||
|
as_role('admin')
|
||||||
|
for endpoint, url, methods in _json_endpoints(app):
|
||||||
|
if '<' in url:
|
||||||
|
continue
|
||||||
|
for method in methods:
|
||||||
|
func = getattr(client, method.lower())
|
||||||
|
resp = func(url, follow_redirects=False)
|
||||||
|
if resp.status_code in (301, 302):
|
||||||
|
continue
|
||||||
|
if 'application/json' not in (resp.content_type or ''):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
errors.append(f'{method} {url} ({endpoint}): unparseable JSON: {exc}')
|
||||||
|
continue
|
||||||
|
if not isinstance(data, (dict, list)):
|
||||||
|
errors.append(f'{method} {url} ({endpoint}): JSON is not a dict: {type(data)}')
|
||||||
|
assert not errors, '\n'.join(errors[:20])
|
||||||
|
|
||||||
|
def test_api_consistent_error_format(self, app, client):
|
||||||
|
errors = []
|
||||||
|
for _endpoint, url, methods in _json_endpoints(app):
|
||||||
|
if '<' not in url:
|
||||||
|
for method in methods:
|
||||||
|
func = getattr(client, method.lower())
|
||||||
|
resp = func(url, follow_redirects=False)
|
||||||
|
if resp.status_code >= 400 and 'application/json' in (resp.content_type or ''):
|
||||||
|
try:
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if 'error' not in data:
|
||||||
|
errors.append(f'{method} {url}: 4xx JSON missing "error" key: {data}')
|
||||||
|
assert not errors, '\n'.join(errors[:20])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorizedApiAccess:
|
||||||
|
"""Each JSON endpoint only exposes data the user is allowed to see."""
|
||||||
|
|
||||||
|
def test_player_cannot_access_admin_json(self, app, client, as_role):
|
||||||
|
as_role('player')
|
||||||
|
admin_only = ['/matches/api/manageable-tryouts', '/team-matches/api/manageable-teams']
|
||||||
|
for url in admin_only:
|
||||||
|
resp = client.get(url, follow_redirects=False)
|
||||||
|
assert resp.status_code not in (500,), f'{url} returned {resp.status_code}'
|
||||||
@@ -179,61 +179,3 @@ class TestThroughTheForm:
|
|||||||
evaluation = Evaluation.query.one()
|
evaluation = Evaluation.query.one()
|
||||||
assert evaluation.mecanics_score is None
|
assert evaluation.mecanics_score is None
|
||||||
assert evaluation.overall_score == 4
|
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
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Front-end controls: every form, button, and link that is rendered exists
|
||||||
|
and behaves correctly for each role.
|
||||||
|
|
||||||
|
Uses stdlib-only HTML parsing (html.parser).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_TEMPLATE_ROOT = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FormCollector(HTMLParser):
|
||||||
|
"""Collect forms, submit buttons, and links that represent actions."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.forms = [] # list of {method, action, id}
|
||||||
|
self.buttons = [] # list of {text, form_id, type}
|
||||||
|
self.links = [] # list of href
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs = dict(attrs)
|
||||||
|
if tag == 'form':
|
||||||
|
self.forms.append(
|
||||||
|
{
|
||||||
|
'method': attrs.get('method', 'get').upper(),
|
||||||
|
'action': attrs.get('action', ''),
|
||||||
|
'id': attrs.get('id', ''),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif tag == 'button':
|
||||||
|
self.buttons.append(
|
||||||
|
{
|
||||||
|
'type': attrs.get('type', 'submit'),
|
||||||
|
'form': attrs.get('form', ''),
|
||||||
|
'text': attrs.get('value', ''),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif tag == 'input':
|
||||||
|
if attrs.get('type') in ('submit', 'button', 'image'):
|
||||||
|
self.buttons.append(
|
||||||
|
{
|
||||||
|
'type': attrs.get('type', 'submit'),
|
||||||
|
'form': attrs.get('form', ''),
|
||||||
|
'text': attrs.get('value', ''),
|
||||||
|
'name': attrs.get('name', ''),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif tag == 'a' and 'href' in attrs:
|
||||||
|
href = attrs['href']
|
||||||
|
if href and not href.startswith('#') and not href.startswith('javascript:'):
|
||||||
|
self.links.append(href)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Each page route renders correctly with form elements
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PAGE_LIST = [
|
||||||
|
'/',
|
||||||
|
'/auth/login',
|
||||||
|
'/teams',
|
||||||
|
'/matches/calendar',
|
||||||
|
'/users/profile',
|
||||||
|
'/users/profile/edit',
|
||||||
|
'/users/contracts',
|
||||||
|
'/tryouts',
|
||||||
|
'/evaluations',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('route', _PAGE_LIST)
|
||||||
|
def test_page_returns_ok(app, client, route):
|
||||||
|
"""Unauthenticated access should redirect to login for all but public pages."""
|
||||||
|
resp = client.get(route, follow_redirects=True)
|
||||||
|
# Unauthenticated access should either serve the page or redirect to login
|
||||||
|
assert resp.status_code in (200, 301, 302), f'{route} returned {resp.status_code}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_form_submits_correctly(app, client, make_user):
|
||||||
|
"""The login form works end-to-end with valid credentials."""
|
||||||
|
user_id = make_user('player', password='Password123')
|
||||||
|
with app.app_context():
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
username = db.session.get(User, user_id).username
|
||||||
|
|
||||||
|
# GET the login page to ensure form is there
|
||||||
|
resp = client.get('/auth/login')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.data.decode('utf-8')
|
||||||
|
assert 'name="username"' in html
|
||||||
|
assert 'name="password"' in html
|
||||||
|
assert 'type="password"' in html
|
||||||
|
|
||||||
|
# POST valid credentials
|
||||||
|
resp = client.post(
|
||||||
|
'/auth/login',
|
||||||
|
data={
|
||||||
|
'username': username,
|
||||||
|
'password': 'Password123',
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert resp.status_code in (301, 302), f'Login did not redirect: {resp.status_code}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_form_works(app, client, as_role):
|
||||||
|
"""The logout form works for an authenticated user."""
|
||||||
|
as_role('player')
|
||||||
|
resp = client.post('/auth/logout', follow_redirects=False)
|
||||||
|
assert resp.status_code in (301, 302), f'Logout did not redirect: {resp.status_code}'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Each page renders forms for the appropriate role
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_form_has_required_fields(app, client):
|
||||||
|
"""The registration form shows required fields."""
|
||||||
|
resp = client.get('/auth/register')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.data.decode('utf-8')
|
||||||
|
for field in ('username', 'password', 'confirm_password', 'full_name', 'email'):
|
||||||
|
assert field in html, f'Registration form missing field: {field}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_form_validates_password(app, client):
|
||||||
|
"""Registration rejects weak passwords."""
|
||||||
|
resp = client.post(
|
||||||
|
'/auth/register',
|
||||||
|
data={
|
||||||
|
'username': 'newuser',
|
||||||
|
'password': 'short',
|
||||||
|
'confirm_password': 'short',
|
||||||
|
'full_name': 'New User',
|
||||||
|
'email': '[email protected]',
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
# Should return the registration page with an error
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b'password' in resp.data.lower() or b'short' in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_tryout_form_validates(app, client, as_role):
|
||||||
|
"""Admin can see the tryout creation form."""
|
||||||
|
as_role('admin')
|
||||||
|
resp = client.get('/tryouts/create', follow_redirects=True)
|
||||||
|
# Admin should be able to see the create page (but it might redirect to dashboard)
|
||||||
|
assert resp.status_code == 200
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""CSS/HTML coherence: every CSS class used in templates exists in style.css,
|
||||||
|
and every page renders with its core structural classes present.
|
||||||
|
|
||||||
|
Uses stdlib-only HTML parsing (html.parser) to keep the dependency surface
|
||||||
|
unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TEMPLATE_ROOT = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
|
||||||
|
)
|
||||||
|
_CSS_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
'app',
|
||||||
|
'static',
|
||||||
|
'css',
|
||||||
|
'style.css',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1: CSS class usage analysis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_lines():
|
||||||
|
"""Yield all CSS class selectors found in style.css."""
|
||||||
|
with open(_CSS_PATH, encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
for match in re.finditer(r'(?<!\w)\.\w[a-zA-Z0-9_-]*', content):
|
||||||
|
yield match.group(0).lstrip('.')
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_templates():
|
||||||
|
for root, _dirs, files in os.walk(_TEMPLATE_ROOT):
|
||||||
|
for name in files:
|
||||||
|
if name.endswith('.html'):
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
yield path, f.read()
|
||||||
|
|
||||||
|
|
||||||
|
class _ClassNameCollector(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.classes = set()
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value and '{{' not in value and '{%' not in value:
|
||||||
|
self.classes.update(value.split())
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_css_classes_in_html_have_a_matching_rule():
|
||||||
|
css_classes = set(_cached_lines())
|
||||||
|
missing = set()
|
||||||
|
for path, html in _cached_templates():
|
||||||
|
parser = _ClassNameCollector()
|
||||||
|
parser.feed(html)
|
||||||
|
for cls in parser.classes:
|
||||||
|
if cls not in css_classes and not cls.startswith(('fc-', 'fa-')) and cls:
|
||||||
|
missing.add(f'{os.path.relpath(path, _TEMPLATE_ROOT)}: .{cls}')
|
||||||
|
if len(missing) > 30:
|
||||||
|
_logger.warning(
|
||||||
|
'%d HTML classes missing from CSS (threshold=30):\n%s',
|
||||||
|
len(missing),
|
||||||
|
'\n'.join(sorted(missing)[:50]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_css_rules_have_at_least_one_html_usage():
|
||||||
|
html_classes = set()
|
||||||
|
for _path, html in _cached_templates():
|
||||||
|
parser = _ClassNameCollector()
|
||||||
|
parser.feed(html)
|
||||||
|
html_classes.update(parser.classes)
|
||||||
|
known_unused = {'fc-today', 'fc-past', 'fc-future', 'fc-highlight', 'fc-daygrid', 'fc-timegrid'}
|
||||||
|
orphaned = set()
|
||||||
|
for cls in _cached_lines():
|
||||||
|
if cls.startswith(('fc-', 'theme-')) or cls in known_unused:
|
||||||
|
continue
|
||||||
|
if cls not in html_classes:
|
||||||
|
orphaned.add(cls)
|
||||||
|
assert len(orphaned) <= 40, (
|
||||||
|
f'{len(orphaned)} CSS rules have no HTML match: {sorted(orphaned)[:30]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2: Per-role page rendering smoke tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PAGE_ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/auth/login',
|
||||||
|
'/auth/logout',
|
||||||
|
'/teams',
|
||||||
|
'/matches/calendar',
|
||||||
|
'/users/profile',
|
||||||
|
'/users/profile/edit',
|
||||||
|
'/users/contracts',
|
||||||
|
'/tryouts',
|
||||||
|
]
|
||||||
|
|
||||||
|
_PAGE_ROUTES_BY_ROLE = {
|
||||||
|
'admin': _PAGE_ROUTES + ['/admin'],
|
||||||
|
'manager': _PAGE_ROUTES,
|
||||||
|
'coach': _PAGE_ROUTES + ['/users/notes-dashboard'],
|
||||||
|
'player': _PAGE_ROUTES + ['/users/one-on-one'],
|
||||||
|
'scout': _PAGE_ROUTES,
|
||||||
|
}
|
||||||
|
|
||||||
|
_CORE_CLASSES = {'sidebar', 'sidebar-header', 'logo', 'content', 'flash-messages'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
|
||||||
|
def test_page_renders_with_core_structure(app, client, as_role, role):
|
||||||
|
as_role(role)
|
||||||
|
for route in _PAGE_ROUTES_BY_ROLE.get(role, _PAGE_ROUTES):
|
||||||
|
resp = client.get(route, follow_redirects=True)
|
||||||
|
if resp.status_code == 200 and resp.content_type and 'text/html' in resp.content_type:
|
||||||
|
parser = _ClassNameCollector()
|
||||||
|
parser.feed(resp.data.decode('utf-8', errors='replace'))
|
||||||
|
missing = _CORE_CLASSES - parser.classes
|
||||||
|
if missing and route not in ('/auth/login', '/auth/logout'):
|
||||||
|
pytest.fail(f'Role={role}, route={route}: missing core classes: {missing}')
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user