7 Commits
Author SHA1 Message Date
cedrick2711 e53bc3b80d first cleanup iter
CI - Security, Lint & Tests / validate (push) Failing after 25m59s
2026-09-09 08:26:47 -04:00
cedrick2711 101e3ae880 First cleanup iteration 2026-09-09 08:26:40 -04:00
cedrick2711 b9791d1547 chhangement du comportement du discord bot sur modif des matchs
CI - Security, Lint & Tests / validate (push) Failing after 59s
2026-09-08 12:46:44 -04:00
cedrick2711 4a2e2f053f Changement du refresh handler, soft reset d'ajouter sur beaucoup de formulaire de modification
CI - Security, Lint & Tests / validate (push) Failing after 1m3s
2026-09-08 11:27:53 -04:00
cedrick2711 e2e2b1b07e premier fix pour le discord bot qui envoie des reminders sur des versions de matchs qui n'existent plus
CI - Security, Lint & Tests / validate (push) Failing after 1m5s
2026-09-06 13:49:07 -04:00
cedrick2711 e4a271cfd8 changer gestion des team intra tryout
CI - Security, Lint & Tests / validate (push) Failing after 1m4s
2026-09-05 14:38:31 -04:00
cedrick2711 742995d3cd fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs
CI - Security, Lint & Tests / validate (push) Failing after 1m0s
2026-09-05 12:50:48 -04:00
72 changed files with 3584 additions and 2854 deletions
-40
View File
@@ -1,40 +0,0 @@
"""Add Drive signed upload test."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
drive_test = (
b"\n def test_signed_upload_with_google_drive(\n"
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
b" ):\n"
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
b" monkeypatch.setattr(\n"
b" 'app.storage.upload_google_drive_file',\n"
b" lambda **_kwargs: 'drive-signed-456',\n"
b" )\n"
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status == 'signed'\n"
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
)
# Add before 'class TestTheHelper:'
marker = b'\nclass TestTheHelper:'
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + drive_test + t[pos:]
p.write_bytes(t)
print('Added Drive signed upload test')
else:
print('Could not find marker')
-48
View File
@@ -1,48 +0,0 @@
"""Add new tests to test_contract_uploads.py."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
marker = b'\n\nclass TestTheHelper:'
test = (
b"\n def test_a_header_only_pdf_is_refused(\n"
b" self, app, client, as_role, make_user, contract_for\n"
b" ):\n"
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status != 'signed'\n"
b" assert contract.signed_file_path is None\n\n"
)
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + test + t[pos:]
# 2. Header-only helper test at end of TestTheHelper
trailing = t.rstrip(b'\n')
idx = t.find(trailing) + len(trailing)
helper = (
b"\n def test_a_header_only_file_is_refused(self, app):\n"
b' """A stream starting with %PDF- but holding non-PDF data is\n'
b" rejected (the header check is not enough).\"\"\"\n"
b" from werkzeug.datastructures import FileStorage\n\n"
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
b" with app.test_request_context('/'):\n"
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
)
t = t[:idx] + helper + t[idx:]
p.write_bytes(t)
print('Added new tests')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

+54 -4
View File
@@ -26,8 +26,10 @@ from sqlalchemy import text
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from app import i18n from app import i18n
from app.api import json_endpoint
from app.extensions import babel, csrf, db, limiter, login_manager from app.extensions import babel, csrf, db, limiter, login_manager
from app.pagination import page_url from app.pagination import page_url
from app.responders import current_revision, is_ajax_submit
load_dotenv() load_dotenv()
@@ -347,6 +349,42 @@ def create_app(config=None):
response.headers['X-Request-Id'] = g.get('request_id', '-') response.headers['X-Request-Id'] = g.get('request_id', '-')
return response return response
@app.before_request
def reject_stale_ajax_write():
"""Reject an AJAX edit whose view is stale, instead of overwriting.
The AJAX form handler in main.js sends the revision it last saw in the
X-Revision header. If another client has committed since then the
server's revision has moved on, and clobbering their change would be
silent last-write-wins. Answer 409 so the client asks the user to
reload the page first.
"""
if request.method != 'POST':
return None
if not is_ajax_submit():
return None
sent = request.headers.get('X-Revision')
if sent is None:
return None
try:
sent = int(sent)
except (TypeError, ValueError):
return None
if sent != current_revision():
return (
jsonify(
{
'error': 'conflict',
'message': _(
'This page was changed by someone else. Reload to '
'see the latest changes before saving.'
),
}
),
409,
)
return None
@app.url_defaults @app.url_defaults
def version_static_urls(endpoint, values): def version_static_urls(endpoint, values):
"""Stamp every static URL with the file's modification time. """Stamp every static URL with the file's modification time.
@@ -403,6 +441,7 @@ def create_app(config=None):
configure_logging(app) configure_logging(app)
from app.routes.admin import admin_bp
from app.routes.auth import auth_bp from app.routes.auth import auth_bp
from app.routes.evaluations import evaluations_bp from app.routes.evaluations import evaluations_bp
from app.routes.main import main_bp from app.routes.main import main_bp
@@ -411,7 +450,6 @@ def create_app(config=None):
from app.routes.teams import teams_bp from app.routes.teams import teams_bp
from app.routes.tryouts import tryouts_bp from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp from app.routes.users import users_bp
from app.routes.admin import admin_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp) app.register_blueprint(tryouts_bp)
@@ -512,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()
@@ -531,6 +569,18 @@ def create_app(config=None):
return jsonify(health_data), 200 return jsonify(health_data), 200
@app.route('/api/revision')
@json_endpoint
def data_revision():
"""The current dataset revision, for the AJAX form handler in main.js.
One primary-key lookup in app_settings. The client compares this with
the revision it last saw to decide whether a focus refresh is needed,
and sends it back with every AJAX mutation so a stale edit is rejected
with a 409 instead of overwriting someone else's change.
"""
return jsonify({'revision': current_revision()})
# ========================================================================= # =========================================================================
# Custom Error Handlers # Custom Error Handlers
# ========================================================================= # =========================================================================
@@ -700,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
@@ -725,4 +775,4 @@ def create_app(config=None):
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000 # own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This # while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
# module defines the factory; run.py starts it for development, wsgi.py for # module defines the factory; run.py starts it for development, wsgi.py for
# production (ARCH-007). # production (ARCH-007).
+29
View File
@@ -0,0 +1,29 @@
"""Discord notification subsystem.
Split from a single 1654-line module (app/discord_bot.py) into a package with
one concern per module (ARCH-003).
Public API — these are the functions that Flask routes and services import:
start_bot, bot_status, get_bot → .bot
send_one_on_one_dm, send_schedule_notification,
send_one_on_one_response → .messaging
purge_pending_for_participants → .pending
"""
from app.discord.bot import bot_status, get_bot, start_bot
from app.discord.messaging import (
send_one_on_one_dm,
send_one_on_one_response,
send_schedule_notification,
)
from app.discord.pending import purge_pending_for_participants
__all__ = [
'bot_status',
'get_bot',
'purge_pending_for_participants',
'send_one_on_one_dm',
'send_one_on_one_response',
'send_schedule_notification',
'start_bot',
]
+702
View File
@@ -0,0 +1,702 @@
"""Discord bot lifecycle, class definition, and supervisor."""
import logging
import threading
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.exc import SQLAlchemyError
from app.discord.constants import (
BOT_RESTART_DELAY_SECONDS,
BOT_RESTART_MAX_DELAY_SECONDS,
BOT_RESTART_RESET_SECONDS,
CHECK_EMOJI,
CROSS_EMOJI,
DISCORD_BOT_TOKEN,
TIMEZONE,
USER_CACHE_MAX,
)
from app.discord.pending import (
forget_pending,
load_pending,
save_pending,
)
from app.discord.queue import message_queue
from app.time_utils import utc_now_naive
from discord import (
Forbidden,
HTTPException,
Intents,
LoginFailure,
NotFound,
PrivilegedIntentsRequired,
)
from discord.ext import commands
_logger = logging.getLogger(__name__)
bot_instance = None
bot_thread = None
bot_flask_app = None
_stop_bot = threading.Event()
class TeamTryoutsBot(commands.Bot):
"""Unified Discord bot for Team Tryouts notifications."""
def __init__(self, flask_app=None):
intents = Intents.default()
intents.message_content = True
intents.dm_messages = True
intents.dm_reactions = True
intents.reactions = True
intents.guilds = True
super().__init__(command_prefix='!', intents=intents)
self.flask_app = flask_app
self.pending_requests = load_pending()
self.message_queue = message_queue
self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo(TIMEZONE)
self._user_cache = {}
self.reminders_scheduled = False
# ------------------------------------------------------------------
# Lifecycle hooks
# ------------------------------------------------------------------
async def setup_hook(self):
self.loop.create_task(self.process_queue())
async def on_error(self, event_method, *args, **kwargs):
_logger.error('Unhandled error in %s', event_method, exc_info=True)
async def on_ready(self):
_logger.info('Bot logged in as %s', self.user)
await self.start_scheduler()
async def start_scheduler(self):
if self.reminders_scheduled:
_logger.debug('Reminders already scheduled, skipping')
return
try:
self.scheduler.add_job(
self._send_daily_reminders_impl,
CronTrigger(hour=18, minute=0, timezone=self.timezone),
id='daily_match_check',
replace_existing=True,
)
self.scheduler.start()
self.reminders_scheduled = True
_logger.info('Daily reminder scheduled for 18:00 %s', TIMEZONE)
except Exception:
_logger.exception('Failed to start the daily reminder scheduler')
self.reminders_scheduled = False
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _reply(self, channel, message):
try:
await channel.send(message)
except (HTTPException, Forbidden) as exc:
_logger.warning('Could not reply to channel: %s', exc)
async def _commit_or_report(self, channel, *, operation):
from app.extensions import db
try:
operation()
db.session.commit()
except SQLAlchemyError:
db.session.rollback()
_logger.exception('DB commit failed for %s', operation.__name__)
await self._reply(channel, 'Something went wrong saving that. Please try again.')
def _forget_pending(self, message_id):
self.pending_requests = forget_pending(self.pending_requests, message_id)
async def _resolve_user(self, discord_uid, *, context=''):
uid = str(discord_uid)
if uid in self._user_cache:
return self._user_cache[uid]
try:
user = await self.fetch_user(int(uid))
except (NotFound, HTTPException) as exc:
_logger.warning('Could not resolve Discord user %s (%s): %s', uid, context, exc)
return None
if len(self._user_cache) >= USER_CACHE_MAX:
self._user_cache.pop(next(iter(self._user_cache)))
self._user_cache[uid] = user
return user
async def _send_dm(self, discord_uid, message, *, purpose, recipient=''):
user = await self._resolve_user(discord_uid, context=purpose)
if not user:
_logger.warning(
'Could not send DM (%s): user not found', purpose, recipient or discord_uid
)
return None
try:
sent = await user.send(message)
_logger.info(
'DM sent (%s) to %s (%s)', purpose, recipient or user.display_name, discord_uid
)
return sent
except Forbidden:
_logger.warning('DMs closed for %s (%s)', recipient or discord_uid, discord_uid)
except HTTPException as exc:
_logger.warning('Failed to DM %s: %s', discord_uid, exc)
except Exception:
_logger.exception('DM to %s failed unexpectedly', discord_uid)
return None
async def _dispatch(self, item):
kind = item['type']
data = item['data']
_logger.debug('Dispatching %s', kind)
try:
if kind == 'one_on_one_request':
await self._send_one_on_one_dm(**data)
elif kind == 'schedule_addition':
await self._send_schedule_notification(**data)
elif kind == 'one_on_one_response':
await self._send_one_on_one_response_dm(**data)
elif kind == 'purge_pending':
pids = data.get('participant_ids', [])
self.pending_requests = {
mid: e
for mid, e in self.pending_requests.items()
if e.get('type') != 'attendance' or e.get('reference_id') not in pids
}
save_pending(self.pending_requests)
else:
_logger.warning('Unknown queue item type: %s', kind)
except Exception:
_logger.exception('Error dispatching %s', kind)
async def process_queue(self):
while True:
try:
item = self.message_queue.get(timeout=5)
except __import__('queue').Empty:
continue
await self._dispatch(item)
def is_dm_channel(self, channel) -> bool:
return channel.type.name == 'private'
# ------------------------------------------------------------------
# Reaction handling
# ------------------------------------------------------------------
async def on_raw_reaction_add(self, payload):
if payload.user_id == self.user.id:
return
message_id = payload.message_id
entry = self.pending_requests.get(str(message_id))
if not entry:
return
channel = self.get_channel(payload.channel_id)
if not channel:
return
reacting_user = payload.member or await self._resolve_user(
payload.user_id,
context='reaction handler',
)
if not reacting_user:
return
entry_type = entry.get('type')
reference_id = entry.get('reference_id')
emoji = str(payload.emoji)
if emoji == CHECK_EMOJI:
if entry_type == 'one_on_one':
await self.handle_one_on_one_approve(
reacting_user, message_id, reference_id, channel
)
elif entry_type in ('match_attendance', 'tryout_attendance'):
await self.handle_attendance_confirm(
reacting_user, message_id, reference_id, channel
)
elif emoji == CROSS_EMOJI:
if entry_type == 'one_on_one':
await self.handle_one_on_one_reject(
reacting_user, message_id, reference_id, channel
)
elif entry_type in ('match_attendance', 'tryout_attendance'):
await self.handle_attendance_decline(
reacting_user, message_id, reference_id, channel
)
# ------------------------------------------------------------------
# One-on-One handlers
# ------------------------------------------------------------------
async def _send_one_on_one_dm(
self,
coach_name='',
coach_discord_id='',
player_name='',
team_name='',
date_str='',
start_time='',
end_time='',
points='',
request_id=None,
):
embed = {
'embeds': [
{
'title': 'One on One Request',
'color': 3447003,
'fields': [
{'name': 'Player', 'value': player_name, 'inline': True},
{'name': 'Team', 'value': team_name or 'Unknown', 'inline': True},
{'name': 'Date', 'value': date_str, 'inline': True},
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
{
'name': 'Quick Reply',
'value': f'React with {CHECK_EMOJI} to approve, {CROSS_EMOJI} to reject.',
'inline': False,
},
],
'footer': {'text': f'Request #{request_id}'},
}
]
}
sent = await self._send_dm(
coach_discord_id, embed, purpose='one_on_one_request', recipient=coach_name
)
if sent and request_id:
self.pending_requests[str(sent.id)] = {
'type': 'one_on_one',
'reference_id': request_id,
'created_at': time.time(),
}
save_pending(self.pending_requests)
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
from app.extensions import db
from app.models import OneOnOneRequest, User
req = db.session.get(OneOnOneRequest, request_id)
if not req:
await self._reply(channel, 'This request no longer exists.')
self._forget_pending(message_id)
return
if str(coach.id) != str(req.coach.discord_user_id):
await self._reply(channel, 'This request is not for you.')
return
if req.status != 'pending':
await self._reply(channel, 'This request has already been handled.')
self._forget_pending(message_id)
return
async def _approve():
req.status = 'approved'
req.responded_at = utc_now_naive()
await self._commit_or_report(channel, operation=_approve)
await self._reply(channel, f'One on One request from {req.player.full_name} approved!')
self._forget_pending(message_id)
player = db.session.get(User, req.player_id)
if player and player.discord_user_id:
await self._send_one_on_one_response_dm(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=getattr(coach, 'display_name', 'Coach'),
date_str=req.date.strftime('%A, %B %d, %Y'),
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
points=req.points or 'No specific points provided',
approved=True,
)
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
from app.extensions import db
from app.models import OneOnOneRequest, User
req = db.session.get(OneOnOneRequest, request_id)
if not req:
await self._reply(channel, 'This request no longer exists.')
self._forget_pending(message_id)
return
if str(coach.id) != str(req.coach.discord_user_id):
await self._reply(channel, 'This request is not for you.')
return
if req.status != 'pending':
await self._reply(channel, 'This request has already been handled.')
self._forget_pending(message_id)
return
refusal_note = None
try:
msg = await channel.fetch_message(message_id)
if msg and msg.reference and msg.reference.resolved:
reply_to = msg.reference.resolved
if reply_to.author.id == coach.id:
refusal_note = reply_to.content if isinstance(reply_to.content, str) else None
except (NotFound, HTTPException) as exc:
_logger.warning('Could not read rejection reply: %s', exc)
async def _reject():
req.status = 'rejected'
req.responded_at = utc_now_naive()
if refusal_note:
req.coach_rejection_message = refusal_note
await self._commit_or_report(channel, operation=_reject)
await self._reply(channel, f'One on One request from {req.player.full_name} rejected.')
self._forget_pending(message_id)
player = db.session.get(User, req.player_id)
if player and player.discord_user_id:
await self._send_one_on_one_response_dm(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=getattr(coach, 'display_name', 'Coach'),
date_str=req.date.strftime('%A, %B %d, %Y'),
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
points=req.points or 'No specific points provided',
approved=False,
refusal_note=refusal_note,
)
# ------------------------------------------------------------------
# Attendance handlers
# ------------------------------------------------------------------
@staticmethod
def _attendance_record(event_type, reference_id):
from app.models import MatchParticipant, TeamMatchParticipant
if event_type == 'match_attendance':
return MatchParticipant.query.get(reference_id)
if event_type == 'team_match_attendance':
return TeamMatchParticipant.query.get(reference_id)
return None
@staticmethod
def _reacting_user_owns(player_id, reacting_user):
from app.models import User
player = User.query.get(player_id)
if not player:
return False
return str(player.discord_user_id) == str(reacting_user.id)
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
entry = self.pending_requests.get(str(message_id))
if not entry:
return
event_type = entry.get('type', '')
record = self._attendance_record(event_type, reference_id)
if not record:
await self._reply(channel, 'This event no longer exists.')
self._forget_pending(message_id)
return
if not self._reacting_user_owns(record.player_id, player):
await self._reply(channel, 'You cannot confirm attendance for someone else.')
return
async def _confirm():
record.attendance_confirmed = True
record.is_confirmed = True
await self._commit_or_report(channel, operation=_confirm)
await self._reply(channel, 'Attendance confirmed! ✅')
self._forget_pending(message_id)
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
entry = self.pending_requests.get(str(message_id))
if not entry:
return
event_type = entry.get('type', '')
record = self._attendance_record(event_type, reference_id)
if not record:
await self._reply(channel, 'This event no longer exists.')
self._forget_pending(message_id)
return
if not self._reacting_user_owns(record.player_id, player):
await self._reply(channel, 'You cannot decline attendance for someone else.')
return
async def _decline():
record.attendance_confirmed = False
record.is_confirmed = False
await self._commit_or_report(channel, operation=_decline)
await self._reply(channel, 'Attendance declined. ❌')
self._forget_pending(message_id)
# ------------------------------------------------------------------
# Schedule notification
# ------------------------------------------------------------------
async def _send_schedule_notification(
self,
user_id=None,
event_type='match',
event_title='',
event_date='',
event_time='',
reference_id=None,
change_kind='scheduled',
):
from app.models import User
user = User.query.get(user_id)
if not user or not user.discord_user_id:
_logger.warning('Cannot notify user %s: no Discord user id', user_id)
return
title = '🎯 Match Updated' if change_kind == 'modified' else '🎯 New Match Scheduled'
desc = f'**{event_title}**\n📅 {event_date}\n⏰ {event_time}'
embed = {
'embeds': [
{
'title': title,
'color': 7506394 if change_kind == 'modified' else 3447003,
'description': desc,
'fields': [
{
'name': 'Quick RSVP',
'value': f'React with {CHECK_EMOJI} to confirm, {CROSS_EMOJI} to decline.',
'inline': False,
}
],
}
]
}
sent = await self._send_dm(
user.discord_user_id,
embed,
purpose=f'{change_kind}_{event_type}',
recipient=user.full_name,
)
if sent and reference_id:
entry_type = 'match_attendance' if event_type == 'match' else 'tryout_attendance'
self.pending_requests[str(sent.id)] = {
'type': entry_type,
'reference_id': reference_id,
'created_at': time.time(),
}
save_pending(self.pending_requests)
# ------------------------------------------------------------------
# One-on-One response DM
# ------------------------------------------------------------------
async def _send_one_on_one_response_dm(
self,
player_discord_id='',
player_full_name='',
coach_full_name='',
date_str='',
start_time='',
end_time='',
points='',
approved=True,
refusal_note=None,
):
status = '✅ Approved' if approved else '❌ Rejected'
color = 3066993 if approved else 15158332
desc = f'Your One on One request has been **{"approved" if approved else "rejected"}**.'
embed = {
'embeds': [
{
'title': f'One on One {status}',
'color': color,
'description': desc,
'fields': [
{'name': 'Coach', 'value': coach_full_name, 'inline': True},
{'name': 'Date', 'value': date_str, 'inline': True},
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
],
}
]
}
if refusal_note:
embed['embeds'][0]['fields'].append(
{'name': 'Reason', 'value': refusal_note, 'inline': False}
)
await self._send_dm(
player_discord_id, embed, purpose='one_on_one_response', recipient=player_full_name
)
# ------------------------------------------------------------------
# Daily reminders
# ------------------------------------------------------------------
async def send_daily_reminders(self):
"""Public entry point, called from tests."""
await self._send_daily_reminders_impl()
async def _send_daily_reminders_impl(self):
from app.extensions import db
from app.models import Match, OneOnOneRequest, TeamMatch, Tryout, User
tomorrow = (datetime.now(self.timezone) + timedelta(days=1)).date()
_logger.info('Running daily reminder check for %s', tomorrow)
for match in Match.query.filter(Match.date == tomorrow).all():
for participant in match.participants:
await self.send_match_reminder(participant.player, match)
for tm in TeamMatch.query.filter(TeamMatch.date == tomorrow).all():
for participant in tm.participants:
await self.send_match_reminder(participant.player, tm)
for tryout in Tryout.query.all():
if any(m.date == tomorrow for m in tryout.matches):
for reg in tryout.registrations:
await self.send_tryout_reminder(reg.player, tryout)
sessions = OneOnOneRequest.query.filter(
OneOnOneRequest.status == 'pending', OneOnOneRequest.date == tomorrow
).all()
for session in sessions:
player = db.session.get(User, session.player_id)
await self.send_one_on_one_reminder(player, session)
async def send_match_reminder(self, player, match):
if not player or not player.discord_user_id:
return
embed = {
'embeds': [
{
'title': '⏰ Match Tomorrow!',
'color': 15844367,
'description': f'**{match.title}**\n📅 {match.date.strftime("%A, %B %d, %Y")}\n⏰ {match.start_time.strftime("%I:%M %p") if match.start_time else "TBD"}',
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='match_reminder', recipient=player.full_name
)
async def send_tryout_reminder(self, player, tryout):
if not player or not player.discord_user_id:
return
embed = {
'embeds': [
{
'title': '⏰ Tryout Tomorrow!',
'color': 15844367,
'description': f'**{tryout.name}**\n📅 {tryout.date.strftime("%A, %B %d, %Y")}\n⏰ {tryout.time.strftime("%I:%M %p") if tryout.time else "TBD"}',
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='tryout_reminder', recipient=player.full_name
)
async def send_one_on_one_reminder(self, player, session):
if not player or not player.discord_user_id:
return
embed = {
'embeds': [
{
'title': '⏰ One on One Tomorrow!',
'color': 15844367,
'description': f'📅 {session.date.strftime("%A, %B %d, %Y")}\n⏰ {session.start_time.strftime("%I:%M %p") if session.start_time else "TBD"}',
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='one_on_one_reminder', recipient=player.full_name
)
# -----------------------------------------------------------------
# Legacy alias
# -----------------------------------------------------------------
async def notify_player_about_one_on_one_direct(
self, player, coach, coach_name, coach_discord_id, date_str, start_time, end_time, points
):
embed = {
'embeds': [
{
'title': 'One on One Scheduled',
'color': 3447003,
'fields': [
{'name': 'Coach', 'value': coach_name, 'inline': True},
{'name': 'Date', 'value': date_str, 'inline': True},
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
],
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='one_on_one_direct', recipient=player.full_name
)
# ======================================================================
# Module-level API
# ======================================================================
def get_bot(flask_app=None):
global bot_instance, bot_flask_app
if flask_app is not None:
bot_flask_app = flask_app
if bot_instance is None:
bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_flask_app)
elif flask_app is not None and bot_instance.flask_app is None:
bot_instance.flask_app = flask_app
return bot_instance
def supervise_bot(run_once=False):
delay = BOT_RESTART_DELAY_SECONDS
while True:
bot = get_bot()
try:
bot.run(DISCORD_BOT_TOKEN, reconnect=True)
except LoginFailure:
_logger.critical('Discord bot token is invalid. Bot will not start.')
return
except PrivilegedIntentsRequired as exc:
_logger.critical('Missing privileged intent: %s. Bot will not start.', exc)
return
except Exception as exc: # noqa: BLE001 — supervisor boundary, restart on any failure
_logger.error('Discord bot stopped unexpectedly: %s', exc)
if run_once:
_logger.info('Bot run_once completed.')
return
bot_age = time.time() - getattr(bot, '_started_at', time.time())
if bot_age >= BOT_RESTART_RESET_SECONDS:
delay = BOT_RESTART_DELAY_SECONDS
_logger.warning('Discord bot disconnected. Restarting in %s seconds.', delay)
if _stop_bot.wait(delay):
_logger.info('Discord bot supervisor asked to stop.')
return
delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS)
def start_bot(flask_app=None):
global bot_thread, bot_flask_app
if flask_app is not None:
bot_flask_app = flask_app
get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None:
_stop_bot.clear()
bot_thread = threading.Thread(target=supervise_bot, daemon=True)
bot_thread.start()
_logger.info('TeamTryoutsBot started in background thread')
elif not DISCORD_BOT_TOKEN:
_logger.warning('DISCORD_BOT_TOKEN not set, bot not started')
def bot_status():
running = bool(bot_thread and bot_thread.is_alive())
instance = bot_instance
connected = bool(instance and not instance.is_closed() and instance.is_ready())
return {
'configured': bool(DISCORD_BOT_TOKEN),
'running': running,
'connected': connected,
'pending': len(instance.pending_requests) if instance else 0,
'reminders_scheduled': bool(instance and instance.reminders_scheduled),
}
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
"""Constants used across the Discord notification subsystem."""
import os
# ---------------------------------------------------------------------------
# Token
# ---------------------------------------------------------------------------
#: Discord bot token. Loaded once at import time so the bot can decide whether
#: to start before the application factory runs.
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
# ---------------------------------------------------------------------------
# Emoji
# ---------------------------------------------------------------------------
CHECK_EMOJI = '✅' # Green checkmark
CROSS_EMOJI = '❌' # Red X
# ---------------------------------------------------------------------------
# Timing
# ---------------------------------------------------------------------------
#: Entries older than this are dropped when the pending file is loaded.
PENDING_MAX_AGE_DAYS = 30
#: How late the daily reminder may still be sent.
REMINDER_GRACE_SECONDS = 3600
#: First wait before restarting a bot that stopped (OPS-004).
BOT_RESTART_DELAY_SECONDS = 5
#: Ceiling on that wait.
BOT_RESTART_MAX_DELAY_SECONDS = 300
#: A connection that lasted this long resets the back-off.
BOT_RESTART_RESET_SECONDS = 600
# ---------------------------------------------------------------------------
# Caching
# ---------------------------------------------------------------------------
#: How many Discord user objects to keep resolved (PERF-005).
USER_CACHE_MAX = 512
# ---------------------------------------------------------------------------
# Timezone
# ---------------------------------------------------------------------------
TIMEZONE = 'America/Toronto'
+128
View File
@@ -0,0 +1,128 @@
"""Public API wrappers that enqueue notifications for the bot thread.
These are the functions that Flask routes and services import. They run in the
request thread and hand the actual Discord work to the bot's async loop via the
shared queue.
"""
import logging
from app.discord.queue import enqueue
_logger = logging.getLogger(__name__)
def send_one_on_one_dm(
coach_name: str,
coach_discord_id: str,
player_name: str,
team_name: str,
date_str: str,
start_time: str,
end_time: str,
points: str,
request_id: int,
) -> bool:
"""Queue a One on One request DM to be sent by the bot.
Args:
coach_name: Display name of the coach.
coach_discord_id: Discord snowflake of the coach.
player_name: Display name of the requesting player.
team_name: The player's team, or None/empty.
date_str, start_time, end_time: Already formatted for display.
points: Free-text discussion points.
request_id: OneOnOnRequest primary key for reaction routing.
Returns:
True if queued, False on failure.
"""
return enqueue(
'one_on_one_request',
{
'coach_name': coach_name,
'coach_discord_id': coach_discord_id,
'player_name': player_name,
'team_name': team_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'request_id': request_id,
},
description=f'the One on One request DM for request {request_id}',
)
def send_schedule_notification(
user_id: int,
event_type: str,
event_title: str,
event_date: str,
event_time: str,
reference_id: int,
change_kind: str = 'scheduled',
) -> bool:
"""Queue a schedule notification DM to be sent by the bot.
Args:
user_id: The player's database id (resolved to a snowflake in the bot).
event_type: 'match' or 'tryout'.
event_title: Display title of the event.
event_date: Formatted date string.
event_time: Formatted time range, or 'TBD'.
reference_id: The participant id (or fallback match/tryout id) for
reaction routing.
change_kind: 'scheduled' for new events, 'modified' for changes.
Returns:
True if queued, False on failure.
"""
return enqueue(
'schedule_addition',
{
'user_id': user_id,
'event_type': event_type,
'event_title': event_title,
'event_date': event_date,
'event_time': event_time,
'reference_id': reference_id,
'change_kind': change_kind,
},
description=f'the {change_kind} notification for event {event_title}',
)
def send_one_on_one_response(
player_discord_id: str,
player_full_name: str,
coach_full_name: str,
date_str: str,
start_time: str,
end_time: str,
points: str,
approved: bool,
refusal_note: str = None,
) -> bool:
"""Queue a One on One response DM to be sent to the player by the bot.
Called from Flask routes when a coach accepts/rejects via the web app.
Returns:
True if queued, False on failure.
"""
return enqueue(
'one_on_one_response',
{
'player_discord_id': player_discord_id,
'player_full_name': player_full_name,
'coach_full_name': coach_full_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'approved': approved,
'refusal_note': refusal_note,
},
description=f'the One on One response DM for {player_full_name}',
)
+102
View File
@@ -0,0 +1,102 @@
"""Persistence of pending reaction-request mappings across bot restarts.
The bot uses reaction messages (✅ / ❌) on DMs to let a coach approve or reject
a one-on-one request without visiting the site. The mapping from Discord
message_id → {request_type, primary_key} is stored in a JSON file so that a
bot restart does not orphan reactions that were already sent.
"""
import json
import logging
import os
from datetime import UTC, datetime
from app.discord.constants import PENDING_MAX_AGE_DAYS
from app.discord.queue import enqueue
_logger = logging.getLogger(__name__)
#: File for persisting pending requests across bot restarts.
PENDING_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'discord_pending.json',
)
def load_pending() -> dict:
"""Load pending requests from the JSON file.
Returns:
dict mapping message_id (int) → entry dict.
"""
if not os.path.exists(PENDING_FILE):
return {}
try:
with open(PENDING_FILE, encoding='utf-8') as f:
entries = json.load(f)
except (json.JSONDecodeError, OSError):
_logger.warning('Could not read pending file, starting empty.', exc_info=True)
return {}
if not isinstance(entries, dict):
return {}
cleaned = drop_expired(entries)
if len(cleaned) < len(entries):
save_pending(cleaned)
return cleaned
def drop_expired(entries: dict) -> dict:
"""Remove entries whose creation timestamp is too old.
Args:
entries: dict mapping message_id → entry.
Returns:
Filtered dict.
"""
now = datetime.now(UTC).timestamp()
cutoff = now - PENDING_MAX_AGE_DAYS * 86400
return {mid: e for mid, e in entries.items() if e.get('created_at', 0) >= cutoff}
def save_pending(entries: dict) -> None:
"""Persist pending entries to the JSON file."""
try:
with open(PENDING_FILE, 'w', encoding='utf-8') as f:
json.dump(entries, f, indent=2)
except OSError:
_logger.exception('Could not write pending file')
def forget_pending(entries: dict, message_id: int) -> dict:
"""Remove one message from the pending map and persist.
Args:
entries: The current pending map.
message_id: The message whose entry to remove.
Returns:
Updated dict (already persisted).
"""
entries.pop(str(message_id), None)
save_pending(entries)
return entries
def purge_pending_for_participants(participant_ids: list[int]) -> None:
"""Queue a purge of stale participant pending entries.
Called after a match edit deletes participants. The bot thread removes
matching ``schedule_addition`` pending entries so that a reaction on an
old message cannot produce a false confirmation.
Args:
participant_ids: MatchParticipant primary keys that no longer exist.
"""
if not participant_ids:
return
enqueue(
'purge_pending',
{'participant_ids': participant_ids},
description=f'purge of {len(participant_ids)} stale participant records',
)
+44
View File
@@ -0,0 +1,44 @@
"""Shared queue that bridges Flask request threads to the bot's async loop.
Module-level, not per-instance: the supervisor replaces the bot object on every
restart (discord.py cannot reuse a client whose run() has returned). A queue
living on the old object would be discarded with it, taking every pending
notification along.
"""
import logging
from queue import Empty, Queue # noqa: F401 — re-exported for bot.py
_logger = logging.getLogger(__name__)
#: The shared queue instance.
message_queue: Queue = Queue()
def enqueue(kind: str, data: dict, *, description: str) -> bool:
"""Hand one notification to the bot thread. Never raises.
The three public senders were the same fifteen lines three times, each with
its own blanket catch. There is one now, and it is the only place where
breadth is a property of the caller rather than of the failure: this runs
in a Flask request thread, and the work the notification announces is
already committed. The meeting is booked whether or not Discord hears about
it, so a failure here must not become a 500 on a page whose job is done.
What it is *not* is somewhere to lose a defect: the traceback is logged
and the caller is told False.
Args:
kind: Event type name ('one_on_one_request', 'schedule_addition', …).
data: Payload dict.
description: Human-readable label for the log message.
Returns:
True on success, False on failure.
"""
try:
message_queue.put({'type': kind, 'data': data})
except Exception: # a notification must never fail the request that caused it # noqa: BLE001
_logger.exception('Could not queue %s', description)
return False
return True
+25 -1581
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

+1 -1
View File
@@ -96,4 +96,4 @@ from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
+1 -1
View File
@@ -53,4 +53,4 @@ class AppSettings(db.Model):
@staticmethod @staticmethod
def set_bool(key, value): def set_bool(key, value):
"""Store a boolean setting as 'true' / 'false'.""" """Store a boolean setting as 'true' / 'false'."""
AppSettings.set(key, 'true' if value else 'false') AppSettings.set(key, 'true' if value else 'false')
+1 -1
View File
@@ -11,7 +11,7 @@ class BaseMatch(db.Model):
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=True) start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True) end_time = db.Column(db.Time, nullable=True)
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
+1 -1
View File
@@ -12,7 +12,7 @@ class OneOnOneRequest(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)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=False) start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True) points = db.Column(db.Text, nullable=True)
+7 -3
View File
@@ -13,21 +13,25 @@ class Tryout(db.Model):
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
game = db.Column(db.String(50), nullable=False) game = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False, index=True)
end_date = db.Column(db.Date, nullable=True) end_date = db.Column(db.Date, nullable=True)
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='upcoming') status = db.Column(db.String(20), default='upcoming')
max_players = db.Column(db.Integer, nullable=True) max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration manager_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
coach_id = db.Column( coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration ) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=utc_now_naive) created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated manager = db.relationship(
'User', foreign_keys=[manager_id], backref='managed_tryouts'
) # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts') coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts') coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m') managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
+84
View File
@@ -0,0 +1,84 @@
"""Helpers for mutating routes that should not force a full page reload.
The site is server-rendered, and every mutating route used to end with
``return redirect(url_for(...))``, so each change reloaded the whole page:
scroll reset, open dialogs closed, and chaining several edits meant waiting
for a full render between each one.
These helpers let a route answer an AJAX submit (the ``data-ajax`` forms in
main.js) with a small JSON payload instead of a redirect, while the plain
HTML form path keeps behaving exactly as before. The client then fetches the
page again and swaps only the content region, so the page never reloads.
The counterpart that performs the swap lives in app/static/js/main.js.
"""
from flask import get_flashed_messages, jsonify, redirect, request, session
from app.models import AppSettings
#: Key holding a coarse, monotonically increasing stamp for the whole dataset.
#: It advances after every committed mutation so a stale client can be told
#: (with a 409) that someone else has changed what it is looking at.
REVISION_KEY = 'data_revision'
def is_ajax_submit():
"""Whether this POST came from the fetch-based form handler in main.js."""
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def current_revision():
"""The current revision stamp, as an int (0 when nothing has been bumped)."""
try:
return int(AppSettings.get(REVISION_KEY, '0') or '0')
except (TypeError, ValueError):
return 0
def bump_revision():
"""Advance the revision stamp after a committed change.
Stored through AppSettings so it survives restarts and is shared by every
worker, unlike a module-level counter.
"""
AppSettings.set(REVISION_KEY, str(current_revision() + 1))
def _flashed_success():
"""Whether a success flash is pending, without consuming it.
The HTML path still redirects and must leave the flash for the next page
to render; peeking at the session is the only way to know without clearing
it. Only a successful commit advances the revision -- a rejected edit must
not make other users reload for nothing.
"""
flashes = session.get('_flashes', [])
return any(category == 'success' for category, _message in flashes)
def respond_after_change(redirect_url):
"""Answer a committed (or rejected) mutation.
For a normal HTML form submit this is exactly the ``redirect(...)`` the
routes used to return. For an AJAX submit it returns JSON telling the
client where to fetch the refreshed fragment and which flash messages to
show, so the page can update in place instead of reloading.
"""
if _flashed_success():
bump_revision()
if is_ajax_submit():
messages = [
{'category': category, 'text': message}
for category, message in get_flashed_messages(with_categories=True)
]
return jsonify(
{
'ok': True,
'redirect': redirect_url,
'messages': messages,
'revision': current_revision(),
}
)
return redirect(redirect_url)
+67 -38
View File
@@ -9,19 +9,35 @@ import os
from datetime import datetime from datetime import datetime
from flask import ( from flask import (
Blueprint, render_template, redirect, url_for, flash, request, Blueprint,
flash,
redirect,
render_template,
request,
send_file, send_file,
url_for,
) )
from flask_login import login_required, current_user from flask_login import current_user, login_required
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch, Admin,
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog, AppSettings,
AuditLog,
BackupRecord,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
Tryout,
User,
) )
from app.responders import respond_after_change
from app.supporting_scripts.backup import ( from app.supporting_scripts.backup import (
BACKUP_DIR, BackupError, backup_database, backup_documents, BackupError,
create_backup_dir, parse_database_url, verify_backup, backup_database,
create_backup_dir,
parse_database_url,
) )
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -56,6 +72,7 @@ def _log(action, details=None):
# Dashboard # Dashboard
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@admin_bp.route('') @admin_bp.route('')
@login_required @login_required
def dashboard(): def dashboard():
@@ -96,6 +113,7 @@ def dashboard():
# Backups # Backups
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _create_backup_record(backup_type='manual', notes=None): def _create_backup_record(backup_type='manual', notes=None):
"""Run a database backup and return a BackupRecord, or raise BackupError.""" """Run a database backup and return a BackupRecord, or raise BackupError."""
create_backup_dir() create_backup_dir()
@@ -130,11 +148,11 @@ def create_backup():
except BackupError as e: except BackupError as e:
flash(f'Backup failed: {e}', 'danger') flash(f'Backup failed: {e}', 'danger')
_log('backup_failed', f'Error: {e}') _log('backup_failed', f'Error: {e}')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)') _log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
flash(f'Backup created successfully: {record.filename}', 'success') flash(f'Backup created successfully: {record.filename}', 'success')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/backup/<int:backup_id>/download') @admin_bp.route('/backup/<int:backup_id>/download')
@@ -147,7 +165,7 @@ def download_backup(backup_id):
record = BackupRecord.query.get_or_404(backup_id) record = BackupRecord.query.get_or_404(backup_id)
if not os.path.exists(record.file_path): if not os.path.exists(record.file_path):
flash('Backup file is missing from disk.', 'danger') flash('Backup file is missing from disk.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
_log('backup_downloaded', f'File: {record.filename}') _log('backup_downloaded', f'File: {record.filename}')
return send_file(record.file_path, as_attachment=True, download_name=record.filename) return send_file(record.file_path, as_attachment=True, download_name=record.filename)
@@ -166,13 +184,13 @@ def delete_backup(backup_id):
os.remove(record.file_path) os.remove(record.file_path)
except OSError as e: except OSError as e:
flash(f'Could not remove backup file: {e}', 'danger') flash(f'Could not remove backup file: {e}', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
_log('backup_deleted', f'File: {record.filename}') _log('backup_deleted', f'File: {record.filename}')
db.session.delete(record) db.session.delete(record)
db.session.commit() db.session.commit()
flash(f'Backup {record.filename} deleted.', 'success') flash(f'Backup {record.filename} deleted.', 'success')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST']) @admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
@@ -189,32 +207,41 @@ def restore_backup(backup_id):
record = BackupRecord.query.get_or_404(backup_id) record = BackupRecord.query.get_or_404(backup_id)
if not os.path.exists(record.file_path): if not os.path.exists(record.file_path):
flash('Backup file is missing from disk.', 'danger') flash('Backup file is missing from disk.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
# 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,
) )
except BackupError as e: except BackupError as e:
flash(f'Could not create safety backup, restore aborted: {e}', 'danger') flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
# Restore using pg_restore # Restore using pg_restore
import subprocess import subprocess
from app.supporting_scripts.backup import ( from app.supporting_scripts.backup import (
PG_RESTORE, dump_environment, parse_database_url, PG_RESTORE,
dump_environment,
parse_database_url,
) )
conn = parse_database_url(os.getenv('DATABASE_URL')) conn = parse_database_url(os.getenv('DATABASE_URL'))
cmd = [ cmd = [
PG_RESTORE, PG_RESTORE,
'--host', conn['host'], '--host',
'--port', conn['port'], conn['host'],
'--username', conn['user'], '--port',
'--dbname', conn['dbname'], conn['port'],
'--clean', '--if-exists', '--no-owner', '--username',
conn['user'],
'--dbname',
conn['dbname'],
'--clean',
'--if-exists',
'--no-owner',
record.file_path, record.file_path,
] ]
try: try:
@@ -230,20 +257,21 @@ def restore_backup(backup_id):
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e: except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
flash(f'Restore failed: {e}', 'danger') flash(f'Restore failed: {e}', 'danger')
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}') _log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
_log('backup_restored', f'File: {record.filename}') _log('backup_restored', f'File: {record.filename}')
flash( flash(
f'Backup {record.filename} restored successfully. The database has been rolled back.', f'Backup {record.filename} restored successfully. The database has been rolled back.',
'success', 'success',
) )
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tryouts open/close toggle # Tryouts open/close toggle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@admin_bp.route('/toggle-tryouts', methods=['POST']) @admin_bp.route('/toggle-tryouts', methods=['POST'])
@login_required @login_required
def toggle_tryouts(): def toggle_tryouts():
@@ -258,13 +286,14 @@ def toggle_tryouts():
state = 'opened' if new_value else 'closed' state = 'opened' if new_value else 'closed'
_log('tryouts_toggled', f'Tryouts {state}') _log('tryouts_toggled', f'Tryouts {state}')
flash(f'Tryouts are now {state}.', 'success') flash(f'Tryouts are now {state}.', 'success')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Season lifecycle # Season lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@admin_bp.route('/season/start', methods=['POST']) @admin_bp.route('/season/start', methods=['POST'])
@login_required @login_required
def start_season(): def start_season():
@@ -274,19 +303,19 @@ def start_season():
if AppSettings.get_bool('season_active', default=False): if AppSettings.get_bool('season_active', default=False):
flash('A season is already active. End it before starting a new one.', 'danger') flash('A season is already active. End it before starting a new one.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
name = request.form.get('season_name', '').strip() name = request.form.get('season_name', '').strip()
start_date = request.form.get('season_start', '').strip() start_date = request.form.get('season_start', '').strip()
if not start_date: if not start_date:
flash('A season start date is required.', 'danger') flash('A season start date is required.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
try: try:
datetime.strptime(start_date, '%Y-%m-%d') datetime.strptime(start_date, '%Y-%m-%d')
except ValueError: except ValueError:
flash('Invalid season start date format.', 'danger') flash('Invalid season start date format.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
AppSettings.set('season_name', name or 'Untitled Season') AppSettings.set('season_name', name or 'Untitled Season')
AppSettings.set('season_start', start_date) AppSettings.set('season_start', start_date)
@@ -295,7 +324,7 @@ def start_season():
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}') _log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success') flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/season/end', methods=['POST']) @admin_bp.route('/season/end', methods=['POST'])
@@ -307,18 +336,18 @@ def end_season():
if not AppSettings.get_bool('season_active', default=False): if not AppSettings.get_bool('season_active', default=False):
flash('No season is currently active.', 'danger') flash('No season is currently active.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
end_date = request.form.get('season_end', '').strip() end_date = request.form.get('season_end', '').strip()
if not end_date: if not end_date:
flash('A season end date is required.', 'danger') flash('A season end date is required.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
try: try:
datetime.strptime(end_date, '%Y-%m-%d') datetime.strptime(end_date, '%Y-%m-%d')
except ValueError: except ValueError:
flash('Invalid season end date format.', 'danger') flash('Invalid season end date format.', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
name = AppSettings.get('season_name') name = AppSettings.get('season_name')
AppSettings.set('season_end', end_date) AppSettings.set('season_end', end_date)
@@ -326,7 +355,9 @@ def end_season():
_log('season_ended', f'Season: {name}, End: {end_date}') _log('season_ended', f'Season: {name}, End: {end_date}')
flash(f'Season "{name}" has ended.', 'success') flash(f'Season "{name}" has ended.', 'success')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/toggle-document-uploads', methods=['POST']) @admin_bp.route('/toggle-document-uploads', methods=['POST'])
@login_required @login_required
def toggle_document_uploads(): def toggle_document_uploads():
@@ -341,16 +372,14 @@ def toggle_document_uploads():
state = 'enabled' if new_value else 'disabled' state = 'enabled' if new_value else 'disabled'
_log('document_uploads_toggled', f'Document uploads {state}') _log('document_uploads_toggled', f'Document uploads {state}')
flash(f'Document uploads are now {state}.', 'success') flash(f'Document uploads are now {state}.', 'success')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Wipe teams for new season # Wipe teams for new season
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@admin_bp.route('/teams/wipe', methods=['POST']) @admin_bp.route('/teams/wipe', methods=['POST'])
@login_required @login_required
def wipe_teams(): def wipe_teams():
@@ -366,7 +395,7 @@ def wipe_teams():
confirm = request.form.get('confirm', '').strip() confirm = request.form.get('confirm', '').strip()
if confirm != 'WIPE': if confirm != 'WIPE':
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger') flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
# Safety backup before destructive operation # Safety backup before destructive operation
try: try:
@@ -376,7 +405,7 @@ def wipe_teams():
) )
except BackupError as e: except BackupError as e:
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger') flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
roster_count = TeamPlayer.query.count() roster_count = TeamPlayer.query.count()
match_count = TeamMatch.query.count() match_count = TeamMatch.query.count()
@@ -393,4 +422,4 @@ def wipe_teams():
f'and {match_count} regular-season matches.', f'and {match_count} regular-season matches.',
'success', 'success',
) )
return redirect(url_for('admin.dashboard')) return respond_after_change(url_for('admin.dashboard'))
+66 -26
View File
@@ -7,20 +7,13 @@ 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 flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
)
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from app.extensions import db from app.extensions import db
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 (
EVALUATION_CRITERIA,
GAME_POSITIONS, GAME_POSITIONS,
Admin, Admin,
Evaluation, Evaluation,
@@ -30,11 +23,46 @@ from app.models import (
User, User,
) )
from app.pagination import paginate from app.pagination import paginate
from app.responders import respond_after_change
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}
@@ -157,12 +185,12 @@ def evaluate_player(tryout_id, player_id):
) )
if not is_registered: if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger') flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = db.get_or_404(User, player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger') flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by( existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id, tryout_id=tryout_id,
@@ -216,7 +244,7 @@ def evaluate_player(tryout_id, player_id):
evaluation.position_recommendation = data['position_recommendation'] evaluation.position_recommendation = data['position_recommendation']
db.session.commit() db.session.commit()
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_evaluation_form() return render_evaluation_form()
@@ -291,23 +319,31 @@ def batch_evaluate(tryout_id):
player = User.query.get(pid) player = User.query.get(pid)
if not player or not isinstance(player, Player): if not player or not isinstance(player, Player):
continue continue
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=pid, TryoutRegistration.query.filter_by(
).first() is not None tryout_id=tryout_id,
player_id=pid,
).first()
is not None
)
if not is_registered: if not is_registered:
continue continue
existing = Evaluation.query.filter_by( existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id, tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.id,
).first() ).first()
existing_scores = { existing_scores = {
field_name: getattr(existing, field_name) if existing else None field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA for field_name, _ in EVALUATION_CRITERIA
} }
players.append({ players.append(
'player': player, {
'existing': existing, 'player': player,
'existing_scores': existing_scores, 'existing': existing,
}) 'existing_scores': existing_scores,
}
)
if not players: if not players:
flash('No valid players selected for evaluation.', 'danger') flash('No valid players selected for evaluation.', 'danger')
@@ -329,7 +365,8 @@ def batch_evaluate(tryout_id):
_apply_evaluation(existing, scores, comments, position) _apply_evaluation(existing, scores, comments, position)
else: else:
evaluation = Evaluation( evaluation = Evaluation(
tryout_id=tryout_id, player_id=pid, tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.id, evaluator_id=current_user.id,
) )
_apply_evaluation(evaluation, scores, comments, position) _apply_evaluation(evaluation, scores, comments, position)
@@ -338,9 +375,12 @@ def batch_evaluate(tryout_id):
db.session.commit() db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success') flash(f'Saved evaluations for {saved} player(s).', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/batch_evaluate.html', return render_template(
tryout=tryout, players=players, 'pages/batch_evaluate.html',
evaluation_criteria=EVALUATION_CRITERIA, tryout=tryout,
game_positions=GAME_POSITIONS) players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS,
)
+232 -96
View File
@@ -5,7 +5,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons.
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, flash, jsonify, 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
@@ -26,11 +26,14 @@ from app.models import (
PlayerDisponibility, PlayerDisponibility,
Scout, Scout,
Team, Team,
TeamMatch,
TeamMember, TeamMember,
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
) )
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema from app.validators import MatchEditSchema, MatchSchema
@@ -74,6 +77,25 @@ def default_end_time(date, start_time):
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time() return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
def calendar_event_time_fields(date, start_time, end_time):
"""FullCalendar date/time fields for a match.
A date-only event renders in the all-day row; a ``start``/``end`` with a
time places it on the clock for the same calendar day. Matches, team
matches and one-on-one sessions are stored as a date plus optional time
columns, so the feed translates that into the shape FullCalendar expects.
"""
if not start_time:
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
fields = {
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
'allDay': False,
}
if end_time:
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
return fields
def create_participants(match, data): def create_participants(match, data):
"""Attach participants to a match, per its type. """Attach participants to a match, per its type.
@@ -165,13 +187,17 @@ def api_events():
president — and then issue one MatchParticipant query per match to find president — and then issue one MatchParticipant query per match to find
out whether the viewer was in it. The calendar's cost grew with the out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation. whole history, on every navigation.
Tryout matches, regular-season team matches and approved one-on-one
sessions share this feed. Timed rows carry `start`/`end` so
FullCalendar places them on the clock instead of the all-day row.
""" """
events = [] events = []
tryouts = get_visible_tryouts_for_user() tryouts = get_visible_tryouts_for_user()
window_start, window_end = calendar_window(request.args)
tryouts_by_id = {tryout.id: tryout for tryout in tryouts} tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
if tryouts_by_id: if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id)) query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start: if window_start:
query = query.filter(Match.date >= window_start) query = query.filter(Match.date >= window_start)
@@ -225,30 +251,71 @@ def api_events():
user_participant = mine_by_match.get(match.id) user_participant = mine_by_match.get(match.id)
events.append( event = {
{ 'id': f'match_{match.id}',
'id': f'match_{match.id}', 'title': match.title,
'title': match.title, 'type': 'match',
'date': match.date.strftime('%Y-%m-%d'), 'color': match_color,
'type': 'match', 'extendedProps': {
'color': match_color, 'location': match.location or tryout.location or 'TBD',
'extendedProps': { 'status': match.status,
'location': match.location or tryout.location or 'TBD', 'description': match.description or '',
'status': match.status, 'match_type': match.match_type,
'description': match.description or '', 'tryout_id': tryout.id,
'match_type': match.match_type, 'match_id': match.id,
'tryout_id': tryout.id, 'start_time': start_time_str,
'match_id': match.id, 'end_time': end_time_str,
'start_time': start_time_str, 'participants': participants_str,
'end_time': end_time_str, 'user_participant_id': user_participant.id if user_participant else None,
'participants': participants_str, 'user_attendance_confirmed': user_participant.attendance_confirmed
'user_participant_id': user_participant.id if user_participant else None, if user_participant
'user_attendance_confirmed': user_participant.attendance_confirmed else False,
if user_participant },
else False, }
}, event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
} events.append(event)
)
# Regular-season team matches: a separate table from tryout matches, but
# the same calendar. Visible to the roles that can see the org team.
if isinstance(current_user, (Admin, Manager)):
team_query = TeamMatch.query
elif isinstance(current_user, (Coach, Player)):
team_ids = [team.id for team in visible_org_teams(current_user)]
team_query = (
TeamMatch.query.filter(TeamMatch.org_team_id.in_(team_ids))
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
team_query = TeamMatch.query.filter(TeamMatch.id == -1)
if window_start:
team_query = team_query.filter(TeamMatch.date >= window_start)
if window_end:
team_query = team_query.filter(TeamMatch.date <= window_end)
for team_match in team_query.options(joinedload(TeamMatch.org_team)).all():
start_time_str = team_match.start_time.strftime('%H:%M') if team_match.start_time else None
end_time_str = team_match.end_time.strftime('%H:%M') if team_match.end_time else None
event = {
'id': f'team_match_{team_match.id}',
'title': team_match.title,
'type': 'team_match',
'color': '#3b82f6',
'extendedProps': {
'location': team_match.location or 'TBD',
'status': team_match.status,
'description': team_match.description or '',
'org_team': team_match.org_team.name if team_match.org_team else '',
'opponent': team_match.opponent or '',
'start_time': start_time_str,
'end_time': end_time_str,
},
}
event.update(
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
)
events.append(event)
# Add approved One on One sessions for the current user (player or coach) # Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player): if isinstance(current_user, Player):
@@ -263,23 +330,22 @@ def api_events():
one_on_ones = [] one_on_ones = []
for ooo in one_on_ones: for ooo in one_on_ones:
events.append( event = {
{ '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',
'date': ooo.date.strftime('%Y-%m-%d'), 'color': '#8b5cf6',
'type': 'one_on_one', 'extendedProps': {
'color': '#8b5cf6', 'location': 'Discord / Voice Chat',
'extendedProps': { 'status': 'approved',
'location': 'Discord / Voice Chat', 'description': ooo.points or 'One on One session',
'status': 'approved', 'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'description': ooo.points or 'One on One session', 'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None, 'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None, },
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}", }
}, event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
} events.append(event)
)
return jsonify(events) return jsonify(events)
@@ -351,25 +417,24 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append( event = {
{ 'id': f'match_{match.id}',
'id': f'match_{match.id}', 'title': match.title,
'title': match.title, 'type': 'match',
'date': match.date.strftime('%Y-%m-%d'), 'color': match_color,
'type': 'match', 'extendedProps': {
'color': match_color, 'location': match.location or tryout.location or 'TBD',
'extendedProps': { 'status': match.status,
'location': match.location or tryout.location or 'TBD', 'match_type': match.match_type,
'status': match.status, 'tryout_id': tryout.id,
'match_type': match.match_type, 'match_id': match.id,
'tryout_id': tryout.id, 'participants': participants_str,
'match_id': match.id, 'start_time': start_time_str,
'participants': participants_str, 'end_time': end_time_str,
'start_time': start_time_str, },
'end_time': end_time_str, }
}, event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
} events.append(event)
)
return jsonify(events) return jsonify(events)
@@ -381,11 +446,11 @@ def create_match(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger') flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended: if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger') flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
all_players = registered_players(tryout_id) all_players = registered_players(tryout_id)
@@ -443,7 +508,7 @@ def create_match(tryout_id):
) )
flash(_('Match scheduled successfully!'), 'success') flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return rerender() return rerender()
@@ -457,11 +522,11 @@ def edit_match(match_id):
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this match.'), 'danger') flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('matches.calendar')) return respond_after_change(url_for('matches.calendar'))
if tryout.is_ended: if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger') flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all() teams = Team.query.filter_by(tryout_id=tryout.id).all()
all_players = registered_players(tryout.id) all_players = registered_players(tryout.id)
@@ -505,6 +570,12 @@ def edit_match(match_id):
flash_validation_errors(err) flash_validation_errors(err)
return rerender() return rerender()
# Snapshot the date/time before we overwrite them, so we can detect
# whether only these fields changed and choose the right Discord message.
old_date = match.date
old_start = match.start_time
old_end = match.end_time
# Assigned only once the whole form has been accepted. Assigning as # Assigned only once the whole form has been accepted. Assigning as
# each field was read meant a form rejected halfway had already # each field was read meant a form rejected halfway had already
# changed the record in the session. # changed the record in the session.
@@ -516,41 +587,106 @@ def edit_match(match_id):
match.location = data['location'] match.location = data['location']
match.status = data['status'] match.status = data['status']
time_changed = (
match.date != old_date or match.start_time != old_start or match.end_time != old_end
)
# Build the desired participant set and reconcile it against the
# existing participants, rather than deleting and recreating everyone.
# This preserves attendance_confirmed for players who stay.
desired = set()
if match.match_type == 'team_vs_team':
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
for team_id in (data['team1_id'], data['team2_id']):
if team_id:
for member in TeamMember.query.filter_by(team_id=team_id).all():
desired.add(member.player_id)
elif match.match_type == 'player_vs_player':
for pid in data['team1_player_ids']:
desired.add(pid)
for pid in data['team2_player_ids']:
desired.add(pid)
else:
for pid in data['player_ids']:
desired.add(pid)
existing = {
p.player_id: p for p in MatchParticipant.query.filter_by(match_id=match.id).all()
}
removed_ids = []
notified_player_ids = [] notified_player_ids = []
notified_participant_ids = [] notified_participant_ids = []
kept_ids = set()
if match.match_type == 'team_vs_team': for player_id, participant in list(existing.items()):
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id if player_id in desired:
if teams_changed: kept_ids.add(player_id)
MatchParticipant.query.filter_by(match_id=match.id).delete() notified_player_ids.append(player_id)
match.team1_id = data['team1_id'] notified_participant_ids.append(participant.id)
match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data)
else: else:
# Same teams: the roster stands, but everyone is told again, removed_ids.append(participant.id)
# because the date or the time may have moved. db.session.delete(participant)
for team_id in (match.team1_id, match.team2_id):
if team_id: # New players get new participant rows.
notified_player_ids.extend( for player_id in desired - set(existing.keys()):
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all() participant = MatchParticipant(match_id=match.id, player_id=player_id)
) db.session.add(participant)
else: db.session.flush()
MatchParticipant.query.filter_by(match_id=match.id).delete() notified_player_ids.append(player_id)
notified_player_ids, notified_participant_ids = create_participants(match, data) notified_participant_ids.append(participant.id)
kept_ids.add(player_id)
# When the date or time changed, reset attendance on everyone who
# stayed. They'll be asked to re-confirm via the modified message.
if time_changed:
for participant in MatchParticipant.query.filter_by(match_id=match.id).all():
if participant.player_id in kept_ids:
participant.attendance_confirmed = False
db.session.commit() db.session.commit()
notify_participants( # Purge Discord pending entries for participants that no longer exist.
title=match.title, if removed_ids:
date=match.date, from app.discord import purge_pending_for_participants
start_time=match.start_time,
end_time=match.end_time, purge_pending_for_participants(removed_ids)
participants=zip_participants(notified_player_ids, notified_participant_ids),
fallback_id=match.id, # Decide whether and what to notify.
) if time_changed:
# All current participants get the "modified" message.
all_current = list(zip(notified_player_ids, notified_participant_ids, strict=True))
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=all_current,
fallback_id=match.id,
change_kind='modified',
)
elif desired - set(existing.keys()):
# Only the newly added players are told about the match.
new_player_ids = desired - set(existing.keys())
new_pairs = [
(pid, nid)
for pid, nid in zip(notified_player_ids, notified_participant_ids, strict=True)
if pid in new_player_ids
]
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=new_pairs,
fallback_id=match.id,
change_kind='scheduled',
)
# else: nothing changed that needs a notification.
flash(_('Match updated successfully!'), 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return rerender() return rerender()
@@ -586,10 +722,10 @@ def delete_match(match_id):
tryout = match.tryout tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('matches.calendar')) return respond_after_change(url_for('matches.calendar'))
if tryout.is_ended: if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger') flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Notes outlive the match they were taken during: a coach's observation # Notes outlive the match they were taken during: a coach's observation
# keeps its value, and deleting it here would destroy unrelated content. # keeps its value, and deleting it here would destroy unrelated content.
@@ -602,7 +738,7 @@ def delete_match(match_id):
db.session.delete(match) db.session.delete(match)
db.session.commit() db.session.commit()
flash(_('Match deleted successfully.'), 'success') flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
def get_players_available_at_time(date_str, time_str): def get_players_available_at_time(date_str, time_str):
+17 -12
View File
@@ -3,7 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, flash, jsonify, 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
@@ -13,6 +13,7 @@ from app.extensions import db
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 (
Admin, Admin,
AppSettings,
Coach, Coach,
Manager, Manager,
OrgTeam, OrgTeam,
@@ -20,10 +21,10 @@ from app.models import (
TeamMatch, TeamMatch,
TeamMatchParticipant, TeamMatchParticipant,
TeamPlayer, TeamPlayer,
AppSettings,
) )
from app.pagination import paginate from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.responders import respond_after_change
from app.routes.matches import default_end_time from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive from app.time_utils import utc_now_naive
@@ -121,12 +122,14 @@ def create_match(team_id):
"""Create a new regular-season team match.""" """Create a new regular-season team match."""
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if season_locked(): if season_locked():
flash('The regular season is not active. Begin a season before scheduling matches.', 'warning') flash(
return redirect(url_for('team_matches.list_matches')) 'The regular season is not active. Begin a season before scheduling matches.', 'warning'
)
return respond_after_change(url_for('team_matches.list_matches'))
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger') flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all() team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
@@ -219,7 +222,7 @@ def create_match(team_id):
flash( flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success' _('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
) )
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players) return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -233,7 +236,7 @@ def edit_match(match_id):
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash(_('You do not have permission to edit this match.'), 'danger') flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
if request.method == 'POST': if request.method == 'POST':
# The three date and time fields used to be checked one at a time, # The three date and time fields used to be checked one at a time,
@@ -263,7 +266,7 @@ def edit_match(match_id):
db.session.commit() db.session.commit()
flash(_('Match updated successfully!'), 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
return render_template( return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[] 'pages/team_match_form.html', match=team_match, team=team, team_players=[]
@@ -277,17 +280,19 @@ def delete_match(match_id):
team_match = db.get_or_404(TeamMatch, match_id) team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team team = team_match.org_team
if season_locked(): if season_locked():
flash('The regular season is not active. Begin a season before deleting matches.', 'warning') flash(
return redirect(url_for('team_matches.list_matches')) 'The regular season is not active. Begin a season before deleting matches.', 'warning'
)
return respond_after_change(url_for('team_matches.list_matches'))
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
db.session.delete(team_match) db.session.delete(team_match)
db.session.commit() db.session.commit()
flash(_('Match deleted successfully.'), 'success') flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams') @team_matches_bp.route('/api/manageable-teams')
+47 -46
View File
@@ -27,6 +27,7 @@ from app.models import (
User, User,
) )
from app.permissions import visible_org_teams from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.time_utils import utc_now_naive from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
@@ -76,7 +77,7 @@ def my_teams():
"""View the player's own teams with upcoming matches.""" """View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player): if not isinstance(current_user, Player):
flash(_('This page is for players.'), 'info') flash(_('This page is for players.'), 'info')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant from app.models import TeamMatch, TeamMatchParticipant
@@ -183,18 +184,18 @@ def create_team():
"""Create a new organization team.""" """Create a new organization team."""
if not current_user.can_manage_teams(): if not current_user.can_manage_teams():
flash(_('You do not have permission to create teams.'), 'danger') flash(_('You do not have permission to create teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
try: try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids'))) data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
name = data['name'] name = data['name']
if OrgTeam.query.filter_by(name=name).first(): if OrgTeam.query.filter_by(name=name).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger') flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
coach = _staff_member(data['coach_id'], Coach) coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager) manager = _staff_member(data['manager_id'], Manager)
@@ -215,7 +216,7 @@ def create_team():
db.session.commit() db.session.commit()
flash(_('Team "%(name)s" created successfully!', name=name), 'success') flash(_('Team "%(name)s" created successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/edit', methods=['POST']) @teams_bp.route('/<int:team_id>/edit', methods=['POST'])
@@ -225,18 +226,18 @@ def edit_team(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger') flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
try: try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids'))) data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
name = data['name'] name = data['name']
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first(): if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger') flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
team.name = name team.name = name
@@ -269,7 +270,7 @@ def edit_team(team_id):
db.session.commit() db.session.commit()
flash(_('Team "%(name)s" updated successfully!', name=name), 'success') flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST']) @teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@@ -297,7 +298,7 @@ def delete_team(team_id):
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)): if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger') flash(_('You do not have permission to delete teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
name = team.name name = team.name
@@ -328,7 +329,7 @@ def delete_team(team_id):
db.session.delete(team) db.session.delete(team)
db.session.commit() db.session.commit()
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success') flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST']) @teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
@@ -338,19 +339,19 @@ def add_coach(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if not data['coach_id']: if not data['coach_id']:
flash(_('Please select a coach.'), 'danger') flash(_('Please select a coach.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
coach = db.session.get(User, data['coach_id']) coach = db.session.get(User, data['coach_id'])
if not coach or not _assignable(coach, Coach): if not coach or not _assignable(coach, Coach):
flash(_('Only coaches can be assigned as coach.'), 'danger') flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first(): if team.coaches.filter_by(id=coach.id).first():
flash( flash(
@@ -361,7 +362,7 @@ def add_coach(team_id):
), ),
'info', 'info',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
team.coaches.append(coach) team.coaches.append(coach)
if not team.coach_id: if not team.coach_id:
@@ -371,7 +372,7 @@ def add_coach(team_id):
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), _('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
'success', 'success',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST']) @teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
@@ -381,19 +382,19 @@ def add_manager(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if not data['manager_id']: if not data['manager_id']:
flash(_('Please select a manager.'), 'danger') flash(_('Please select a manager.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
manager = db.session.get(User, data['manager_id']) manager = db.session.get(User, data['manager_id'])
if not manager or not _assignable(manager, Manager): if not manager or not _assignable(manager, Manager):
flash(_('Only managers can be assigned as manager.'), 'danger') flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first(): if team.managers.filter_by(id=manager.id).first():
flash( flash(
@@ -404,7 +405,7 @@ def add_manager(team_id):
), ),
'info', 'info',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
team.managers.append(manager) team.managers.append(manager)
if not team.manager_id: if not team.manager_id:
@@ -414,7 +415,7 @@ def add_manager(team_id):
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), _('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
'success', 'success',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST']) @teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
@@ -424,11 +425,11 @@ def remove_coach(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if data['coach_id']: if data['coach_id']:
coach = db.session.get(User, data['coach_id']) coach = db.session.get(User, data['coach_id'])
@@ -442,7 +443,7 @@ def remove_coach(team_id):
db.session.commit() db.session.commit()
flash(_('Coach removed from %(name)s.', name=team.name), 'success') flash(_('Coach removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST']) @teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
@@ -452,11 +453,11 @@ def remove_manager(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if data['manager_id']: if data['manager_id']:
manager = db.session.get(User, data['manager_id']) manager = db.session.get(User, data['manager_id'])
@@ -470,7 +471,7 @@ def remove_manager(team_id):
db.session.commit() db.session.commit()
flash(_('Manager removed from %(name)s.', name=team.name), 'success') flash(_('Manager removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_player', methods=['POST']) @teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
@@ -480,20 +481,20 @@ def add_player(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamPlayerSchema()) data = _posted(TeamPlayerSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if not data['player_id']: if not data['player_id']:
flash(_('Please select a player.'), 'danger') flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
status = data['status'] status = data['status']
player = db.session.get(User, data['player_id']) player = db.session.get(User, data['player_id'])
if not player or not _assignable(player, Player): if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger') flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first() existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing: if existing:
@@ -501,13 +502,13 @@ def add_player(team_id):
_('%(username)s is already on %(name)s.', username=player.username, name=team.name), _('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info', 'info',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status) tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
db.session.add(tp) db.session.add(tp)
db.session.commit() db.session.commit()
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success') flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
@@ -517,7 +518,7 @@ def remove_player(team_id, player_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
player = db.get_or_404(User, player_id) player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
@@ -526,7 +527,7 @@ def remove_player(team_id, player_id):
_('%(username)s is not on %(name)s.', username=player.username, name=team.name), _('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger', 'danger',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
db.session.delete(tp) db.session.delete(tp)
db.session.commit() db.session.commit()
@@ -534,7 +535,7 @@ def remove_player(team_id, player_id):
_('%(username)s removed from %(name)s.', username=player.username, name=team.name), _('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success', 'success',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@@ -569,19 +570,19 @@ def add_team_note(team_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger') flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
try: try:
data = NoteContentSchema().load(form_payload(list_fields=())) data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content']) note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Team notes added successfully!'), 'success') flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
@@ -591,12 +592,12 @@ def add_player_note(team_id, player_id):
team = db.get_or_404(OrgTeam, team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger') flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
player = db.get_or_404(User, player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
@@ -604,16 +605,16 @@ def add_player_note(team_id, player_id):
_('%(username)s is not on %(name)s.', username=player.username, name=team.name), _('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger', 'danger',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
try: try:
data = NoteContentSchema().load(form_payload(list_fields=())) data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content']) note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success') flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
+224 -46
View File
@@ -16,6 +16,7 @@ from app.models import (
ESPORT_GAMES, ESPORT_GAMES,
GAME_POSITIONS, GAME_POSITIONS,
Admin, Admin,
AppSettings,
Coach, Coach,
Evaluation, Evaluation,
Manager, Manager,
@@ -30,14 +31,15 @@ from app.models import (
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
AppSettings,
) )
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 ( from app.validators import (
PlayerSelectionSchema, PlayerSelectionSchema,
TryoutRegistrationStatusSchema, TryoutRegistrationStatusSchema,
TryoutSchema, TryoutSchema,
TryoutStatusSchema, TryoutStatusSchema,
TryoutTeamMemberPositionSchema,
TryoutTeamMemberSchema, TryoutTeamMemberSchema,
TryoutTeamSchema, TryoutTeamSchema,
) )
@@ -140,7 +142,10 @@ def list_tryouts():
def create_tryout(): def create_tryout():
"""Create a new tryout event. Requires Admin or Manager.""" """Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if not can_manage(): if not can_manage():
@@ -204,7 +209,10 @@ def edit_tryout(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
@@ -273,9 +281,7 @@ def view_tryout(tryout_id):
can_view = False can_view = False
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
can_view = True can_view = True
elif isinstance(current_user, Manager): elif isinstance(current_user, (Manager, Coach)):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
is_registered = ( is_registered = (
@@ -341,7 +347,12 @@ def view_tryout(tryout_id):
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id]) member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows: for row in member_rows:
members_by_team.setdefault(row.team_id, []).append( members_by_team.setdefault(row.team_id, []).append(
{'player': member_players.get(row.player_id), 'position': row.position} {
'player': member_players.get(row.player_id),
'position': row.position,
'member_id': row.id,
'player_id': row.player_id,
}
) )
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams] team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
@@ -467,31 +478,31 @@ def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register.""" """Register a player for a tryout. Only Players can self-register."""
if not isinstance(current_user, Player): if not isinstance(current_user, Player):
flash(_('Only players can register for tryouts.'), 'danger') flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id) tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']: if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger') flash(_('This tryout is not accepting registrations.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by( existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id tryout_id=tryout_id, player_id=current_user.id
).first() ).first()
if existing: if existing:
flash(_('You are already registered for this tryout.'), 'info') flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players: if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger') flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id) registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration) db.session.add(registration)
db.session.commit() db.session.commit()
flash(_('Successfully registered for tryout!'), 'success') flash(_('Successfully registered for tryout!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@@ -500,8 +511,11 @@ def update_status(tryout_id):
"""Update the status of a tryout.""" """Update the status of a tryout."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
@@ -510,12 +524,12 @@ def update_status(tryout_id):
data = TryoutStatusSchema().load(form_payload(list_fields=())) data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout.status = data['status'] tryout.status = data['status']
db.session.commit() db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success') flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@@ -524,8 +538,11 @@ def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status.""" """Update a registration's attendance status."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
@@ -538,12 +555,12 @@ def update_registration_status(tryout_id, player_id):
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=())) data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration.status = data['status'] registration.status = data['status']
db.session.commit() db.session.commit()
flash(_('Registration status updated.'), 'success') flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@@ -552,21 +569,24 @@ def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches).""" """Manually register a player for a tryout (by managers/coaches)."""
tryout = locked_tryout_or_404(tryout_id) tryout = locked_tryout_or_404(tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try: try:
data = PlayerSelectionSchema().load(form_payload()) data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not data['player_id']: if not data['player_id']:
flash(_('Please select a player.'), 'danger') flash(_('Please select a player.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Same two checks as the team roster (SEC-16): the right role, and an # Same two checks as the team roster (SEC-16): the right role, and an
# account that has not been deactivated. The select this comes from now # account that has not been deactivated. The select this comes from now
@@ -574,7 +594,7 @@ def register_player(tryout_id):
player = db.session.get(User, data['player_id']) player = db.session.get(User, data['player_id'])
if not player or not isinstance(player, Player) or not player.is_active_account: if not player or not isinstance(player, Player) or not player.is_active_account:
flash(_('Can only register players.'), 'danger') flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first() existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
if existing: if existing:
@@ -582,19 +602,19 @@ def register_player(tryout_id):
_('%(username)s is already registered for this tryout.', username=player.username), _('%(username)s is already registered for this tryout.', username=player.username),
'info', 'info',
) )
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players: if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger') flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id) registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration) db.session.add(registration)
db.session.commit() db.session.commit()
flash(_('%(username)s registered for tryout!', username=player.username), 'success') flash(_('%(username)s registered for tryout!', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@@ -603,8 +623,11 @@ def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches).""" """Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
@@ -634,7 +657,7 @@ def remove_player(tryout_id, player_id):
db.session.commit() db.session.commit()
flash(_('%(username)s removed from tryout.', username=player.username), 'success') flash(_('%(username)s removed from tryout.', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@@ -643,24 +666,27 @@ def create_team(tryout_id):
"""Create a tryout-specific team.""" """Create a tryout-specific team."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try: try:
data = TryoutTeamSchema().load(form_payload(list_fields=())) data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id) team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team) db.session.add(team)
db.session.commit() db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success') flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@@ -670,12 +696,15 @@ def add_to_team(tryout_id, team_id):
team = db.get_or_404(Team, team_id) team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being # The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to # allowed to manage tryout A was enough to modify a team belonging to
@@ -687,7 +716,7 @@ def add_to_team(tryout_id, team_id):
data = TryoutTeamMemberSchema().load(form_payload(list_fields=())) data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_id'] player_id = data['player_id']
# Only players registered for this tryout may be placed on its teams. # Only players registered for this tryout may be placed on its teams.
@@ -697,7 +726,7 @@ def add_to_team(tryout_id, team_id):
) )
if not is_registered: if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger') flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing: if existing:
@@ -707,7 +736,153 @@ def add_to_team(tryout_id, team_id):
db.session.add(member) db.session.add(member)
db.session.commit() db.session.commit()
flash(_('Player added to team!'), 'success') flash(_('Player added to team!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
@login_required
def edit_team(tryout_id, team_id):
"""Rename a tryout-specific team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team.name = data['team_name']
db.session.commit()
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(tryout_id, team_id):
"""Delete a tryout-specific team and its memberships."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
name = team.name
# Personal note references outlive the team.
PersonalNote.query.filter_by(team_id=team_id).update(
{'team_id': None}, synchronize_session=False
)
# Matches referencing this team: null the FK so the match survives.
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
# Clean participants for those matches (they are no longer in that team).
for match in Match.query.filter(
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
).all():
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
db.session.delete(team)
db.session.commit()
flash(_('Team "%(name)s" deleted.', name=name), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
@login_required
def remove_from_team(tryout_id, team_id, player_id):
"""Remove a player from a tryout team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
synchronize_session=False
)
# Also remove the player from any match participants that were tied to this team.
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(_('Player removed from team.'), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route(
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
)
@login_required
def update_member_position(tryout_id, team_id, member_id):
"""Set or clear a team member's in-game position (role)."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
member = db.get_or_404(TeamMember, member_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id or member.team_id != team_id:
abort(404)
try:
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
member.position = data['position'] or None
db.session.commit()
flash(_('Position updated.'), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@@ -716,8 +891,11 @@ def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" """Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') flash(
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger') flash(_('You do not have permission to delete this tryout.'), 'danger')
@@ -757,4 +935,4 @@ def delete_tryout(tryout_id):
db.session.delete(tryout) db.session.delete(tryout)
db.session.commit() db.session.commit()
flash(_('Tryout deleted successfully.'), 'success') flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
+18 -17
View File
@@ -3,7 +3,7 @@
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
@@ -13,6 +13,7 @@ from app.extensions import db
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,
@@ -102,11 +103,11 @@ def upload_contract():
"""Upload a contract for a player.""" """Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)): if not isinstance(current_user, (Admin, Manager, Coach)):
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger') flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
if document_uploads_locked(): if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger') flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
players = manageable_players() players = manageable_players()
@@ -125,13 +126,13 @@ def upload_contract():
if not can_manage_player_contract(current_user, player_id): if not can_manage_player_contract(current_user, player_id):
flash(_('You do not have permission to upload a contract for this player.'), 'danger') flash(_('You do not have permission to upload a contract for this player.'), 'danger')
return redirect(url_for('users.upload_contract')) return respond_after_change(url_for('users.upload_contract'))
file = request.files.get('contract_file') file = request.files.get('contract_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error: if error:
flash(error, 'danger') flash(error, 'danger')
return redirect(url_for('users.upload_contract')) return respond_after_change(url_for('users.upload_contract'))
player = db.get_or_404(User, player_id) player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams() player_teams = player.get_org_teams()
@@ -154,7 +155,7 @@ def upload_contract():
flash( flash(
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger' _('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
) )
return redirect(url_for('users.upload_contract')) return respond_after_change(url_for('users.upload_contract'))
contract = Contract( contract = Contract(
player_id=player_id, player_id=player_id,
@@ -171,7 +172,7 @@ def upload_contract():
_('Contract uploaded successfully for %(username)s!', username=player.username), _('Contract uploaded successfully for %(username)s!', username=player.username),
'success', 'success',
) )
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
return render_template('pages/upload_contract.html', players=players) return render_template('pages/upload_contract.html', players=players)
@@ -183,17 +184,17 @@ def upload_signed_contract(contract_id):
contract = db.get_or_404(Contract, contract_id) contract = db.get_or_404(Contract, contract_id)
if not contract.can_upload_signed(current_user): if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger') flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
if document_uploads_locked(): if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger') flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
file = request.files.get('signed_file') file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error: if error:
flash(error, 'danger') flash(error, 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}" signed_filename = f"signed_{contract.stored_filename}"
# Legacy local records keep their signed copy beside the original. Drive # Legacy local records keep their signed copy beside the original. Drive
@@ -208,7 +209,7 @@ def upload_signed_contract(contract_id):
stored_signed_path = store_uploaded_document(file, signed_path) stored_signed_path = store_uploaded_document(file, signed_path)
except GoogleDriveStorageError: except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
contract.signed_filename = signed_filename contract.signed_filename = signed_filename
contract.signed_file_path = stored_signed_path contract.signed_file_path = stored_signed_path
@@ -216,7 +217,7 @@ def upload_signed_contract(contract_id):
contract.signed_at = utc_now_naive() contract.signed_at = utc_now_naive()
db.session.commit() db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success') flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
@users_bp.route('/contracts/<int:contract_id>/download') @users_bp.route('/contracts/<int:contract_id>/download')
@@ -226,12 +227,12 @@ def download_contract(contract_id):
contract = db.get_or_404(Contract, contract_id) contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user): if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger') flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
try: try:
document = open_document(contract.file_path) document = open_document(contract.file_path)
except GoogleDriveStorageError: except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.original_filename) return send_file(document, as_attachment=True, download_name=contract.original_filename)
@@ -242,13 +243,13 @@ def download_signed_contract(contract_id):
contract = db.get_or_404(Contract, contract_id) contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user): if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger') flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
if not contract.signed_file_path: if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger') flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
try: try:
document = open_document(contract.signed_file_path) document = open_document(contract.signed_file_path)
except GoogleDriveStorageError: except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.signed_filename) return send_file(document, as_attachment=True, download_name=contract.signed_filename)
+20 -19
View File
@@ -31,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
@@ -176,14 +177,14 @@ def manage_team_notes():
org_teams = coach_org_teams(current_user) org_teams = coach_org_teams(current_user)
if not org_teams: if not org_teams:
flash(_('You are not assigned to a team.'), 'danger') flash(_('You are not assigned to a team.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
org_team = org_teams[0] org_team = org_teams[0]
try: try:
data = NoteContentSchema().load(form_payload(list_fields=())) data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
note = TeamNote( note = TeamNote(
org_team_id=org_team.id, org_team_id=org_team.id,
@@ -194,7 +195,7 @@ def manage_team_notes():
db.session.commit() db.session.commit()
flash(_('Team notes saved successfully!'), 'success') flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -214,17 +215,17 @@ def manage_personal_notes():
data = PersonalNoteSchema().load(form_payload(list_fields=())) data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
player_id = data['player_id'] player_id = data['player_id']
player = db.get_or_404(User, player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id): if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger') flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
note = PersonalNote( note = PersonalNote(
player_id=player_id, player_id=player_id,
@@ -234,7 +235,7 @@ def manage_personal_notes():
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success') flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -254,44 +255,44 @@ def add_personal_note():
data = PersonalNoteSchema().load(form_payload(list_fields=())) data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
player_id = data['player_id'] player_id = data['player_id']
player = db.get_or_404(User, player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id): if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger') flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if data['match_id']: if data['match_id']:
match = db.get_or_404(Match, data['match_id']) match = db.get_or_404(Match, data['match_id'])
if not current_user.can_manage_this_tryout(match.tryout): if not current_user.can_manage_this_tryout(match.tryout):
flash(_('You cannot use that match as note context.'), 'danger') flash(_('You cannot use that match as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first(): if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
flash(_('That player did not participate in the selected match.'), 'danger') flash(_('That player did not participate in the selected match.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if data['tryout_id']: if data['tryout_id']:
tryout = db.get_or_404(Tryout, data['tryout_id']) tryout = db.get_or_404(Tryout, data['tryout_id'])
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You cannot use that tryout as note context.'), 'danger') flash(_('You cannot use that tryout as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first(): if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
flash(_('That player is not registered for the selected tryout.'), 'danger') flash(_('That player is not registered for the selected tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if data['team_id']: if data['team_id']:
team = db.get_or_404(Team, data['team_id']) team = db.get_or_404(Team, data['team_id'])
if not current_user.can_manage_this_tryout(team.tryout): if not current_user.can_manage_this_tryout(team.tryout):
flash(_('You cannot use that team as note context.'), 'danger') flash(_('You cannot use that team as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first(): if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
flash(_('That player is not on the selected team.'), 'danger') flash(_('That player is not on the selected team.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
note = PersonalNote( note = PersonalNote(
player_id=player_id, player_id=player_id,
@@ -304,7 +305,7 @@ def add_personal_note():
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success') flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -323,7 +324,7 @@ def add_note_from_tryout(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger') flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
preselected_player_id = request.args.get('player_id', type=int) preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list # Get registrations as players for the select list
@@ -387,7 +388,7 @@ def add_note_from_match(match_id):
match_obj = db.get_or_404(Match, match_id) match_obj = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout): if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger') flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
# Get participants as players for the select list # Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all() participants = MatchParticipant.query.filter_by(match_id=match_id).all()
+14 -13
View File
@@ -8,6 +8,7 @@ from marshmallow import ValidationError
from app.extensions import db from app.extensions import db
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
@@ -64,13 +65,13 @@ def one_on_one():
if request.method == 'POST': if request.method == 'POST':
if not coach: if not coach:
flash(_('Cannot request One on One - no coach assigned.'), 'danger') flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
try: try:
data = OneOnOneRequestSchema().load(form_payload()) data = OneOnOneRequestSchema().load(form_payload())
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
date_obj = data['date'] date_obj = data['date']
start_time = data['start_time'] start_time = data['start_time']
@@ -90,7 +91,7 @@ def one_on_one():
if not is_available: if not is_available:
flash(_("The requested time is not within the coach's availability."), 'danger') flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
request_obj = OneOnOneRequest( request_obj = OneOnOneRequest(
player_id=current_user.id, player_id=current_user.id,
@@ -118,7 +119,7 @@ def one_on_one():
) )
flash(_('Your One on One request has been submitted!'), 'success') flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability # Build list of upcoming dates that have coach availability
from datetime import date as date_cls from datetime import date as date_cls
@@ -169,11 +170,11 @@ def accept_one_on_one(request_id):
if request_obj.coach_id != current_user.id: if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger') flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if request_obj.status != 'pending': if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info') flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
player = request_obj.player player = request_obj.player
request_obj.status = 'approved' request_obj.status = 'approved'
@@ -182,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,
@@ -204,7 +205,7 @@ def accept_one_on_one(request_id):
), ),
'success', 'success',
) )
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST']) @users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@@ -219,17 +220,17 @@ def reject_one_on_one(request_id):
if request_obj.coach_id != current_user.id: if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger') flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if request_obj.status != 'pending': if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info') flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
try: try:
data = OneOnOneRejectionSchema().load(form_payload(list_fields=())) data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason'] rejection_reason = data['rejection_reason']
player = request_obj.player player = request_obj.player
@@ -241,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,
@@ -264,4 +265,4 @@ def reject_one_on_one(request_id):
), ),
'info', 'info',
) )
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
+3 -2
View File
@@ -1,6 +1,6 @@
"""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
@@ -16,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,
@@ -129,7 +130,7 @@ def edit_profile():
db.session.commit() db.session.commit()
flash(_('Profile updated successfully!'), 'success') flash(_('Profile updated successfully!'), 'success')
return redirect(url_for('users.profile')) return respond_after_change(url_for('users.profile'))
return render_template( return render_template(
'pages/edit_profile.html', 'pages/edit_profile.html',
+2 -2
View File
@@ -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,
+7 -2
View File
@@ -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__)
@@ -60,7 +60,9 @@ def zip_participants(player_ids, participant_ids):
yield player_id, participant_ids[index] if index < len(participant_ids) else None yield player_id, participant_ids[index] if index < len(participant_ids) else None
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id): def notify_participants(
*, title, date, start_time, end_time, participants, fallback_id, change_kind='scheduled'
):
"""Tell each participant that a match has been scheduled or changed. """Tell each participant that a match has been scheduled or changed.
Args: Args:
@@ -73,6 +75,8 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
attendance lands on the right row. attendance lands on the right row.
fallback_id: Reference to use when a participant row has no id — fallback_id: Reference to use when a participant row has no id —
the match's own, which the bot can still act on. the match's own, which the bot can still act on.
change_kind: 'scheduled' (new match / new player added) or
'modified' (date/time changed for existing players).
Returns: Returns:
int: How many notifications were handed to the bot. int: How many notifications were handed to the bot.
@@ -91,6 +95,7 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
event_date=event_date, event_date=event_date,
event_time=event_time, event_time=event_time,
reference_id=participant_id or fallback_id, reference_id=participant_id or fallback_id,
change_kind=change_kind,
) )
sent += 1 sent += 1
return sent return sent
+193 -16
View File
@@ -420,22 +420,9 @@ document.addEventListener('DOMContentLoaded', function() {
initDraggableBlocks(); initDraggableBlocks();
/** /**
* Auto-dismiss flash alerts after 5 seconds. * Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
*/ */
const alerts = document.querySelectorAll('.alert-dismissible'); armAlertAutoDismiss();
alerts.forEach(function(alert) {
setTimeout(function() {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function() {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
}); });
/* ========================================================================= /* =========================================================================
Declarative behaviours — replacing inline event handlers Declarative behaviours — replacing inline event handlers
@@ -593,7 +580,7 @@ document.addEventListener('input', function (event) {
document.addEventListener('change', function (event) { document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]'); const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) { if (control && control.form) {
control.form.submit(); submitForm(control.form);
} }
}); });
@@ -613,3 +600,193 @@ document.addEventListener('change', function (event) {
window.location.href = select.getAttribute('data-navigate') window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value)); .replace('{value}', encodeURIComponent(select.value));
}); });
/* =========================================================================
AJAX forms — server-rendered changes without a full page reload
=========================================================================
A <form method="post" data-ajax> is submitted with fetch() instead of a
navigation. The route answers a JSON payload (see app/responders.py) that
says where to fetch the refreshed content and which flash messages to
show; the content region is then swapped in place, so scroll position and
open state survive between edits.
A stale edit — the page changed since this copy was loaded — is answered
409, and the user is asked to reload rather than silently overwrite the
other person's change.
========================================================================= */
let TT_REVISION = null;
function armAlertAutoDismiss() {
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(function (alert) {
if (alert.getAttribute('data-armed')) return;
alert.setAttribute('data-armed', '1');
setTimeout(function () {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function () {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
}
function fetchRevision() {
return fetch('/api/revision', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (data) {
TT_REVISION = data.revision;
return data.revision;
}).catch(function () {
// Revision tracking is best-effort; a failed lookup just disables it.
});
}
function showFlashMessages(messages) {
const container = document.querySelector('.flash-messages');
if (!container || !messages || !messages.length) return;
messages.forEach(function (m) {
const alert = document.createElement('div');
alert.className = 'alert alert-' + (m.category || 'info') + ' alert-dismissible';
const span = document.createElement('span');
span.textContent = m.text;
const close = document.createElement('button');
close.type = 'button';
close.className = 'alert-close';
close.setAttribute('data-action', 'dismiss-alert');
close.setAttribute('aria-label', 'Dismiss');
close.innerHTML = '&times;';
alert.appendChild(span);
alert.appendChild(close);
container.appendChild(alert);
});
armAlertAutoDismiss();
}
function replaceContent(html, url) {
const holder = document.createElement('div');
holder.innerHTML = html;
const next = holder.querySelector('#page-content');
const target = document.getElementById('page-content');
if (next && target) {
target.innerHTML = next.innerHTML;
}
if (url && window.history && window.history.replaceState) {
window.history.replaceState(null, '', url);
}
initDraggableBlocks();
document.dispatchEvent(new Event('fragment:updated'));
}
function refreshPage(url) {
return fetch(url, { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) { replaceContent(html, url); });
}
function submitAjaxForm(form) {
const headers = {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
};
const token = form.querySelector('input[name="csrf_token"]');
if (token) headers['X-CSRFToken'] = token.value;
if (TT_REVISION !== null && TT_REVISION !== undefined) {
headers['X-Revision'] = String(TT_REVISION);
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
body: new FormData(form),
headers: headers,
credentials: 'same-origin'
}).then(function (response) {
return response.json().then(function (data) {
return { status: response.status, data: data };
}).catch(function () {
return { status: response.status, data: null };
});
}).then(function (result) {
if (result.status === 409) {
const message = (result.data && result.data.message) ||
'This page was changed by someone else. Reload to see the latest changes.';
if (window.confirm(message)) {
window.location.reload();
}
return null;
}
if (result.status >= 400) {
const message = (result.data && (result.data.message || result.data.error)) ||
'Something went wrong. Please try again.';
window.alert(message);
return null;
}
return result.data;
}).then(function (data) {
if (!data) return;
if (data.revision !== undefined && data.revision !== null) {
TT_REVISION = data.revision;
}
showFlashMessages(data.messages);
if (data.redirect) {
return refreshPage(data.redirect);
}
return undefined;
}).catch(function (err) {
console.error('AJAX form submission failed:', err);
});
}
function submitForm(form) {
if (form.hasAttribute('data-ajax')) {
submitAjaxForm(form);
} else {
form.submit();
}
}
function checkForRemoteChanges() {
return fetchRevision().then(function (rev) {
if (TT_REVISION !== null && rev !== TT_REVISION) {
TT_REVISION = rev;
return refreshPage(window.location.href);
}
return undefined;
}).catch(function () { /* best-effort */ });
}
// data-submit-on-change and data-ajax both funnel through submitForm so a
// <select> change uses fetch() when the form is marked, and a plain submit
// otherwise. (form.submit() does not fire the submit event, so intercepting
// only 'submit' would miss the selects.)
document.addEventListener('submit', function (event) {
const form = event.target.closest('form[data-ajax]');
if (!form || event.defaultPrevented) return;
event.preventDefault();
submitAjaxForm(form);
});
// Refresh when the user returns to the tab: if someone else changed the data,
// this brings the view up to date without a constant polling loop.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
checkForRemoteChanges();
}
});
window.addEventListener('focus', function () {
checkForRemoteChanges();
});
document.addEventListener('DOMContentLoaded', function () {
fetchRevision();
});
window.TT = window.TT || {};
window.TT.refreshPage = refreshPage;
+1 -1
View File
@@ -118,4 +118,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+1 -1
View File
@@ -173,7 +173,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
<div class="content"> <div class="content" id="page-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>
+1 -1
View File
@@ -27,7 +27,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action=" <form method="POST" data-ajax action="
{% if context_type == 'tryout' %} {% if context_type == 'tryout' %}
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }} {{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
{% elif context_type == 'match' %} {% elif context_type == 'match' %}
+4 -4
View File
@@ -73,7 +73,7 @@
Only admins can create or modify tryouts. Coaches and managers are locked out. Only admins can create or modify tryouts. Coaches and managers are locked out.
{% endif %} {% endif %}
</p> </p>
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}"> <form method="POST" data-ajax action="{{ url_for('admin.toggle_tryouts') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}"> <button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %} {% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
@@ -100,7 +100,7 @@
Only admins can upload or sign contracts. Other roles are blocked. Only admins can upload or sign contracts. Other roles are blocked.
{% endif %} {% endif %}
</p> </p>
<form method="POST" action="{{ url_for('admin.toggle_document_uploads') }}"> <form method="POST" data-ajax action="{{ url_for('admin.toggle_document_uploads') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.document_uploads_enabled %}warning{% else %}success{% endif %}"> <button type="submit" class="btn btn-{% if settings.document_uploads_enabled %}warning{% else %}success{% endif %}">
{% if settings.document_uploads_enabled %}Disable Uploads{% else %}Enable Uploads{% endif %} {% if settings.document_uploads_enabled %}Disable Uploads{% else %}Enable Uploads{% endif %}
@@ -126,7 +126,7 @@
{% if not settings.season_active %} {% if not settings.season_active %}
<hr style="margin: 12px 0;"> <hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.start_season') }}"> <form method="POST" data-ajax action="{{ url_for('admin.start_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label>Season Name</label> <label>Season Name</label>
@@ -140,7 +140,7 @@
</form> </form>
{% else %} {% else %}
<hr style="margin: 12px 0;"> <hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.end_season') }}"> <form method="POST" data-ajax action="{{ url_for('admin.end_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label>End Date</label> <label>End Date</label>
+1 -1
View File
@@ -4,7 +4,7 @@
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
{% block content %} {% block content %}
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form"> <form method="POST" data-ajax action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% for entry in players %} {% for entry in players %}
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/> <input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
+8
View File
@@ -346,6 +346,14 @@ function showEventModal(event) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true)); grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
} }
// Team match details
if (type === 'team_match') {
grid.appendChild(detailItem('Team', document.createTextNode(props.org_team || 'Team')));
if (props.opponent) {
grid.appendChild(detailItem('Opponent', document.createTextNode(props.opponent)));
}
}
if (props.description) { if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true)); grid.appendChild(detailItem('Description', multilineNode(props.description), true));
} }
+1 -1
View File
@@ -25,7 +25,7 @@
</div> </div>
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form"> <form method="POST" data-ajax action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
+1 -1
View File
@@ -22,7 +22,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm"> <form method="POST" data-ajax action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if is_practice %} {% if is_practice %}
<input type="hidden" name="title" value="Practice"> <input type="hidden" name="title" value="Practice">
+1 -1
View File
@@ -192,7 +192,7 @@ function togglePresence(btn) {
btn.classList.add('btn-outline'); btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm'; btn.innerHTML = 'Confirm';
} }
location.reload(); window.TT.refreshPage(window.location.pathname);
}) })
.catch(function(error) { .catch(function(error) {
console.error('Error:', error); console.error('Error:', error);
+4 -4
View File
@@ -13,7 +13,7 @@
<span class="badge badge-esport">{{ org_team.name }}</span> <span class="badge badge-esport">{{ org_team.name }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm"> <form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
@@ -41,7 +41,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm"> <form method="POST" data-ajax action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
@@ -145,7 +145,7 @@
</td> </td>
<td> <td>
{% if req.status == 'pending' %} {% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;"> <form method="POST" data-ajax action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<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-success" title="{{ _('Accept') }}"> <button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}">
<i class="fas fa-check"></i> {{ _('Accept') }} <i class="fas fa-check"></i> {{ _('Accept') }}
@@ -177,7 +177,7 @@
<h4><i class="fas fa-times-circle"></i> {{ _('Reject One on One Request') }}</h4> <h4><i class="fas fa-times-circle"></i> {{ _('Reject One on One Request') }}</h4>
<button type="button" class="modal-close" data-action="hide-reject-modal">&times;</button> <button type="button" class="modal-close" data-action="hide-reject-modal">&times;</button>
</div> </div>
<form id="rejectForm" method="POST" action=""> <form id="rejectForm" method="POST" data-ajax action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
+1 -1
View File
@@ -124,7 +124,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if coach %} {% if coach %}
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm"> <form method="POST" data-ajax action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.manage_personal_notes') }}" class="form"> <form method="POST" data-ajax action="{{ url_for('users.manage_personal_notes') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
+1 -1
View File
@@ -9,7 +9,7 @@
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3> <h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form"> <form method="POST" data-ajax action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
+3 -3
View File
@@ -98,7 +98,7 @@
<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>
</a> </a>
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}"> <form method="POST" data-ajax action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<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" title="{{ _('Delete Match') }}"> <button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
@@ -170,8 +170,8 @@ function togglePresence(btn) {
btn.classList.add('btn-outline'); btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm'; btn.innerHTML = 'Confirm';
} }
// Reload to update presence bar // Update in place to preserve state
location.reload(); window.TT.refreshPage(window.location.pathname);
}) })
.catch(function(error) { .catch(function(error) {
console.error('Error:', error); console.error('Error:', error);
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form"> <form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
+7 -7
View File
@@ -18,7 +18,7 @@
<h3>{{ _('Create New Team') }}</h3> <h3>{{ _('Create New Team') }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('teams.create_team') }}" class="form"> <form method="POST" data-ajax action="{{ url_for('teams.create_team') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
<div class="form-group col-4"> <div class="form-group col-4">
@@ -63,7 +63,7 @@
<button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}"> <button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}">
<i class="fas fa-edit"></i> {{ _('Edit') }} <i class="fas fa-edit"></i> {{ _('Edit') }}
</button> </button>
<form method="POST" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=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">
<i class="fas fa-trash"></i> {{ _('Delete') }} <i class="fas fa-trash"></i> {{ _('Delete') }}
@@ -89,7 +89,7 @@
<span class="staff-tag"> <span class="staff-tag">
{{ c.username }} {{ c.username }}
{% if can_manage %} {% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="coach_id" value="{{ c.id }}"/> <input type="hidden" name="coach_id" value="{{ c.id }}"/>
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">&times;</button> <button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">&times;</button>
@@ -111,7 +111,7 @@
<span class="staff-tag manager-tag"> <span class="staff-tag manager-tag">
{{ m.username }} {{ m.username }}
{% if can_manage %} {% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="manager_id" value="{{ m.id }}"/> <input type="hidden" name="manager_id" value="{{ m.id }}"/>
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">&times;</button> <button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">&times;</button>
@@ -180,7 +180,7 @@
<td>{{ entry.player.phone or '-' }}</td> <td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %} {% if can_manage_team %}
<td> <td>
<form method="POST" 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">
<i class="fas fa-user-minus"></i> {{ _('Remove') }} <i class="fas fa-user-minus"></i> {{ _('Remove') }}
@@ -210,7 +210,7 @@
<hr class="my-3"> <hr class="my-3">
<div class="add-player-section"> <div class="add-player-section">
<h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5> <h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5>
<form method="POST" action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline"> <form method="POST" data-ajax action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required> <select name="player_id" class="form-select" required>
<option value="">{{ _('-- Select a player --') }}</option> <option value="">{{ _('-- Select a player --') }}</option>
@@ -258,7 +258,7 @@
<button class="modal-close" data-action="hide-edit-form">&times;</button> <button class="modal-close" data-action="hide-edit-form">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="editTeamForm" method="POST" action="" class="form"> <form id="editTeamForm" method="POST" data-ajax action="" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="sync_staff" value="1"/> <input type="hidden" name="sync_staff" value="1"/>
+195 -53
View File
@@ -15,7 +15,7 @@
</a> </a>
{% endif %} {% endif %}
{% if can_edit %} {% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form"> <form method="POST" data-ajax action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" data-submit-on-change class="form-select"> <select name="status" data-submit-on-change class="form-select">
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option> <option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option>
@@ -108,7 +108,7 @@
{% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %} {% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body text-center"> <div class="card-body text-center">
<form method="POST" action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}"> <form method="POST" data-ajax action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }} <i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }}
@@ -124,7 +124,7 @@
<h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3> <h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline"> <form method="POST" data-ajax action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required> <select name="player_id" class="form-select" required>
<option value="">{{ _('-- Select a player --') }}</option> <option value="">{{ _('-- Select a player --') }}</option>
@@ -173,7 +173,7 @@
</td> </td>
<td> <td>
{% if can_edit %} {% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form"> <form method="POST" data-ajax action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" data-submit-on-change class="form-select form-select-sm"> <select name="status" data-submit-on-change class="form-select form-select-sm">
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option> <option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option>
@@ -205,7 +205,7 @@
</a> </a>
{% endif %} {% endif %}
{% if not tryout.is_ended %} {% if not tryout.is_ended %}
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}"> <form method="POST" data-ajax action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
<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" title="{{ _('Remove player from tryout') }}"> <button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}">
<i class="fas fa-user-minus"></i> {{ _('Remove') }} <i class="fas fa-user-minus"></i> {{ _('Remove') }}
@@ -238,7 +238,7 @@
<div class="card-body"> <div class="card-body">
{% if can_edit and not tryout.is_ended %} {% if can_edit and not tryout.is_ended %}
<div id="createTeamForm" class="hidden mb-3"> <div id="createTeamForm" class="hidden mb-3">
<form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline"> <form method="POST" data-ajax action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2"> <input type="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2">
<button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button> <button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button>
@@ -249,7 +249,14 @@
{% for team in team_data %} {% for team in team_data %}
<div class="team-card mb-3"> <div class="team-card mb-3">
<h4 class="team-name">{{ team.team.name }}</h4> <div class="team-card-header">
<h4 class="team-name">{{ team.team.name }}</h4>
{% if can_edit and not tryout.is_ended %}
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</button>
{% endif %}
</div>
<ul class="team-members"> <ul class="team-members">
{% for member in team.members %} {% for member in team.members %}
<li> <li>
@@ -263,32 +270,96 @@
<li class="text-muted">{{ _('No players assigned yet.') }}</li> <li class="text-muted">{{ _('No players assigned yet.') }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if can_edit and not tryout.is_ended and registered_players %}
{% set positions = game_positions.get(tryout.game, []) %}
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required>
<option value="">{{ _('Select player...') }}</option>
{% for p in registered_players %}
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
<option value="{{ p.id }}">{{ p.username }}</option>
{% endif %}
{% endfor %}
</select>
{% if positions %}
<select name="position" class="form-select mx-2">
<option value="">{{ _('-- Select Position --') }}</option>
{% for pos in positions %}
<option value="{{ pos }}">{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2">
{% endif %}
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
</form>
{% endif %}
</div> </div>
{% if can_edit and not tryout.is_ended %}
{% set positions = game_positions.get(tryout.game, []) %}
<!-- Edit Team Modal -->
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">&times;</button>
</div>
<div class="modal-body">
<!-- Rename -->
<form method="POST" data-ajax action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<label for="renameInput{{ team.team.id }}" class="mr-2" style="white-space:nowrap;">{{ _('Team name') }}</label>
<input type="text" id="renameInput{{ team.team.id }}" name="team_name" value="{{ team.team.name }}" required class="form-input" style="flex:1;">
<button type="submit" class="btn btn-sm btn-primary ml-2">{{ _('Rename') }}</button>
</form>
<hr class="section-divider">
<!-- Roster -->
<h5 class="mb-2"><i class="fas fa-users"></i> {{ _('Roster') }}</h5>
<div class="roster-rows">
{% for member in team.members %}
<div class="roster-row">
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
<span class="roster-player-name">{{ member.player.username }}</span>
<form method="POST" data-ajax action="{{ url_for('tryouts.update_member_position', tryout_id=tryout.id, team_id=team.team.id, member_id=member.member_id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="position" class="form-select roster-role-select" data-submit-on-change>
<option value="">{{ _('No role') }}</option>
{% for pos in positions %}
<option value="{{ pos }}" {% if member.position == pos %}selected{% endif %}>{{ pos }}</option>
{% endfor %}
</select>
</form>
<form method="POST" data-ajax action="{{ url_for('tryouts.remove_from_team', tryout_id=tryout.id, team_id=team.team.id, player_id=member.player_id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from this team?', player=member.player.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger roster-remove-btn" title="{{ _('Remove from team') }}">
<i class="fas fa-user-minus"></i>
</button>
</form>
</div>
{% else %}
<p class="text-muted text-center">{{ _('No players assigned yet.') }}</p>
{% endfor %}
</div>
{% if registered_players %}
<hr class="section-divider">
<h5 class="mb-2"><i class="fas fa-user-plus"></i> {{ _('Add Player') }}</h5>
<form method="POST" data-ajax action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required style="flex:1;">
<option value="">{{ _('Select player...') }}</option>
{% for p in registered_players %}
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
<option value="{{ p.id }}">{{ p.username }}</option>
{% endif %}
{% endfor %}
</select>
{% if positions %}
<select name="position" class="form-select mx-2">
<option value="">{{ _('-- Select Position --') }}</option>
{% for pos in positions %}
<option value="{{ pos }}">{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
{% endif %}
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
</form>
{% endif %}
<hr class="section-divider" style="border-color: var(--danger);">
<div class="text-center">
<form method="POST" data-ajax action="{{ url_for('tryouts.delete_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="inline-form" data-confirm="{{ _('Delete this team? Players already in the team will be removed and matches referencing it will be unlinked.') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% else %} {% else %}
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p> <p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
{% endfor %} {% endfor %}
@@ -458,6 +529,12 @@
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline"> <a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-edit"></i> {{ _('Edit') }} <i class="fas fa-edit"></i> {{ _('Edit') }}
</a> </a>
<form method="POST" data-ajax action="{{ url_for('matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i> {{ _('Delete') }}
</button>
</form>
{% endif %} {% endif %}
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}"> <a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
<i class="fas fa-sticky-note"></i> {{ _('Note') }} <i class="fas fa-sticky-note"></i> {{ _('Note') }}
@@ -568,8 +645,56 @@
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 1px 3px rgba(0,0,0,0.12); box-shadow: 0 1px 3px rgba(0,0,0,0.12);
} }
/* Team card header — edit button aligned right */
.team-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.team-card-header .team-name {
margin-bottom: 0;
}
/* Roster rows inside modal */
.roster-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.roster-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--gray-50);
border-radius: var(--radius-sm);
}
.roster-player-name {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
}
.roster-role-select {
width: auto;
font-size: 0.75rem;
padding: 1px 4px;
}
.roster-remove-btn {
padding: 1px 5px;
font-size: 0.7rem;
}
[data-theme="dark"] .roster-row {
background: var(--bg-tertiary);
}
</style> </style>
<script nonce="{{ csp_nonce }}"> <script nonce="{{ csp_nonce }}">
function showModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); } function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); } function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
@@ -592,41 +717,58 @@ function toggleTryoutPresence(matchId, participantId, btn) {
btn.classList.add('presence-pending-btn'); btn.classList.add('presence-pending-btn');
btn.innerHTML = btn.innerHTML.replace('✅', '⏳'); btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
} }
location.reload(); window.TT.refreshPage(window.location.pathname);
}) })
.catch(function(err) { .catch(function(err) {
console.error('Error toggling tryout presence:', err); console.error('Error toggling tryout presence:', err);
}); });
} }
// Mini calendar for matches // Mini calendar for matches. Re-initialised on fragment:updated so the
document.addEventListener('DOMContentLoaded', function() { // calendar survives AJAX edits that swap the page content in place.
var miniCalendar = null;
function initMiniCalendar() {
var miniCalendarEl = document.getElementById('mini-calendar'); var miniCalendarEl = document.getElementById('mini-calendar');
if (miniCalendarEl) { if (!miniCalendarEl) {
var miniCalendar = new FullCalendar.Calendar(miniCalendarEl, { return;
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next',
center: 'title',
right: 'dayGridMonth,timeGridWeek'
},
events: '/matches/api/events/{{ tryout.id }}',
height: '300px',
eventClick: function(info) {
if (info.event.extendedProps.match_id) {
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
}
}
});
miniCalendar.render();
} }
}); if (miniCalendar) {
miniCalendar.destroy();
miniCalendar = null;
}
miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next',
center: 'title',
right: 'dayGridMonth,timeGridWeek'
},
events: '/matches/api/events/{{ tryout.id }}',
height: '300px',
eventClick: function(info) {
if (info.event.extendedProps.match_id) {
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
}
}
});
miniCalendar.render();
}
document.addEventListener('DOMContentLoaded', initMiniCalendar);
document.addEventListener('fragment:updated', initMiniCalendar);
// Behaviours declared in the markup, dispatched by the delegated listener // Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce. // in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({ registerActions({
'show-create-team': showCreateTeam, 'show-create-team': showCreateTeam,
'hide-create-team': hideCreateTeam, 'hide-create-team': hideCreateTeam,
'show-modal': function (element) {
showModal(element.getAttribute('data-modal-id'));
},
'hide-modal': function (element) {
hideModal(element.getAttribute('data-modal-id'));
},
'toggle-tryout-presence': function (element) { 'toggle-tryout-presence': function (element) {
toggleTryoutPresence( toggleTryoutPresence(
element.getAttribute('data-match-id'), element.getAttribute('data-match-id'),
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
+12
View File
@@ -605,6 +605,18 @@ class TryoutTeamMemberSchema(StripMixin):
) )
class TryoutTeamMemberPositionSchema(StripMixin):
"""Update a team member's in-game position (role)."""
position = fields.String(
load_default='',
validate=validate.Length(
max=50,
error=_l('Position must be 50 characters or less.'),
),
)
class NoteContentSchema(StripMixin): class NoteContentSchema(StripMixin):
"""Bounded text stored as a team or personal coaching note.""" """Bounded text stored as a team or personal coaching note."""
-66
View File
@@ -1,66 +0,0 @@
"""Database seeding script for Team Tryouts application.
Wipes all data and creates an admin user. Run manually with:
python -m app.supporting_scripts.seed
"""
from app.extensions import db, hash_password
from app.models import Admin
def seed_database():
"""Delete all existing data and create a single admin account."""
# Delete all data in FK-safe order
print("Deleting existing data...")
tables = [
'one_on_one_requests',
'player_disponibilities',
'coach_availabilities',
'match_participants',
'team_match_participants',
'matches',
'team_matches',
'team_players',
'team_members',
'teams',
'evaluations',
'tryout_registrations',
'tryouts',
'org_team_coaches',
'org_team_managers',
'team_notes',
'personal_notes',
'user_gamertags',
'org_teams',
'users',
]
for table in tables:
db.session.execute(db.text(f'DELETE FROM {table}'))
db.session.commit()
print("[OK] All data deleted.")
# Create the admin user
admin = Admin(
username='admin',
password_hash=hash_password('password'),
role='admin',
full_name='Admin',
email='[email protected]',
)
db.session.add(admin)
db.session.commit()
print("[OK] Created admin user")
print("\n=== Admin Credentials ===")
print("Username: admin")
print("Password: password")
print("\n[SUCCESS] Database reset and seeded!")
if __name__ == '__main__':
from app.app import create_app
app = create_app()
with app.app_context():
seed_database()
+36
View File
@@ -0,0 +1,36 @@
"""Migration: Create indexes on date columns used by the 18:00 daily reminder.
Run this script to add indexes on the date columns that the Discord bot's
daily reminder queries (Match.date, TeamMatch.date, Tryout.date,
OneOnOneRequest.date). The app uses create_all() for new tables, but existing
production databases need these added explicitly.
Usage: python migrations/add_reminder_date_indexes.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
indexes = [
('ix_matches_date', 'matches', 'date'),
('ix_team_matches_date', 'team_matches', 'date'),
('ix_tryouts_date', 'tryouts', 'date'),
('ix_one_on_one_requests_date', 'one_on_one_requests', 'date'),
]
for index_name, table, column in indexes:
db.session.execute(text(
f'CREATE INDEX IF NOT EXISTS {index_name} ON {table} ({column})'
))
print(f'Index {index_name} on {table}({column}) ensured.')
db.session.commit()
print('Migration complete.')
-143
View File
@@ -1,143 +0,0 @@
"""Development HTTPS server for testing production settings locally.
Generates a self-signed certificate (if not present) and runs the
application via Waitress wrapped in a TLS socket. This simulates the
production environment where Nginx handles TLS termination.
Usage:
python run_https.py
The server will listen on https://localhost:8443
Accept the self-signed certificate warning in your browser to proceed.
"""
import os
import socket
import ssl
import subprocess
import sys
from waitress.server import create_server
from app.app import create_app
CERT_FILE = 'certs/localhost.pem'
KEY_FILE = 'certs/localhost-key.pem'
def generate_self_signed_cert():
"""Generate a self-signed certificate for local HTTPS testing.
Uses OpenSSL to create a key and certificate valid for 365 days.
Skips generation if certificate files already exist.
"""
if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
print('[OK] Self-signed certificate already exists.')
return
os.makedirs('certs', exist_ok=True)
print('[INFO] Generating self-signed certificate for localhost...')
try:
subprocess.run(
[
'openssl',
'req',
'-x509',
'-newkey',
'rsa:2048',
'-keyout',
KEY_FILE,
'-out',
CERT_FILE,
'-days',
'365',
'-nodes',
'-subj',
'/CN=localhost',
],
check=True,
capture_output=True,
)
print('[OK] Certificate generated: certs/localhost.pem')
except FileNotFoundError:
print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
print(' winget install OpenSSL.OpenSSL')
print(' OR download from https://slproweb.com/products/Win32OpenSSL.html')
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f'[ERROR] Certificate generation failed: {e}')
sys.exit(1)
def main():
"""Start the HTTPS development server."""
port = int(os.getenv('HTTPS_PORT', 8443))
host = os.getenv('HOST', '127.0.0.1')
# Ensure certificate exists
generate_self_signed_cert()
# Create the Flask application
app = create_app()
# Create SSL context
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(CERT_FILE, KEY_FILE)
# Create a TCP socket, wrap it with TLS, then pass to Waitress
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
sock.listen(5)
# Wrap the socket with TLS
ssl_sock = context.wrap_socket(sock, server_side=True)
# =====================================================================
# WSGI Middleware: Tell Flask the connection is HTTPS
#
# Waitress doesn't know the underlying socket is TLS, so Flask sees
# wsgi.url_scheme = "http". Without this middleware, force_https()
# would cause an infinite redirect loop (ERR_TOO_MANY_REDIRECTS).
# =====================================================================
class ForceHTTPSMiddleware:
"""WSGI middleware that sets url_scheme to 'https'.
Since we're wrapping the TCP socket with SSL before passing it
to Waitress, Flask's request.is_secure returns False because
Waitress reports wsgi.url_scheme='http'. This middleware fixes
that so Flask correctly identifies the connection as HTTPS.
"""
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
environ['wsgi.url_scheme'] = 'https'
environ['HTTPS'] = 'on'
return self.wsgi_app(environ, start_response)
# Wrap the Flask app with the HTTPS middleware
app.wsgi_app = ForceHTTPSMiddleware(app.wsgi_app)
print('\n╔══════════════════════════════════════════════════════╗')
print('║ TEAM TRYOUTS - Development HTTPS Server ║')
print('╠══════════════════════════════════════════════════════╣')
print(f'║ URL: https://{host}:{port} ║')
print('║ Cert: self-signed (accept browser warning) ║')
print('║ Press Ctrl+C to stop ║')
print('╚══════════════════════════════════════════════════════╝\n')
# Create Waitress server with the SSL-wrapped socket
server = create_server(
app,
sockets=[ssl_sock],
threads=4,
channel_timeout=30,
)
server.run()
if __name__ == '__main__':
main()
+1 -4
View File
@@ -5,9 +5,6 @@ ADMIN-001. The admin panel relies on AppSettings for global toggles
These tests verify the key-value store itself, independent of any route. 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
@@ -59,4 +56,4 @@ class TestAppSettingsBool:
AppSettings.set_bool('bool_key', True) AppSettings.set_bool('bool_key', True)
assert AppSettings.get('bool_key') == 'true' assert AppSettings.get('bool_key') == 'true'
AppSettings.set_bool('bool_key', False) AppSettings.set_bool('bool_key', False)
assert AppSettings.get('bool_key') == 'false' assert AppSettings.get('bool_key') == 'false'
+1 -3
View File
@@ -4,8 +4,6 @@ ADMIN-002. Every sensitive admin action (backup, restore, toggle, season,
wipe) writes an AuditLog entry. These tests verify the model itself. 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
@@ -58,4 +56,4 @@ class TestAuditLogRecord:
entry = AuditLog.query.filter_by(action='before_delete').first() entry = AuditLog.query.filter_by(action='before_delete').first()
assert entry is not None assert entry is not None
assert entry.user_id is None # FK set to NULL on delete assert entry.user_id is None # FK set to NULL on delete
+14 -14
View File
@@ -5,10 +5,6 @@ in production, but tests mock the subprocess boundary. These tests verify
the route logic, the BackupRecord model, and audit logging. 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
@@ -41,7 +36,9 @@ class TestBackupCreate:
as_role('admin') as_role('admin')
response = client.post( response = client.post(
'/admin/backup/create', data={'notes': 'test backup'}, follow_redirects=False, '/admin/backup/create',
data={'notes': 'test backup'},
follow_redirects=False,
) )
assert _redirected(response) assert _redirected(response)
@@ -61,7 +58,9 @@ class TestBackupCreate:
as_role('admin') as_role('admin')
response = client.post( response = client.post(
'/admin/backup/create', data={}, follow_redirects=True, '/admin/backup/create',
data={},
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'Backup failed' in html assert 'Backup failed' in html
@@ -135,7 +134,8 @@ class TestBackupDownload:
record_id = record.id record_id = record.id
response = client.get( response = client.get(
f'/admin/backup/{record_id}/download', follow_redirects=True, f'/admin/backup/{record_id}/download',
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'missing from disk' in html.lower() assert 'missing from disk' in html.lower()
@@ -162,7 +162,8 @@ class TestBackupDelete:
record_id = record.id record_id = record.id
response = client.post( response = client.post(
f'/admin/backup/{record_id}/delete', follow_redirects=False, f'/admin/backup/{record_id}/delete',
follow_redirects=False,
) )
assert _redirected(response) assert _redirected(response)
@@ -214,14 +215,13 @@ class TestBackupRestore:
record_id = record.id record_id = record.id
response = client.post( response = client.post(
f'/admin/backup/{record_id}/restore', follow_redirects=True, f'/admin/backup/{record_id}/restore',
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'missing from disk' in html.lower() assert 'missing from disk' in html.lower()
def test_restore_creates_safety_backup_first( def test_restore_creates_safety_backup_first(self, app, client, as_role, monkeypatch, tmp_path):
self, app, client, as_role, monkeypatch, tmp_path
):
import app.routes.admin as admin_module import app.routes.admin as admin_module
# The restore route parses DATABASE_URL outside its try block. # The restore route parses DATABASE_URL outside its try block.
@@ -282,4 +282,4 @@ class TestBackupRestore:
with app.app_context(): with app.app_context():
safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first() safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first()
assert safety is not None assert safety is not None
assert safety.backup_type == 'pre_restore' assert safety.backup_type == 'pre_restore'
+1 -3
View File
@@ -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()
@@ -53,4 +51,4 @@ class TestAdminCsrfProtection:
# Flask-WTF returns 400 on missing CSRF token # Flask-WTF returns 400 on missing CSRF token
assert response.status_code in (400, 302), ( assert response.status_code in (400, 302), (
f'{route} returned {response.status_code} without CSRF token' f'{route} returned {response.status_code} without CSRF token'
) )
+1 -5
View File
@@ -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 = [
@@ -147,4 +143,4 @@ class TestAdminTemplateIntegrity:
def test_static_js_loads(self, client): def test_static_js_loads(self, client):
response = client.get('/static/js/main.js') response = client.get('/static/js/main.js')
assert response.status_code == 200 assert response.status_code == 200
assert 'javascript' in response.content_type or 'text/' in response.content_type assert 'javascript' in response.content_type or 'text/' in response.content_type
+3 -4
View File
@@ -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)
@@ -47,4 +46,4 @@ class TestAdminRouteRegistration:
return return
assert _redirected(response), ( assert _redirected(response), (
f'{route} answered {response.status_code} to an anonymous caller' f'{route} answered {response.status_code} to an anonymous caller'
) )
+5 -5
View File
@@ -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']
@@ -154,7 +152,9 @@ class TestSeasonGateEnforcement:
as_role(role) as_role(role)
response = client.post( response = client.post(
f'/team-matches/{match_id}/delete', data={}, follow_redirects=True, f'/team-matches/{match_id}/delete',
data={},
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'season is not active' in html.lower() or 'begin a season' in html.lower() assert 'season is not active' in html.lower() or 'begin a season' in html.lower()
@@ -167,4 +167,4 @@ class TestSeasonGateEnforcement:
data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'}, data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False, follow_redirects=False,
) )
assert _redirected(response) # success, not blocked assert _redirected(response) # success, not blocked
-7
View File
@@ -5,8 +5,6 @@ the tryout toggle button, the season form fields, the wipe confirmation
input, and the backup/audit tables, plus that the logo image is served. 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
+6 -8
View File
@@ -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']
@@ -76,9 +74,7 @@ class TestTryoutGateEnforcement:
assert 'closed' in html.lower() or 'open tryouts' in html.lower() assert 'closed' in html.lower() or 'open tryouts' in html.lower()
@pytest.mark.parametrize('role', NON_ADMIN_ROLES) @pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_edit_tryout_when_closed( def test_non_admin_cannot_edit_tryout_when_closed(self, app, client, as_role, make_user, role):
self, app, client, as_role, make_user, role
):
# Create a tryout as admin first # Create a tryout as admin first
as_role('admin') as_role('admin')
client.post( client.post(
@@ -119,7 +115,9 @@ class TestTryoutGateEnforcement:
as_role(role) as_role(role)
response = client.post( response = client.post(
f'/tryouts/{tryout_id}/delete', data={}, follow_redirects=True, f'/tryouts/{tryout_id}/delete',
data={},
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'closed' in html.lower() or 'open tryouts' in html.lower() assert 'closed' in html.lower() or 'open tryouts' in html.lower()
@@ -153,4 +151,4 @@ class TestTryoutGateEnforcement:
data={'title': 'Admin Edited', 'game': 'Valorant', 'date': '2026-12-01'}, data={'title': 'Admin Edited', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False, follow_redirects=False,
) )
assert _redirected(response) # success, not blocked assert _redirected(response) # success, not blocked
+12 -4
View File
@@ -9,7 +9,11 @@ import pytest
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
AuditLog, BackupRecord, OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer, AuditLog,
BackupRecord,
OrgTeam,
TeamMatch,
TeamPlayer,
) )
@@ -21,7 +25,9 @@ class TestWipeConfirmation:
def test_wipe_without_confirmation_fails(self, app, client, as_role): def test_wipe_without_confirmation_fails(self, app, client, as_role):
as_role('admin') as_role('admin')
response = client.post( response = client.post(
'/admin/teams/wipe', data={}, follow_redirects=True, '/admin/teams/wipe',
data={},
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'WIPE' in html assert 'WIPE' in html
@@ -29,7 +35,9 @@ class TestWipeConfirmation:
def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role): def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role):
as_role('admin') as_role('admin')
response = client.post( response = client.post(
'/admin/teams/wipe', data={'confirm': 'yes'}, follow_redirects=True, '/admin/teams/wipe',
data={'confirm': 'yes'},
follow_redirects=True,
) )
html = response.data.decode() html = response.data.decode()
assert 'WIPE' in html assert 'WIPE' in html
@@ -116,4 +124,4 @@ class TestWipeDataRemoval:
with app.app_context(): with app.app_context():
entry = AuditLog.query.filter_by(action='teams_wiped').first() entry = AuditLog.query.filter_by(action='teams_wiped').first()
assert entry is not None assert entry is not None
+99
View File
@@ -0,0 +1,99 @@
"""Every JSON API endpoint works as intended."""
import json
def _json_endpoints(app):
for rule in app.url_map.iter_rules():
if rule.endpoint == 'static':
continue
view = app.view_functions.get(rule.endpoint)
if getattr(view, 'returns_json', False):
yield rule.endpoint, rule.rule, sorted(rule.methods - {'HEAD', 'OPTIONS'})
class TestJsonEndpoints:
"""All API endpoints are properly protected and return JSON."""
def test_all_json_endpoints_require_login(self, app, client):
errors = []
for endpoint, url, methods in _json_endpoints(app):
if '<' in url:
continue
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if endpoint == 'data_revision':
continue
if resp.status_code in (200, 201):
errors.append(
f'{method} {url} ({endpoint}): returned {resp.status_code} without auth'
)
elif resp.status_code not in (301, 302, 401, 403):
errors.append(f'{method} {url} ({endpoint}): unexpected {resp.status_code}')
assert not errors, '\n'.join(errors[:20])
def test_json_endpoints_return_json_content_type(self, app, client, as_role):
errors = []
as_role('admin')
for endpoint, url, methods in _json_endpoints(app):
if '<' in url:
continue
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if resp.status_code == 301:
continue
ct = resp.content_type or ''
if resp.status_code < 500 and 'application/json' not in ct:
errors.append(f'{method} {url} ({endpoint}): content_type={ct}')
assert not errors, '\n'.join(errors[:20])
def test_json_endpoints_return_reasonable_payload(self, app, client, as_role):
errors = []
as_role('admin')
for endpoint, url, methods in _json_endpoints(app):
if '<' in url:
continue
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if resp.status_code in (301, 302):
continue
if 'application/json' not in (resp.content_type or ''):
continue
try:
data = json.loads(resp.data)
except (ValueError, TypeError) as exc:
errors.append(f'{method} {url} ({endpoint}): unparseable JSON: {exc}')
continue
if not isinstance(data, (dict, list)):
errors.append(f'{method} {url} ({endpoint}): JSON is not a dict: {type(data)}')
assert not errors, '\n'.join(errors[:20])
def test_api_consistent_error_format(self, app, client):
errors = []
for _endpoint, url, methods in _json_endpoints(app):
if '<' not in url:
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if resp.status_code >= 400 and 'application/json' in (resp.content_type or ''):
try:
data = json.loads(resp.data)
except (ValueError, TypeError):
continue
if 'error' not in data:
errors.append(f'{method} {url}: 4xx JSON missing "error" key: {data}')
assert not errors, '\n'.join(errors[:20])
class TestAuthorizedApiAccess:
"""Each JSON endpoint only exposes data the user is allowed to see."""
def test_player_cannot_access_admin_json(self, app, client, as_role):
as_role('player')
admin_only = ['/matches/api/manageable-tryouts', '/team-matches/api/manageable-teams']
for url in admin_only:
resp = client.get(url, follow_redirects=False)
assert resp.status_code not in (500,), f'{url} returned {resp.status_code}'
+103 -3
View File
@@ -168,7 +168,7 @@ class TestTheWindow:
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00' '/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
).get_json() ).get_json()
assert _match_event(payload)['date'] == '2030-05-02' assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
def test_a_match_outside_the_window_is_not( def test_a_match_outside_the_window_is_not(
self, app, client, as_role, make_user, match_factory self, app, client, as_role, make_user, match_factory
@@ -192,7 +192,7 @@ class TestTheWindow:
payload = client.get('/matches/api/events').get_json() payload = client.get('/matches/api/events').get_json()
assert _match_event(payload)['date'] == '2030-05-02' assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
def test_unparseable_bounds_are_ignored_rather_than_rejected( def test_unparseable_bounds_are_ignored_rather_than_rejected(
self, app, client, as_role, make_user, match_factory self, app, client, as_role, make_user, match_factory
@@ -206,7 +206,7 @@ class TestTheWindow:
response = client.get('/matches/api/events?start=whenever&end=') response = client.get('/matches/api/events?start=whenever&end=')
assert response.status_code == 200 assert response.status_code == 200
assert _match_event(response.get_json())['date'] == '2030-05-02' assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
def test_the_viewers_own_participation_still_comes_back( def test_the_viewers_own_participation_still_comes_back(
self, app, client, as_role, make_user, match_factory self, app, client, as_role, make_user, match_factory
@@ -258,3 +258,103 @@ class TestTheWindowParser:
from flask import request from flask import request
assert calendar_window(request.args) == (None, None) assert calendar_window(request.args) == (None, None)
class TestTimedEvents:
"""Matches with time fields render on the clock, not as all-day."""
def test_timed_match_has_start_and_end(self, app, client, as_role, make_user, match_factory):
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id)
ev = _match_event(client.get('/matches/api/events').get_json())
assert ev['start'] == '2030-05-02T18:00:00'
assert ev['end'] == '2030-05-02T19:00:00'
assert ev['allDay'] is False
def test_allday_match_has_date_when_no_time(
self, app, client, as_role, make_user, match_factory
):
from datetime import date
from app.extensions import db
from app.models import Match, MatchParticipant, Tryout, TryoutRegistration
player_id = make_user('player')
admin_id = as_role('admin')
with app.app_context():
tryout = Tryout(
title='All-day tryout',
game='Valorant',
date=date(2030, 5, 1),
created_by=admin_id,
status='upcoming',
)
db.session.add(tryout)
db.session.flush()
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
match = Match(
tryout_id=tryout.id,
title='All-day scrim',
description=None,
date=date(2030, 5, 2),
start_time=None,
end_time=None,
match_type='player_scrim',
created_by=admin_id,
)
db.session.add(match)
db.session.flush()
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
db.session.commit()
payload = client.get('/matches/api/events').get_json()
ev = next(e for e in payload if e['title'] == 'All-day scrim')
assert ev['date'] == '2030-05-02'
assert ev['allDay'] is True
assert 'start' not in ev
class TestTeamMatchCalendarEvents:
"""Regular-season team matches appear on the main calendar."""
def test_team_matches_appear_for_admin(self, app, client, as_role, make_user):
from datetime import date, time
from app.extensions import db
from app.models import OrgTeam, TeamMatch
admin_id = as_role('admin')
with app.app_context():
team = OrgTeam(name='Test Org', created_by=admin_id)
db.session.add(team)
db.session.flush()
tm = TeamMatch(
org_team_id=team.id,
title='Semifinal',
opponent='Enemy Team',
date=date(2030, 6, 15),
start_time=time(20, 0),
end_time=time(21, 30),
location='Main arena',
status='scheduled',
created_by=admin_id,
)
db.session.add(tm)
db.session.commit()
payload = client.get('/matches/api/events').get_json()
team_events = [e for e in payload if e['type'] == 'team_match']
assert len(team_events) == 1
ev = team_events[0]
assert ev['title'] == 'Semifinal'
assert ev['start'] == '2030-06-15T20:00:00'
assert ev['allDay'] is False
assert ev['extendedProps']['org_team'] == 'Test Org'
assert ev['extendedProps']['opponent'] == 'Enemy Team'
+158
View File
@@ -0,0 +1,158 @@
"""Front-end controls: every form, button, and link that is rendered exists
and behaves correctly for each role.
Uses stdlib-only HTML parsing (html.parser).
"""
import os
from html.parser import HTMLParser
import pytest
_TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
)
class _FormCollector(HTMLParser):
"""Collect forms, submit buttons, and links that represent actions."""
def __init__(self):
super().__init__()
self.forms = [] # list of {method, action, id}
self.buttons = [] # list of {text, form_id, type}
self.links = [] # list of href
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'form':
self.forms.append(
{
'method': attrs.get('method', 'get').upper(),
'action': attrs.get('action', ''),
'id': attrs.get('id', ''),
}
)
elif tag == 'button':
self.buttons.append(
{
'type': attrs.get('type', 'submit'),
'form': attrs.get('form', ''),
'text': attrs.get('value', ''),
}
)
elif tag == 'input':
if attrs.get('type') in ('submit', 'button', 'image'):
self.buttons.append(
{
'type': attrs.get('type', 'submit'),
'form': attrs.get('form', ''),
'text': attrs.get('value', ''),
'name': attrs.get('name', ''),
}
)
elif tag == 'a' and 'href' in attrs:
href = attrs['href']
if href and not href.startswith('#') and not href.startswith('javascript:'):
self.links.append(href)
# ---------------------------------------------------------------------------
# Each page route renders correctly with form elements
# ---------------------------------------------------------------------------
_PAGE_LIST = [
'/',
'/auth/login',
'/teams',
'/matches/calendar',
'/users/profile',
'/users/profile/edit',
'/users/contracts',
'/tryouts',
'/evaluations',
]
@pytest.mark.parametrize('route', _PAGE_LIST)
def test_page_returns_ok(app, client, route):
"""Unauthenticated access should redirect to login for all but public pages."""
resp = client.get(route, follow_redirects=True)
# Unauthenticated access should either serve the page or redirect to login
assert resp.status_code in (200, 301, 302), f'{route} returned {resp.status_code}'
def test_login_form_submits_correctly(app, client, make_user):
"""The login form works end-to-end with valid credentials."""
user_id = make_user('player', password='Password123')
with app.app_context():
from app.extensions import db
from app.models import User
username = db.session.get(User, user_id).username
# GET the login page to ensure form is there
resp = client.get('/auth/login')
assert resp.status_code == 200
html = resp.data.decode('utf-8')
assert 'name="username"' in html
assert 'name="password"' in html
assert 'type="password"' in html
# POST valid credentials
resp = client.post(
'/auth/login',
data={
'username': username,
'password': 'Password123',
},
follow_redirects=False,
)
assert resp.status_code in (301, 302), f'Login did not redirect: {resp.status_code}'
def test_logout_form_works(app, client, as_role):
"""The logout form works for an authenticated user."""
as_role('player')
resp = client.post('/auth/logout', follow_redirects=False)
assert resp.status_code in (301, 302), f'Logout did not redirect: {resp.status_code}'
# ---------------------------------------------------------------------------
# Each page renders forms for the appropriate role
# ---------------------------------------------------------------------------
def test_register_form_has_required_fields(app, client):
"""The registration form shows required fields."""
resp = client.get('/auth/register')
assert resp.status_code == 200
html = resp.data.decode('utf-8')
for field in ('username', 'password', 'confirm_password', 'full_name', 'email'):
assert field in html, f'Registration form missing field: {field}'
def test_register_form_validates_password(app, client):
"""Registration rejects weak passwords."""
resp = client.post(
'/auth/register',
data={
'username': 'newuser',
'password': 'short',
'confirm_password': 'short',
'full_name': 'New User',
'email': '[email protected]',
},
follow_redirects=True,
)
# Should return the registration page with an error
assert resp.status_code == 200
assert b'password' in resp.data.lower() or b'short' in resp.data
def test_create_tryout_form_validates(app, client, as_role):
"""Admin can see the tryout creation form."""
as_role('admin')
resp = client.get('/tryouts/create', follow_redirects=True)
# Admin should be able to see the create page (but it might redirect to dashboard)
assert resp.status_code == 200
+133
View File
@@ -0,0 +1,133 @@
"""CSS/HTML coherence: every CSS class used in templates exists in style.css,
and every page renders with its core structural classes present.
Uses stdlib-only HTML parsing (html.parser) to keep the dependency surface
unchanged.
"""
import logging
import os
import re
from html.parser import HTMLParser
import pytest
_logger = logging.getLogger(__name__)
_TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
)
_CSS_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app',
'static',
'css',
'style.css',
)
# ---------------------------------------------------------------------------
# Phase 1: CSS class usage analysis
# ---------------------------------------------------------------------------
def _cached_lines():
"""Yield all CSS class selectors found in style.css."""
with open(_CSS_PATH, encoding='utf-8') as f:
content = f.read()
for match in re.finditer(r'(?<!\w)\.\w[a-zA-Z0-9_-]*', content):
yield match.group(0).lstrip('.')
def _cached_templates():
for root, _dirs, files in os.walk(_TEMPLATE_ROOT):
for name in files:
if name.endswith('.html'):
path = os.path.join(root, name)
with open(path, encoding='utf-8') as f:
yield path, f.read()
class _ClassNameCollector(HTMLParser):
def __init__(self):
super().__init__()
self.classes = set()
def handle_starttag(self, tag, attrs):
for name, value in attrs:
if name == 'class' and value and '{{' not in value and '{%' not in value:
self.classes.update(value.split())
def test_all_css_classes_in_html_have_a_matching_rule():
css_classes = set(_cached_lines())
missing = set()
for path, html in _cached_templates():
parser = _ClassNameCollector()
parser.feed(html)
for cls in parser.classes:
if cls not in css_classes and not cls.startswith(('fc-', 'fa-')) and cls:
missing.add(f'{os.path.relpath(path, _TEMPLATE_ROOT)}: .{cls}')
if len(missing) > 30:
_logger.warning(
'%d HTML classes missing from CSS (threshold=30):\n%s',
len(missing),
'\n'.join(sorted(missing)[:50]),
)
def test_all_css_rules_have_at_least_one_html_usage():
html_classes = set()
for _path, html in _cached_templates():
parser = _ClassNameCollector()
parser.feed(html)
html_classes.update(parser.classes)
known_unused = {'fc-today', 'fc-past', 'fc-future', 'fc-highlight', 'fc-daygrid', 'fc-timegrid'}
orphaned = set()
for cls in _cached_lines():
if cls.startswith(('fc-', 'theme-')) or cls in known_unused:
continue
if cls not in html_classes:
orphaned.add(cls)
assert len(orphaned) <= 40, (
f'{len(orphaned)} CSS rules have no HTML match: {sorted(orphaned)[:30]}'
)
# ---------------------------------------------------------------------------
# Phase 2: Per-role page rendering smoke tests
# ---------------------------------------------------------------------------
_PAGE_ROUTES = [
'/',
'/auth/login',
'/auth/logout',
'/teams',
'/matches/calendar',
'/users/profile',
'/users/profile/edit',
'/users/contracts',
'/tryouts',
]
_PAGE_ROUTES_BY_ROLE = {
'admin': _PAGE_ROUTES + ['/admin'],
'manager': _PAGE_ROUTES,
'coach': _PAGE_ROUTES + ['/users/notes-dashboard'],
'player': _PAGE_ROUTES + ['/users/one-on-one'],
'scout': _PAGE_ROUTES,
}
_CORE_CLASSES = {'sidebar', 'sidebar-header', 'logo', 'content', 'flash-messages'}
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
def test_page_renders_with_core_structure(app, client, as_role, role):
as_role(role)
for route in _PAGE_ROUTES_BY_ROLE.get(role, _PAGE_ROUTES):
resp = client.get(route, follow_redirects=True)
if resp.status_code == 200 and resp.content_type and 'text/html' in resp.content_type:
parser = _ClassNameCollector()
parser.feed(resp.data.decode('utf-8', errors='replace'))
missing = _CORE_CLASSES - parser.classes
if missing and route not in ('/auth/login', '/auth/logout'):
pytest.fail(f'Role={role}, route={route}: missing core classes: {missing}')
+15
View File
@@ -69,6 +69,21 @@ class TestNotifying:
assert [call['reference_id'] for call in sent] == [11, 22] assert [call['reference_id'] for call in sent] == [11, 22]
assert {call['event_date'] for call in sent} == {'2030-04-01'} assert {call['event_date'] for call in sent} == {'2030-04-01'}
assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'} assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'}
assert {call['change_kind'] for call in sent} == {'scheduled'}
def test_modified_kind_is_forwarded(self, sent):
"""A modified notification carries 'modified' to the bot."""
scheduling.notify_participants(
title='Scrim',
date=date(2030, 4, 1),
start_time=time(19, 0),
end_time=time(21, 0),
participants=[(1, 11)],
fallback_id=99,
change_kind='modified',
)
assert sent[0]['change_kind'] == 'modified'
def test_a_participant_without_a_row_falls_back_to_the_match(self, sent): def test_a_participant_without_a_row_falls_back_to_the_match(self, sent):
"""A Discord reaction resolves the reference back to a record; the """A Discord reaction resolves the reference back to a record; the