Compare commits
29
Commits
06508cc7d6
...
cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53bc3b80d | ||
|
|
101e3ae880 | ||
|
|
b9791d1547 | ||
|
|
4a2e2f053f | ||
|
|
e2e2b1b07e | ||
|
|
e4a271cfd8 | ||
|
|
742995d3cd | ||
|
|
cf551867c9 | ||
|
|
b43c5b1734 | ||
|
|
85eca9edd4 | ||
|
|
c7b321a636 | ||
|
|
90c00ad29b | ||
|
|
bfb25f4d16 | ||
|
|
820cccb25e | ||
|
|
38defc53a5 | ||
|
|
d18979a3e9 | ||
|
|
1549fbaef3 | ||
|
|
c72ed9b0b1 | ||
|
|
6232b77094 | ||
|
|
a616c79663 | ||
|
|
48ca62cdd0 | ||
|
|
1bef716a12 | ||
|
|
a0e31d1a2f | ||
|
|
e15b3c1293 | ||
|
|
d7a8907953 | ||
|
|
105a72700f | ||
|
|
f84cb4e3b6 | ||
|
|
9fc4ba0b98 | ||
|
|
53e390672e |
@@ -0,0 +1,45 @@
|
||||
name: CI - Security, Lint & Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# This workflow validates branches only. It has no deployment step and no
|
||||
# write permission, so an audit-branch push cannot alter main or production.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
- name: Audit declared dependencies
|
||||
run: pip-audit -r requirements.txt
|
||||
|
||||
- name: Lint and check formatting
|
||||
run: |
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
|
||||
- name: Run tests with coverage gate
|
||||
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||
|
||||
- name: Run repository security checks
|
||||
env:
|
||||
SECRET_KEY: audit-ci-key-not-for-production-1234567890
|
||||
DATABASE_URL: 'sqlite:///:memory:'
|
||||
FLASK_DEBUG: 'false'
|
||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||
@@ -2,7 +2,7 @@ name: CI - Security & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main, master, 'audit/**']
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch: # Allow manual triggers
|
||||
@@ -94,6 +94,7 @@ jobs:
|
||||
- name: Run security scan
|
||||
env:
|
||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||
DATABASE_URL: 'sqlite:///:memory:'
|
||||
FLASK_DEBUG: 'false'
|
||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ coverage.xml
|
||||
# =============================================================================
|
||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||
# voir l'historique du commit `4fd27ba`).
|
||||
# Données PostgreSQL locales — gérées par app/local_postgres.py
|
||||
instance/pgdata/
|
||||
instance/pg.log
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
+29
-9
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||
|
||||
# =============================================================================
|
||||
# Optional — storage
|
||||
# Contract storage — Google Drive
|
||||
# =============================================================================
|
||||
|
||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
||||
# to a release-directory layout, or a deployment will take the documents with
|
||||
# it (OPS-011, app/storage.py).
|
||||
#
|
||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
||||
# did not, and archived `./documents` regardless — so setting this here and
|
||||
# nowhere else produced empty contract backups that still exited 0.
|
||||
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
|
||||
# refresh token, and folder ID are secrets/configuration: do not commit them.
|
||||
# Use `local` only for isolated development and legacy-file maintenance.
|
||||
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||
|
||||
# Target folder in the owner's personal Drive. The application keeps every
|
||||
# contract directly in this folder and stores only each Drive file ID in the
|
||||
# database.
|
||||
GOOGLE_DRIVE_FOLDER_ID=
|
||||
|
||||
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
|
||||
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
|
||||
# refresh token here. See docs/deployment.md before enabling this in production.
|
||||
GOOGLE_DRIVE_CLIENT_ID=
|
||||
GOOGLE_DRIVE_CLIENT_SECRET=
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN=
|
||||
|
||||
# Local storage is retained only for historical rows and local test runs.
|
||||
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
|
||||
DOCUMENTS_ROOT=
|
||||
|
||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
||||
@@ -140,5 +151,14 @@ AUTO_CREATE_TABLES=true
|
||||
# FORCE_HTTPS=false no redirect to HTTPS
|
||||
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||
#
|
||||
# LOCAL_PG=true start a local PostgreSQL server (requires PostgreSQL
|
||||
# installed on the system). Uses port 5433,
|
||||
# data in instance/pgdata/, database team_tryouts.
|
||||
#
|
||||
# LOCAL_PG_PORT=5433 override the PostgreSQL port
|
||||
# LOCAL_PG_USER=team_tryouts_db override the PostgreSQL user
|
||||
# LOCAL_PG_DATABASE=team_tryouts_db override the database name
|
||||
#
|
||||
#
|
||||
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||
# development session cannot accidentally inherit a production binding.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
+54
-2
@@ -26,8 +26,10 @@ from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from app import i18n
|
||||
from app.api import json_endpoint
|
||||
from app.extensions import babel, csrf, db, limiter, login_manager
|
||||
from app.pagination import page_url
|
||||
from app.responders import current_revision, is_ajax_submit
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -347,6 +349,42 @@ def create_app(config=None):
|
||||
response.headers['X-Request-Id'] = g.get('request_id', '-')
|
||||
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
|
||||
def version_static_urls(endpoint, values):
|
||||
"""Stamp every static URL with the file's modification time.
|
||||
@@ -403,6 +441,7 @@ def create_app(config=None):
|
||||
|
||||
configure_logging(app)
|
||||
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.evaluations import evaluations_bp
|
||||
from app.routes.main import main_bp
|
||||
@@ -420,6 +459,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
app.register_blueprint(team_matches_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
@@ -510,7 +550,7 @@ def create_app(config=None):
|
||||
# not down, and a 503 here would take the site out of the load
|
||||
# balancer for it (OPS-012).
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
from app.discord_bot import bot_status
|
||||
from app.discord import bot_status
|
||||
|
||||
health_data['discord_bot'] = bot_status()
|
||||
|
||||
@@ -529,6 +569,18 @@ def create_app(config=None):
|
||||
|
||||
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
|
||||
# =========================================================================
|
||||
@@ -698,7 +750,7 @@ def create_app(config=None):
|
||||
# Start the Discord bot for notifications
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
from app.discord import start_bot
|
||||
|
||||
start_bot(flask_app=app)
|
||||
except Exception: # the site must come up even if the bot cannot
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Discord notification subsystem.
|
||||
|
||||
Split from a single 1654-line module (app/discord_bot.py) into a package with
|
||||
one concern per module (ARCH-003).
|
||||
|
||||
Public API — these are the functions that Flask routes and services import:
|
||||
start_bot, bot_status, get_bot → .bot
|
||||
send_one_on_one_dm, send_schedule_notification,
|
||||
send_one_on_one_response → .messaging
|
||||
purge_pending_for_participants → .pending
|
||||
"""
|
||||
|
||||
from app.discord.bot import bot_status, get_bot, start_bot
|
||||
from app.discord.messaging import (
|
||||
send_one_on_one_dm,
|
||||
send_one_on_one_response,
|
||||
send_schedule_notification,
|
||||
)
|
||||
from app.discord.pending import purge_pending_for_participants
|
||||
|
||||
__all__ = [
|
||||
'bot_status',
|
||||
'get_bot',
|
||||
'purge_pending_for_participants',
|
||||
'send_one_on_one_dm',
|
||||
'send_one_on_one_response',
|
||||
'send_schedule_notification',
|
||||
'start_bot',
|
||||
]
|
||||
@@ -0,0 +1,702 @@
|
||||
"""Discord bot lifecycle, class definition, and supervisor."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.discord.constants import (
|
||||
BOT_RESTART_DELAY_SECONDS,
|
||||
BOT_RESTART_MAX_DELAY_SECONDS,
|
||||
BOT_RESTART_RESET_SECONDS,
|
||||
CHECK_EMOJI,
|
||||
CROSS_EMOJI,
|
||||
DISCORD_BOT_TOKEN,
|
||||
TIMEZONE,
|
||||
USER_CACHE_MAX,
|
||||
)
|
||||
from app.discord.pending import (
|
||||
forget_pending,
|
||||
load_pending,
|
||||
save_pending,
|
||||
)
|
||||
from app.discord.queue import message_queue
|
||||
from app.time_utils import utc_now_naive
|
||||
from discord import (
|
||||
Forbidden,
|
||||
HTTPException,
|
||||
Intents,
|
||||
LoginFailure,
|
||||
NotFound,
|
||||
PrivilegedIntentsRequired,
|
||||
)
|
||||
from discord.ext import commands
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
bot_instance = None
|
||||
bot_thread = None
|
||||
bot_flask_app = None
|
||||
_stop_bot = threading.Event()
|
||||
|
||||
|
||||
class TeamTryoutsBot(commands.Bot):
|
||||
"""Unified Discord bot for Team Tryouts notifications."""
|
||||
|
||||
def __init__(self, flask_app=None):
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
intents.dm_messages = True
|
||||
intents.dm_reactions = True
|
||||
intents.reactions = True
|
||||
intents.guilds = True
|
||||
super().__init__(command_prefix='!', intents=intents)
|
||||
self.flask_app = flask_app
|
||||
self.pending_requests = load_pending()
|
||||
self.message_queue = message_queue
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.timezone = ZoneInfo(TIMEZONE)
|
||||
self._user_cache = {}
|
||||
self.reminders_scheduled = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle hooks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def setup_hook(self):
|
||||
self.loop.create_task(self.process_queue())
|
||||
|
||||
async def on_error(self, event_method, *args, **kwargs):
|
||||
_logger.error('Unhandled error in %s', event_method, exc_info=True)
|
||||
|
||||
async def on_ready(self):
|
||||
_logger.info('Bot logged in as %s', self.user)
|
||||
await self.start_scheduler()
|
||||
|
||||
async def start_scheduler(self):
|
||||
if self.reminders_scheduled:
|
||||
_logger.debug('Reminders already scheduled, skipping')
|
||||
return
|
||||
try:
|
||||
self.scheduler.add_job(
|
||||
self._send_daily_reminders_impl,
|
||||
CronTrigger(hour=18, minute=0, timezone=self.timezone),
|
||||
id='daily_match_check',
|
||||
replace_existing=True,
|
||||
)
|
||||
self.scheduler.start()
|
||||
self.reminders_scheduled = True
|
||||
_logger.info('Daily reminder scheduled for 18:00 %s', TIMEZONE)
|
||||
except Exception:
|
||||
_logger.exception('Failed to start the daily reminder scheduler')
|
||||
self.reminders_scheduled = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _reply(self, channel, message):
|
||||
try:
|
||||
await channel.send(message)
|
||||
except (HTTPException, Forbidden) as exc:
|
||||
_logger.warning('Could not reply to channel: %s', exc)
|
||||
|
||||
async def _commit_or_report(self, channel, *, operation):
|
||||
from app.extensions import db
|
||||
|
||||
try:
|
||||
operation()
|
||||
db.session.commit()
|
||||
except SQLAlchemyError:
|
||||
db.session.rollback()
|
||||
_logger.exception('DB commit failed for %s', operation.__name__)
|
||||
await self._reply(channel, 'Something went wrong saving that. Please try again.')
|
||||
|
||||
def _forget_pending(self, message_id):
|
||||
self.pending_requests = forget_pending(self.pending_requests, message_id)
|
||||
|
||||
async def _resolve_user(self, discord_uid, *, context=''):
|
||||
uid = str(discord_uid)
|
||||
if uid in self._user_cache:
|
||||
return self._user_cache[uid]
|
||||
try:
|
||||
user = await self.fetch_user(int(uid))
|
||||
except (NotFound, HTTPException) as exc:
|
||||
_logger.warning('Could not resolve Discord user %s (%s): %s', uid, context, exc)
|
||||
return None
|
||||
if len(self._user_cache) >= USER_CACHE_MAX:
|
||||
self._user_cache.pop(next(iter(self._user_cache)))
|
||||
self._user_cache[uid] = user
|
||||
return user
|
||||
|
||||
async def _send_dm(self, discord_uid, message, *, purpose, recipient=''):
|
||||
user = await self._resolve_user(discord_uid, context=purpose)
|
||||
if not user:
|
||||
_logger.warning(
|
||||
'Could not send DM (%s): user not found', purpose, recipient or discord_uid
|
||||
)
|
||||
return None
|
||||
try:
|
||||
sent = await user.send(message)
|
||||
_logger.info(
|
||||
'DM sent (%s) to %s (%s)', purpose, recipient or user.display_name, discord_uid
|
||||
)
|
||||
return sent
|
||||
except Forbidden:
|
||||
_logger.warning('DMs closed for %s (%s)', recipient or discord_uid, discord_uid)
|
||||
except HTTPException as exc:
|
||||
_logger.warning('Failed to DM %s: %s', discord_uid, exc)
|
||||
except Exception:
|
||||
_logger.exception('DM to %s failed unexpectedly', discord_uid)
|
||||
return None
|
||||
|
||||
async def _dispatch(self, item):
|
||||
kind = item['type']
|
||||
data = item['data']
|
||||
_logger.debug('Dispatching %s', kind)
|
||||
try:
|
||||
if kind == 'one_on_one_request':
|
||||
await self._send_one_on_one_dm(**data)
|
||||
elif kind == 'schedule_addition':
|
||||
await self._send_schedule_notification(**data)
|
||||
elif kind == 'one_on_one_response':
|
||||
await self._send_one_on_one_response_dm(**data)
|
||||
elif kind == 'purge_pending':
|
||||
pids = data.get('participant_ids', [])
|
||||
self.pending_requests = {
|
||||
mid: e
|
||||
for mid, e in self.pending_requests.items()
|
||||
if e.get('type') != 'attendance' or e.get('reference_id') not in pids
|
||||
}
|
||||
save_pending(self.pending_requests)
|
||||
else:
|
||||
_logger.warning('Unknown queue item type: %s', kind)
|
||||
except Exception:
|
||||
_logger.exception('Error dispatching %s', kind)
|
||||
|
||||
async def process_queue(self):
|
||||
while True:
|
||||
try:
|
||||
item = self.message_queue.get(timeout=5)
|
||||
except __import__('queue').Empty:
|
||||
continue
|
||||
await self._dispatch(item)
|
||||
|
||||
def is_dm_channel(self, channel) -> bool:
|
||||
return channel.type.name == 'private'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reaction handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def on_raw_reaction_add(self, payload):
|
||||
if payload.user_id == self.user.id:
|
||||
return
|
||||
message_id = payload.message_id
|
||||
entry = self.pending_requests.get(str(message_id))
|
||||
if not entry:
|
||||
return
|
||||
channel = self.get_channel(payload.channel_id)
|
||||
if not channel:
|
||||
return
|
||||
reacting_user = payload.member or await self._resolve_user(
|
||||
payload.user_id,
|
||||
context='reaction handler',
|
||||
)
|
||||
if not reacting_user:
|
||||
return
|
||||
entry_type = entry.get('type')
|
||||
reference_id = entry.get('reference_id')
|
||||
emoji = str(payload.emoji)
|
||||
if emoji == CHECK_EMOJI:
|
||||
if entry_type == 'one_on_one':
|
||||
await self.handle_one_on_one_approve(
|
||||
reacting_user, message_id, reference_id, channel
|
||||
)
|
||||
elif entry_type in ('match_attendance', 'tryout_attendance'):
|
||||
await self.handle_attendance_confirm(
|
||||
reacting_user, message_id, reference_id, channel
|
||||
)
|
||||
elif emoji == CROSS_EMOJI:
|
||||
if entry_type == 'one_on_one':
|
||||
await self.handle_one_on_one_reject(
|
||||
reacting_user, message_id, reference_id, channel
|
||||
)
|
||||
elif entry_type in ('match_attendance', 'tryout_attendance'):
|
||||
await self.handle_attendance_decline(
|
||||
reacting_user, message_id, reference_id, channel
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# One-on-One handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_one_on_one_dm(
|
||||
self,
|
||||
coach_name='',
|
||||
coach_discord_id='',
|
||||
player_name='',
|
||||
team_name='',
|
||||
date_str='',
|
||||
start_time='',
|
||||
end_time='',
|
||||
points='',
|
||||
request_id=None,
|
||||
):
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': 'One on One Request',
|
||||
'color': 3447003,
|
||||
'fields': [
|
||||
{'name': 'Player', 'value': player_name, 'inline': True},
|
||||
{'name': 'Team', 'value': team_name or 'Unknown', 'inline': True},
|
||||
{'name': 'Date', 'value': date_str, 'inline': True},
|
||||
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
|
||||
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
|
||||
{
|
||||
'name': 'Quick Reply',
|
||||
'value': f'React with {CHECK_EMOJI} to approve, {CROSS_EMOJI} to reject.',
|
||||
'inline': False,
|
||||
},
|
||||
],
|
||||
'footer': {'text': f'Request #{request_id}'},
|
||||
}
|
||||
]
|
||||
}
|
||||
sent = await self._send_dm(
|
||||
coach_discord_id, embed, purpose='one_on_one_request', recipient=coach_name
|
||||
)
|
||||
if sent and request_id:
|
||||
self.pending_requests[str(sent.id)] = {
|
||||
'type': 'one_on_one',
|
||||
'reference_id': request_id,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
save_pending(self.pending_requests)
|
||||
|
||||
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
|
||||
from app.extensions import db
|
||||
from app.models import OneOnOneRequest, User
|
||||
|
||||
req = db.session.get(OneOnOneRequest, request_id)
|
||||
if not req:
|
||||
await self._reply(channel, 'This request no longer exists.')
|
||||
self._forget_pending(message_id)
|
||||
return
|
||||
if str(coach.id) != str(req.coach.discord_user_id):
|
||||
await self._reply(channel, 'This request is not for you.')
|
||||
return
|
||||
if req.status != 'pending':
|
||||
await self._reply(channel, 'This request has already been handled.')
|
||||
self._forget_pending(message_id)
|
||||
return
|
||||
|
||||
async def _approve():
|
||||
req.status = 'approved'
|
||||
req.responded_at = utc_now_naive()
|
||||
|
||||
await self._commit_or_report(channel, operation=_approve)
|
||||
await self._reply(channel, f'One on One request from {req.player.full_name} approved!')
|
||||
self._forget_pending(message_id)
|
||||
player = db.session.get(User, req.player_id)
|
||||
if player and player.discord_user_id:
|
||||
await self._send_one_on_one_response_dm(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=getattr(coach, 'display_name', 'Coach'),
|
||||
date_str=req.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
|
||||
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
|
||||
points=req.points or 'No specific points provided',
|
||||
approved=True,
|
||||
)
|
||||
|
||||
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
|
||||
from app.extensions import db
|
||||
from app.models import OneOnOneRequest, User
|
||||
|
||||
req = db.session.get(OneOnOneRequest, request_id)
|
||||
if not req:
|
||||
await self._reply(channel, 'This request no longer exists.')
|
||||
self._forget_pending(message_id)
|
||||
return
|
||||
if str(coach.id) != str(req.coach.discord_user_id):
|
||||
await self._reply(channel, 'This request is not for you.')
|
||||
return
|
||||
if req.status != 'pending':
|
||||
await self._reply(channel, 'This request has already been handled.')
|
||||
self._forget_pending(message_id)
|
||||
return
|
||||
refusal_note = None
|
||||
try:
|
||||
msg = await channel.fetch_message(message_id)
|
||||
if msg and msg.reference and msg.reference.resolved:
|
||||
reply_to = msg.reference.resolved
|
||||
if reply_to.author.id == coach.id:
|
||||
refusal_note = reply_to.content if isinstance(reply_to.content, str) else None
|
||||
except (NotFound, HTTPException) as exc:
|
||||
_logger.warning('Could not read rejection reply: %s', exc)
|
||||
|
||||
async def _reject():
|
||||
req.status = 'rejected'
|
||||
req.responded_at = utc_now_naive()
|
||||
if refusal_note:
|
||||
req.coach_rejection_message = refusal_note
|
||||
|
||||
await self._commit_or_report(channel, operation=_reject)
|
||||
await self._reply(channel, f'One on One request from {req.player.full_name} rejected.')
|
||||
self._forget_pending(message_id)
|
||||
player = db.session.get(User, req.player_id)
|
||||
if player and player.discord_user_id:
|
||||
await self._send_one_on_one_response_dm(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=getattr(coach, 'display_name', 'Coach'),
|
||||
date_str=req.date.strftime('%A, %B %d, %Y'),
|
||||
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
|
||||
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
|
||||
points=req.points or 'No specific points provided',
|
||||
approved=False,
|
||||
refusal_note=refusal_note,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attendance handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _attendance_record(event_type, reference_id):
|
||||
from app.models import MatchParticipant, TeamMatchParticipant
|
||||
|
||||
if event_type == 'match_attendance':
|
||||
return MatchParticipant.query.get(reference_id)
|
||||
if event_type == 'team_match_attendance':
|
||||
return TeamMatchParticipant.query.get(reference_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _reacting_user_owns(player_id, reacting_user):
|
||||
from app.models import User
|
||||
|
||||
player = User.query.get(player_id)
|
||||
if not player:
|
||||
return False
|
||||
return str(player.discord_user_id) == str(reacting_user.id)
|
||||
|
||||
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
||||
entry = self.pending_requests.get(str(message_id))
|
||||
if not entry:
|
||||
return
|
||||
event_type = entry.get('type', '')
|
||||
record = self._attendance_record(event_type, reference_id)
|
||||
if not record:
|
||||
await self._reply(channel, 'This event no longer exists.')
|
||||
self._forget_pending(message_id)
|
||||
return
|
||||
if not self._reacting_user_owns(record.player_id, player):
|
||||
await self._reply(channel, 'You cannot confirm attendance for someone else.')
|
||||
return
|
||||
|
||||
async def _confirm():
|
||||
record.attendance_confirmed = True
|
||||
record.is_confirmed = True
|
||||
|
||||
await self._commit_or_report(channel, operation=_confirm)
|
||||
await self._reply(channel, 'Attendance confirmed! ✅')
|
||||
self._forget_pending(message_id)
|
||||
|
||||
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
|
||||
entry = self.pending_requests.get(str(message_id))
|
||||
if not entry:
|
||||
return
|
||||
event_type = entry.get('type', '')
|
||||
record = self._attendance_record(event_type, reference_id)
|
||||
if not record:
|
||||
await self._reply(channel, 'This event no longer exists.')
|
||||
self._forget_pending(message_id)
|
||||
return
|
||||
if not self._reacting_user_owns(record.player_id, player):
|
||||
await self._reply(channel, 'You cannot decline attendance for someone else.')
|
||||
return
|
||||
|
||||
async def _decline():
|
||||
record.attendance_confirmed = False
|
||||
record.is_confirmed = False
|
||||
|
||||
await self._commit_or_report(channel, operation=_decline)
|
||||
await self._reply(channel, 'Attendance declined. ❌')
|
||||
self._forget_pending(message_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schedule notification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_schedule_notification(
|
||||
self,
|
||||
user_id=None,
|
||||
event_type='match',
|
||||
event_title='',
|
||||
event_date='',
|
||||
event_time='',
|
||||
reference_id=None,
|
||||
change_kind='scheduled',
|
||||
):
|
||||
from app.models import User
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if not user or not user.discord_user_id:
|
||||
_logger.warning('Cannot notify user %s: no Discord user id', user_id)
|
||||
return
|
||||
title = '🎯 Match Updated' if change_kind == 'modified' else '🎯 New Match Scheduled'
|
||||
desc = f'**{event_title}**\n📅 {event_date}\n⏰ {event_time}'
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': title,
|
||||
'color': 7506394 if change_kind == 'modified' else 3447003,
|
||||
'description': desc,
|
||||
'fields': [
|
||||
{
|
||||
'name': 'Quick RSVP',
|
||||
'value': f'React with {CHECK_EMOJI} to confirm, {CROSS_EMOJI} to decline.',
|
||||
'inline': False,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
sent = await self._send_dm(
|
||||
user.discord_user_id,
|
||||
embed,
|
||||
purpose=f'{change_kind}_{event_type}',
|
||||
recipient=user.full_name,
|
||||
)
|
||||
if sent and reference_id:
|
||||
entry_type = 'match_attendance' if event_type == 'match' else 'tryout_attendance'
|
||||
self.pending_requests[str(sent.id)] = {
|
||||
'type': entry_type,
|
||||
'reference_id': reference_id,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
save_pending(self.pending_requests)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# One-on-One response DM
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_one_on_one_response_dm(
|
||||
self,
|
||||
player_discord_id='',
|
||||
player_full_name='',
|
||||
coach_full_name='',
|
||||
date_str='',
|
||||
start_time='',
|
||||
end_time='',
|
||||
points='',
|
||||
approved=True,
|
||||
refusal_note=None,
|
||||
):
|
||||
status = '✅ Approved' if approved else '❌ Rejected'
|
||||
color = 3066993 if approved else 15158332
|
||||
desc = f'Your One on One request has been **{"approved" if approved else "rejected"}**.'
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': f'One on One {status}',
|
||||
'color': color,
|
||||
'description': desc,
|
||||
'fields': [
|
||||
{'name': 'Coach', 'value': coach_full_name, 'inline': True},
|
||||
{'name': 'Date', 'value': date_str, 'inline': True},
|
||||
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
|
||||
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
if refusal_note:
|
||||
embed['embeds'][0]['fields'].append(
|
||||
{'name': 'Reason', 'value': refusal_note, 'inline': False}
|
||||
)
|
||||
await self._send_dm(
|
||||
player_discord_id, embed, purpose='one_on_one_response', recipient=player_full_name
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Daily reminders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_daily_reminders(self):
|
||||
"""Public entry point, called from tests."""
|
||||
await self._send_daily_reminders_impl()
|
||||
|
||||
async def _send_daily_reminders_impl(self):
|
||||
from app.extensions import db
|
||||
from app.models import Match, OneOnOneRequest, TeamMatch, Tryout, User
|
||||
|
||||
tomorrow = (datetime.now(self.timezone) + timedelta(days=1)).date()
|
||||
_logger.info('Running daily reminder check for %s', tomorrow)
|
||||
for match in Match.query.filter(Match.date == tomorrow).all():
|
||||
for participant in match.participants:
|
||||
await self.send_match_reminder(participant.player, match)
|
||||
for tm in TeamMatch.query.filter(TeamMatch.date == tomorrow).all():
|
||||
for participant in tm.participants:
|
||||
await self.send_match_reminder(participant.player, tm)
|
||||
for tryout in Tryout.query.all():
|
||||
if any(m.date == tomorrow for m in tryout.matches):
|
||||
for reg in tryout.registrations:
|
||||
await self.send_tryout_reminder(reg.player, tryout)
|
||||
sessions = OneOnOneRequest.query.filter(
|
||||
OneOnOneRequest.status == 'pending', OneOnOneRequest.date == tomorrow
|
||||
).all()
|
||||
for session in sessions:
|
||||
player = db.session.get(User, session.player_id)
|
||||
await self.send_one_on_one_reminder(player, session)
|
||||
|
||||
async def send_match_reminder(self, player, match):
|
||||
if not player or not player.discord_user_id:
|
||||
return
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': '⏰ Match Tomorrow!',
|
||||
'color': 15844367,
|
||||
'description': f'**{match.title}**\n📅 {match.date.strftime("%A, %B %d, %Y")}\n⏰ {match.start_time.strftime("%I:%M %p") if match.start_time else "TBD"}',
|
||||
}
|
||||
]
|
||||
}
|
||||
await self._send_dm(
|
||||
player.discord_user_id, embed, purpose='match_reminder', recipient=player.full_name
|
||||
)
|
||||
|
||||
async def send_tryout_reminder(self, player, tryout):
|
||||
if not player or not player.discord_user_id:
|
||||
return
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': '⏰ Tryout Tomorrow!',
|
||||
'color': 15844367,
|
||||
'description': f'**{tryout.name}**\n📅 {tryout.date.strftime("%A, %B %d, %Y")}\n⏰ {tryout.time.strftime("%I:%M %p") if tryout.time else "TBD"}',
|
||||
}
|
||||
]
|
||||
}
|
||||
await self._send_dm(
|
||||
player.discord_user_id, embed, purpose='tryout_reminder', recipient=player.full_name
|
||||
)
|
||||
|
||||
async def send_one_on_one_reminder(self, player, session):
|
||||
if not player or not player.discord_user_id:
|
||||
return
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': '⏰ One on One Tomorrow!',
|
||||
'color': 15844367,
|
||||
'description': f'📅 {session.date.strftime("%A, %B %d, %Y")}\n⏰ {session.start_time.strftime("%I:%M %p") if session.start_time else "TBD"}',
|
||||
}
|
||||
]
|
||||
}
|
||||
await self._send_dm(
|
||||
player.discord_user_id, embed, purpose='one_on_one_reminder', recipient=player.full_name
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Legacy alias
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def notify_player_about_one_on_one_direct(
|
||||
self, player, coach, coach_name, coach_discord_id, date_str, start_time, end_time, points
|
||||
):
|
||||
embed = {
|
||||
'embeds': [
|
||||
{
|
||||
'title': 'One on One Scheduled',
|
||||
'color': 3447003,
|
||||
'fields': [
|
||||
{'name': 'Coach', 'value': coach_name, 'inline': True},
|
||||
{'name': 'Date', 'value': date_str, 'inline': True},
|
||||
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
|
||||
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
await self._send_dm(
|
||||
player.discord_user_id, embed, purpose='one_on_one_direct', recipient=player.full_name
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Module-level API
|
||||
# ======================================================================
|
||||
|
||||
|
||||
def get_bot(flask_app=None):
|
||||
global bot_instance, bot_flask_app
|
||||
if flask_app is not None:
|
||||
bot_flask_app = flask_app
|
||||
if bot_instance is None:
|
||||
bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_flask_app)
|
||||
elif flask_app is not None and bot_instance.flask_app is None:
|
||||
bot_instance.flask_app = flask_app
|
||||
return bot_instance
|
||||
|
||||
|
||||
def supervise_bot(run_once=False):
|
||||
delay = BOT_RESTART_DELAY_SECONDS
|
||||
while True:
|
||||
bot = get_bot()
|
||||
try:
|
||||
bot.run(DISCORD_BOT_TOKEN, reconnect=True)
|
||||
except LoginFailure:
|
||||
_logger.critical('Discord bot token is invalid. Bot will not start.')
|
||||
return
|
||||
except PrivilegedIntentsRequired as exc:
|
||||
_logger.critical('Missing privileged intent: %s. Bot will not start.', exc)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 — supervisor boundary, restart on any failure
|
||||
_logger.error('Discord bot stopped unexpectedly: %s', exc)
|
||||
if run_once:
|
||||
_logger.info('Bot run_once completed.')
|
||||
return
|
||||
bot_age = time.time() - getattr(bot, '_started_at', time.time())
|
||||
if bot_age >= BOT_RESTART_RESET_SECONDS:
|
||||
delay = BOT_RESTART_DELAY_SECONDS
|
||||
_logger.warning('Discord bot disconnected. Restarting in %s seconds.', delay)
|
||||
if _stop_bot.wait(delay):
|
||||
_logger.info('Discord bot supervisor asked to stop.')
|
||||
return
|
||||
delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS)
|
||||
|
||||
|
||||
def start_bot(flask_app=None):
|
||||
global bot_thread, bot_flask_app
|
||||
if flask_app is not None:
|
||||
bot_flask_app = flask_app
|
||||
get_bot(flask_app=flask_app)
|
||||
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||||
_stop_bot.clear()
|
||||
bot_thread = threading.Thread(target=supervise_bot, daemon=True)
|
||||
bot_thread.start()
|
||||
_logger.info('TeamTryoutsBot started in background thread')
|
||||
elif not DISCORD_BOT_TOKEN:
|
||||
_logger.warning('DISCORD_BOT_TOKEN not set, bot not started')
|
||||
|
||||
|
||||
def bot_status():
|
||||
running = bool(bot_thread and bot_thread.is_alive())
|
||||
instance = bot_instance
|
||||
connected = bool(instance and not instance.is_closed() and instance.is_ready())
|
||||
return {
|
||||
'configured': bool(DISCORD_BOT_TOKEN),
|
||||
'running': running,
|
||||
'connected': connected,
|
||||
'pending': len(instance.pending_requests) if instance else 0,
|
||||
'reminders_scheduled': bool(instance and instance.reminders_scheduled),
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
"""Constants used across the Discord notification subsystem."""
|
||||
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token
|
||||
# ---------------------------------------------------------------------------
|
||||
#: Discord bot token. Loaded once at import time so the bot can decide whether
|
||||
#: to start before the application factory runs.
|
||||
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emoji
|
||||
# ---------------------------------------------------------------------------
|
||||
CHECK_EMOJI = '✅' # Green checkmark
|
||||
CROSS_EMOJI = '❌' # Red X
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timing
|
||||
# ---------------------------------------------------------------------------
|
||||
#: Entries older than this are dropped when the pending file is loaded.
|
||||
PENDING_MAX_AGE_DAYS = 30
|
||||
|
||||
#: How late the daily reminder may still be sent.
|
||||
REMINDER_GRACE_SECONDS = 3600
|
||||
|
||||
#: First wait before restarting a bot that stopped (OPS-004).
|
||||
BOT_RESTART_DELAY_SECONDS = 5
|
||||
|
||||
#: Ceiling on that wait.
|
||||
BOT_RESTART_MAX_DELAY_SECONDS = 300
|
||||
|
||||
#: A connection that lasted this long resets the back-off.
|
||||
BOT_RESTART_RESET_SECONDS = 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caching
|
||||
# ---------------------------------------------------------------------------
|
||||
#: How many Discord user objects to keep resolved (PERF-005).
|
||||
USER_CACHE_MAX = 512
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timezone
|
||||
# ---------------------------------------------------------------------------
|
||||
TIMEZONE = 'America/Toronto'
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Public API wrappers that enqueue notifications for the bot thread.
|
||||
|
||||
These are the functions that Flask routes and services import. They run in the
|
||||
request thread and hand the actual Discord work to the bot's async loop via the
|
||||
shared queue.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.discord.queue import enqueue
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_one_on_one_dm(
|
||||
coach_name: str,
|
||||
coach_discord_id: str,
|
||||
player_name: str,
|
||||
team_name: str,
|
||||
date_str: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
points: str,
|
||||
request_id: int,
|
||||
) -> bool:
|
||||
"""Queue a One on One request DM to be sent by the bot.
|
||||
|
||||
Args:
|
||||
coach_name: Display name of the coach.
|
||||
coach_discord_id: Discord snowflake of the coach.
|
||||
player_name: Display name of the requesting player.
|
||||
team_name: The player's team, or None/empty.
|
||||
date_str, start_time, end_time: Already formatted for display.
|
||||
points: Free-text discussion points.
|
||||
request_id: OneOnOnRequest primary key for reaction routing.
|
||||
|
||||
Returns:
|
||||
True if queued, False on failure.
|
||||
"""
|
||||
return enqueue(
|
||||
'one_on_one_request',
|
||||
{
|
||||
'coach_name': coach_name,
|
||||
'coach_discord_id': coach_discord_id,
|
||||
'player_name': player_name,
|
||||
'team_name': team_name,
|
||||
'date_str': date_str,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'points': points,
|
||||
'request_id': request_id,
|
||||
},
|
||||
description=f'the One on One request DM for request {request_id}',
|
||||
)
|
||||
|
||||
|
||||
def send_schedule_notification(
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
event_title: str,
|
||||
event_date: str,
|
||||
event_time: str,
|
||||
reference_id: int,
|
||||
change_kind: str = 'scheduled',
|
||||
) -> bool:
|
||||
"""Queue a schedule notification DM to be sent by the bot.
|
||||
|
||||
Args:
|
||||
user_id: The player's database id (resolved to a snowflake in the bot).
|
||||
event_type: 'match' or 'tryout'.
|
||||
event_title: Display title of the event.
|
||||
event_date: Formatted date string.
|
||||
event_time: Formatted time range, or 'TBD'.
|
||||
reference_id: The participant id (or fallback match/tryout id) for
|
||||
reaction routing.
|
||||
change_kind: 'scheduled' for new events, 'modified' for changes.
|
||||
|
||||
Returns:
|
||||
True if queued, False on failure.
|
||||
"""
|
||||
return enqueue(
|
||||
'schedule_addition',
|
||||
{
|
||||
'user_id': user_id,
|
||||
'event_type': event_type,
|
||||
'event_title': event_title,
|
||||
'event_date': event_date,
|
||||
'event_time': event_time,
|
||||
'reference_id': reference_id,
|
||||
'change_kind': change_kind,
|
||||
},
|
||||
description=f'the {change_kind} notification for event {event_title}',
|
||||
)
|
||||
|
||||
|
||||
def send_one_on_one_response(
|
||||
player_discord_id: str,
|
||||
player_full_name: str,
|
||||
coach_full_name: str,
|
||||
date_str: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
points: str,
|
||||
approved: bool,
|
||||
refusal_note: str = None,
|
||||
) -> bool:
|
||||
"""Queue a One on One response DM to be sent to the player by the bot.
|
||||
|
||||
Called from Flask routes when a coach accepts/rejects via the web app.
|
||||
|
||||
Returns:
|
||||
True if queued, False on failure.
|
||||
"""
|
||||
return enqueue(
|
||||
'one_on_one_response',
|
||||
{
|
||||
'player_discord_id': player_discord_id,
|
||||
'player_full_name': player_full_name,
|
||||
'coach_full_name': coach_full_name,
|
||||
'date_str': date_str,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'points': points,
|
||||
'approved': approved,
|
||||
'refusal_note': refusal_note,
|
||||
},
|
||||
description=f'the One on One response DM for {player_full_name}',
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Persistence of pending reaction-request mappings across bot restarts.
|
||||
|
||||
The bot uses reaction messages (✅ / ❌) on DMs to let a coach approve or reject
|
||||
a one-on-one request without visiting the site. The mapping from Discord
|
||||
message_id → {request_type, primary_key} is stored in a JSON file so that a
|
||||
bot restart does not orphan reactions that were already sent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.discord.constants import PENDING_MAX_AGE_DAYS
|
||||
from app.discord.queue import enqueue
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#: File for persisting pending requests across bot restarts.
|
||||
PENDING_FILE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'discord_pending.json',
|
||||
)
|
||||
|
||||
|
||||
def load_pending() -> dict:
|
||||
"""Load pending requests from the JSON file.
|
||||
|
||||
Returns:
|
||||
dict mapping message_id (int) → entry dict.
|
||||
"""
|
||||
if not os.path.exists(PENDING_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(PENDING_FILE, encoding='utf-8') as f:
|
||||
entries = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
_logger.warning('Could not read pending file, starting empty.', exc_info=True)
|
||||
return {}
|
||||
if not isinstance(entries, dict):
|
||||
return {}
|
||||
cleaned = drop_expired(entries)
|
||||
if len(cleaned) < len(entries):
|
||||
save_pending(cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
def drop_expired(entries: dict) -> dict:
|
||||
"""Remove entries whose creation timestamp is too old.
|
||||
|
||||
Args:
|
||||
entries: dict mapping message_id → entry.
|
||||
|
||||
Returns:
|
||||
Filtered dict.
|
||||
"""
|
||||
now = datetime.now(UTC).timestamp()
|
||||
cutoff = now - PENDING_MAX_AGE_DAYS * 86400
|
||||
return {mid: e for mid, e in entries.items() if e.get('created_at', 0) >= cutoff}
|
||||
|
||||
|
||||
def save_pending(entries: dict) -> None:
|
||||
"""Persist pending entries to the JSON file."""
|
||||
try:
|
||||
with open(PENDING_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(entries, f, indent=2)
|
||||
except OSError:
|
||||
_logger.exception('Could not write pending file')
|
||||
|
||||
|
||||
def forget_pending(entries: dict, message_id: int) -> dict:
|
||||
"""Remove one message from the pending map and persist.
|
||||
|
||||
Args:
|
||||
entries: The current pending map.
|
||||
message_id: The message whose entry to remove.
|
||||
|
||||
Returns:
|
||||
Updated dict (already persisted).
|
||||
"""
|
||||
entries.pop(str(message_id), None)
|
||||
save_pending(entries)
|
||||
return entries
|
||||
|
||||
|
||||
def purge_pending_for_participants(participant_ids: list[int]) -> None:
|
||||
"""Queue a purge of stale participant pending entries.
|
||||
|
||||
Called after a match edit deletes participants. The bot thread removes
|
||||
matching ``schedule_addition`` pending entries so that a reaction on an
|
||||
old message cannot produce a false confirmation.
|
||||
|
||||
Args:
|
||||
participant_ids: MatchParticipant primary keys that no longer exist.
|
||||
"""
|
||||
if not participant_ids:
|
||||
return
|
||||
enqueue(
|
||||
'purge_pending',
|
||||
{'participant_ids': participant_ids},
|
||||
description=f'purge of {len(participant_ids)} stale participant records',
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared queue that bridges Flask request threads to the bot's async loop.
|
||||
|
||||
Module-level, not per-instance: the supervisor replaces the bot object on every
|
||||
restart (discord.py cannot reuse a client whose run() has returned). A queue
|
||||
living on the old object would be discarded with it, taking every pending
|
||||
notification along.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from queue import Empty, Queue # noqa: F401 — re-exported for bot.py
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#: The shared queue instance.
|
||||
message_queue: Queue = Queue()
|
||||
|
||||
|
||||
def enqueue(kind: str, data: dict, *, description: str) -> bool:
|
||||
"""Hand one notification to the bot thread. Never raises.
|
||||
|
||||
The three public senders were the same fifteen lines three times, each with
|
||||
its own blanket catch. There is one now, and it is the only place where
|
||||
breadth is a property of the caller rather than of the failure: this runs
|
||||
in a Flask request thread, and the work the notification announces is
|
||||
already committed. The meeting is booked whether or not Discord hears about
|
||||
it, so a failure here must not become a 500 on a page whose job is done.
|
||||
|
||||
What it is *not* is somewhere to lose a defect: the traceback is logged
|
||||
and the caller is told False.
|
||||
|
||||
Args:
|
||||
kind: Event type name ('one_on_one_request', 'schedule_addition', …).
|
||||
data: Payload dict.
|
||||
description: Human-readable label for the log message.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
try:
|
||||
message_queue.put({'type': kind, 'data': data})
|
||||
except Exception: # a notification must never fail the request that caused it # noqa: BLE001
|
||||
_logger.exception('Could not queue %s', description)
|
||||
return False
|
||||
return True
|
||||
+25
-1576
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -22,7 +22,7 @@ babel = Babel()
|
||||
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
|
||||
# app.config, which create_app fills from the environment and defaults to
|
||||
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["1000 per day", "200 per hour"])
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
|
||||
@@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass
|
||||
if not payload.get(name):
|
||||
payload.pop(name, None)
|
||||
return payload
|
||||
|
||||
|
||||
def form_gamertags(selected_games):
|
||||
"""Validate the dynamic gamertag fields for the selected games.
|
||||
|
||||
These fields cannot be declared statically on the account schemas: their
|
||||
names contain the game label. They are still untrusted form data, so
|
||||
every caller uses this shared boundary before adding or changing rows.
|
||||
"""
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.models import GAME_PLATFORMS
|
||||
from app.validators import GamertagSchema
|
||||
|
||||
validated = {}
|
||||
for game in selected_games:
|
||||
raw_gamertag = request.form.get(f'gamertag_{game}', '')
|
||||
raw_platform = (
|
||||
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
|
||||
)
|
||||
if not raw_gamertag.strip():
|
||||
continue
|
||||
try:
|
||||
validated[game] = GamertagSchema().load(
|
||||
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
|
||||
)
|
||||
except ValidationError as err:
|
||||
messages = [message for values in err.messages.values() for message in values]
|
||||
raise ValidationError({f'gamertag_{game}': messages}) from err
|
||||
return validated
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Google Drive storage for contract PDFs.
|
||||
|
||||
The application uses an OAuth refresh token for the owner's personal Google
|
||||
account. Tokens and client secrets come only from environment variables; the
|
||||
database stores opaque file IDs, never a credential or a shareable Drive URL.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
|
||||
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
|
||||
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
|
||||
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
|
||||
class GoogleDriveStorageError(RuntimeError):
|
||||
"""A configuration or API failure while storing a contract in Drive."""
|
||||
|
||||
|
||||
def _setting(name):
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise GoogleDriveStorageError(
|
||||
f'{name} must be configured for Google Drive document storage.'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _drive_service():
|
||||
"""Build an authorized Drive client from the owner's refresh token."""
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError as exc:
|
||||
raise GoogleDriveStorageError(
|
||||
'Google Drive dependencies are not installed. Install requirements.txt again.'
|
||||
) from exc
|
||||
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
|
||||
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
|
||||
scopes=[GOOGLE_DRIVE_SCOPE],
|
||||
)
|
||||
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
|
||||
|
||||
|
||||
def list_folder_files():
|
||||
"""Return (file_id, name) tuples for every file in the configured folder.
|
||||
|
||||
Used by the backup script to enumerate Drive-stored contracts. Returns
|
||||
only files directly in the folder (not nested inside sub-folders).
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str]]: (file_id, name) for each file found.
|
||||
"""
|
||||
try:
|
||||
folder_id = _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
|
||||
page_token = None
|
||||
results = []
|
||||
while True:
|
||||
response = (
|
||||
_drive_service()
|
||||
.files()
|
||||
.list(
|
||||
q=f"'{folder_id}' in parents and trashed=false",
|
||||
fields='nextPageToken, files(id, name)',
|
||||
pageToken=page_token,
|
||||
pageSize=1000,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
for f in response.get('files', []):
|
||||
results.append((f['id'], f['name']))
|
||||
page_token = response.get('nextPageToken')
|
||||
if not page_token:
|
||||
break
|
||||
return results
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
||||
|
||||
|
||||
def upload_file(*, stream, filename, mimetype):
|
||||
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseUpload
|
||||
|
||||
stream.seek(0)
|
||||
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
|
||||
response = (
|
||||
_drive_service()
|
||||
.files()
|
||||
.create(
|
||||
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
||||
media_body=media,
|
||||
fields='id',
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
|
||||
|
||||
file_id = response.get('id')
|
||||
if not file_id:
|
||||
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
|
||||
return file_id
|
||||
|
||||
|
||||
def download_file(file_id):
|
||||
"""Download a Drive file into memory for Flask's authenticated response."""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
destination = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(
|
||||
destination,
|
||||
_drive_service().files().get_media(fileId=file_id),
|
||||
)
|
||||
complete = False
|
||||
while not complete:
|
||||
_status, complete = downloader.next_chunk()
|
||||
return destination.getvalue()
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
|
||||
|
||||
|
||||
def delete_file(file_id):
|
||||
"""Permanently delete a Drive document when its contract record is deleted."""
|
||||
try:
|
||||
_drive_service().files().delete(fileId=file_id).execute()
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Manage a local PostgreSQL instance for development.
|
||||
|
||||
Searches for PostgreSQL binaries on the system, initialises a data directory
|
||||
under ``instance/pgdata/``, starts the server on port 5433, creates the
|
||||
``team_tryouts`` database, and registers cleanup on exit.
|
||||
|
||||
Usage from run.py::
|
||||
|
||||
if os.getenv('LOCAL_PG', '').lower() == 'true':
|
||||
from app.local_postgres import start as start_local_pg
|
||||
os.environ['DATABASE_URL'] = start_local_pg()
|
||||
|
||||
Environment used (all optional):
|
||||
LOCAL_PG_PORT Port to bind (default 5433)
|
||||
LOCAL_PG_USER Database superuser name (default team_tryouts_dev)
|
||||
LOCAL_PG_DATABASE Database name (default team_tryouts)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DATADIR = pathlib.Path(__file__).resolve().parent.parent / 'instance' / 'pgdata'
|
||||
PG_LOG = DATADIR.parent / 'pg.log'
|
||||
|
||||
PORT = int(os.getenv('LOCAL_PG_PORT', '5433'))
|
||||
_USER = os.getenv('LOCAL_PG_USER', 'team_tryouts_dev')
|
||||
_DATABASE = os.getenv('LOCAL_PG_DATABASE', 'team_tryouts')
|
||||
|
||||
DATABASE_URL = f'postgresql://{_USER}@127.0.0.1:{PORT}/{_DATABASE}'
|
||||
|
||||
_started = False
|
||||
|
||||
#: Name of the system Windows PostgreSQL service we temporarily stopped.
|
||||
_stopped_system_service_name: str | None = None
|
||||
|
||||
logger = logging.getLogger('app.local_postgres')
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIN_PG_ROOTS = [
|
||||
pathlib.Path(os.environ.get('PROGRAMFILES', 'C:\\Program Files')) / 'PostgreSQL',
|
||||
pathlib.Path(os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)')) / 'PostgreSQL',
|
||||
]
|
||||
|
||||
|
||||
def _find_binary(name: str) -> pathlib.Path | None:
|
||||
"""Locate a PostgreSQL binary (pg_ctl, initdb, psql)."""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return pathlib.Path(found)
|
||||
|
||||
if sys.platform == 'win32':
|
||||
for root in _WIN_PG_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
versions = sorted(
|
||||
(p for p in root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
for version_dir in versions:
|
||||
candidate = version_dir / 'bin' / f'{name}.exe'
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows service management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Path to the Windows service controller.
|
||||
_SC_EXE = pathlib.Path(os.environ.get('SYSTEMROOT', 'C:\\Windows')) / 'System32' / 'sc.exe'
|
||||
|
||||
|
||||
def _detect_system_service() -> str | None:
|
||||
"""Find a running Windows PostgreSQL service installed by the EDB installer.
|
||||
|
||||
Returns the service name (e.g. ``postgresql-x64-18``) or None.
|
||||
"""
|
||||
if sys.platform != 'win32' or not _SC_EXE.is_file():
|
||||
return None
|
||||
for root in _WIN_PG_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
versions = sorted(
|
||||
(p for p in root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
for version_dir in versions:
|
||||
srv = f'postgresql-x64-{version_dir.name}'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(_SC_EXE), 'query', srv],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and 'RUNNING' in result.stdout:
|
||||
return srv
|
||||
except (FileNotFoundError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _stop_system_service(name: str):
|
||||
"""Stop a Windows system service."""
|
||||
print(f'[INFO] Stopping system PostgreSQL service "{name}" ...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(_SC_EXE), 'stop', name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
print(f'[WARNING] Could not stop service "{name}": {exc}')
|
||||
|
||||
|
||||
def _start_system_service(name: str):
|
||||
"""Start a Windows system service."""
|
||||
print(f'[INFO] Restarting system PostgreSQL service "{name}" ...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(_SC_EXE), 'start', name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
print(f'[WARNING] Could not restart service "{name}": {exc}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data directory management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TRUST_HBA = """\
|
||||
# Local development configuration — trust all local connections.
|
||||
# Installed by app/local_postgres.py.
|
||||
local all all trust
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
"""
|
||||
|
||||
|
||||
def _init_cluster():
|
||||
"""Create the data directory if it does not exist yet."""
|
||||
if DATADIR.is_dir() and (DATADIR / 'PG_VERSION').is_file():
|
||||
return
|
||||
|
||||
initdb = _find_binary('initdb')
|
||||
if not initdb:
|
||||
print(
|
||||
'[ERROR] PostgreSQL not found. Install it from:',
|
||||
'https://www.postgresql.org/download/windows/',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f'[INFO] Initialising PostgreSQL data directory at {DATADIR} ...')
|
||||
DATADIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write a non-empty password to a temporary file. PostgreSQL >= 18
|
||||
# rejects an empty password (NUL device), and we immediately overwrite
|
||||
# pg_hba.conf with trust auth anyway, so the value is irrelevant.
|
||||
pw_file = tempfile.NamedTemporaryFile( # noqa: SIM115 — delete=False needed for initdb --pwfile
|
||||
mode='w',
|
||||
delete=False,
|
||||
suffix='.pw',
|
||||
prefix='pg_',
|
||||
)
|
||||
pw_file.write('dev\n')
|
||||
pw_file.close()
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(initdb),
|
||||
'--locale=C',
|
||||
'--encoding=UTF8',
|
||||
'--data-checksums',
|
||||
'--username',
|
||||
_USER,
|
||||
'--pwfile',
|
||||
pw_file.name,
|
||||
'-D',
|
||||
str(DATADIR),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f'[ERROR] initdb failed (exit {exc.returncode}):')
|
||||
if exc.stderr:
|
||||
print(exc.stderr)
|
||||
if exc.stdout:
|
||||
print(exc.stdout)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(pw_file.name)
|
||||
|
||||
_set_postgresql_conf()
|
||||
|
||||
hba_path = DATADIR / 'pg_hba.conf'
|
||||
hba_path.write_text(_TRUST_HBA, encoding='utf-8')
|
||||
print('[INFO] pg_hba.conf set to trust local connections.')
|
||||
|
||||
|
||||
def _set_postgresql_conf():
|
||||
"""Write postgresql.conf to match our development settings."""
|
||||
conf_path = DATADIR / 'postgresql.conf'
|
||||
if not conf_path.is_file():
|
||||
return
|
||||
|
||||
lines = conf_path.read_text(encoding='utf-8').splitlines()
|
||||
lines.append('\n# ---- local_postgres.py overrides ----')
|
||||
lines.append(f'port = {PORT}')
|
||||
lines.append("listen_addresses = '127.0.0.1'")
|
||||
lines.append("unix_socket_directories = ''")
|
||||
lines.append('autovacuum = off') # avoid DLL conflicts with system PostgreSQL service
|
||||
lines.append('logging_collector = off')
|
||||
lines.append('log_destination = stderr')
|
||||
conf_path.write_text('\n'.join(lines), encoding='utf-8')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server start / stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pg_ctl(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run ``pg_ctl`` with the data directory pre-filled."""
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
if not pg_ctl:
|
||||
raise RuntimeError('pg_ctl binary not found')
|
||||
|
||||
return subprocess.run(
|
||||
[str(pg_ctl), *args, '-D', str(DATADIR), '-w'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or str(DATADIR),
|
||||
)
|
||||
|
||||
|
||||
def _server_running() -> bool:
|
||||
"""Return True if the server is already up on our port."""
|
||||
try:
|
||||
result = _pg_ctl(['status'])
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _create_database():
|
||||
"""Create the database if it does not exist."""
|
||||
psql = _find_binary('psql')
|
||||
if not psql:
|
||||
raise RuntimeError('psql binary not found')
|
||||
|
||||
check = subprocess.run(
|
||||
[
|
||||
str(psql),
|
||||
'-h',
|
||||
'127.0.0.1',
|
||||
'-p',
|
||||
str(PORT),
|
||||
'-U',
|
||||
_USER,
|
||||
'-d',
|
||||
'postgres',
|
||||
'-tAc',
|
||||
f"SELECT 1 FROM pg_database WHERE datname = '{_DATABASE}'",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, 'PGPASSWORD': ''},
|
||||
)
|
||||
|
||||
if check.stdout.strip() == '1':
|
||||
print(f'[INFO] Database "{_DATABASE}" already exists.')
|
||||
return
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
str(psql),
|
||||
'-h',
|
||||
'127.0.0.1',
|
||||
'-p',
|
||||
str(PORT),
|
||||
'-U',
|
||||
_USER,
|
||||
'-d',
|
||||
'postgres',
|
||||
'-c',
|
||||
f'CREATE DATABASE "{_DATABASE}"',
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, 'PGPASSWORD': ''},
|
||||
)
|
||||
print(f'[INFO] Created database "{_DATABASE}".')
|
||||
|
||||
|
||||
def _stop(*_args):
|
||||
"""Stop the PostgreSQL server with a fast shutdown.
|
||||
|
||||
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
||||
normal exit, and terminal close.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
if not _started:
|
||||
return
|
||||
_started = False
|
||||
|
||||
try:
|
||||
result = _pg_ctl(['stop', '-m', 'fast'])
|
||||
if result.returncode != 0:
|
||||
logger.info('pg_ctl stop returned %d: %s', result.returncode, result.stderr.strip())
|
||||
else:
|
||||
logger.info('Local PostgreSQL stopped.')
|
||||
except Exception as exc: # noqa: BLE001 — shutdown must never raise
|
||||
logger.warning('Failed to stop local PostgreSQL: %s', exc)
|
||||
|
||||
# Restart the system service if we stopped it earlier.
|
||||
if _stopped_system_service_name:
|
||||
srv = _stopped_system_service_name
|
||||
_stopped_system_service_name = None
|
||||
_start_system_service(srv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def start() -> str:
|
||||
"""Start a local PostgreSQL server for development.
|
||||
|
||||
Returns the ``DATABASE_URL`` the application should use.
|
||||
|
||||
The caller is expected to set this on ``os.environ['DATABASE_URL']``
|
||||
before invoking ``create_app()``.
|
||||
|
||||
This function is idempotent: if the server is already running and the
|
||||
database exists, it returns immediately.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
|
||||
# On Windows, temporarily stop the system PostgreSQL service so its child
|
||||
# processes (autovacuum, etc.) do not collide with our local instance.
|
||||
if _stopped_system_service_name is None: # only do this once per process
|
||||
srv = _detect_system_service()
|
||||
if srv:
|
||||
_stop_system_service(srv)
|
||||
_stopped_system_service_name = srv
|
||||
|
||||
# 1. Initialise if needed.
|
||||
_init_cluster()
|
||||
|
||||
# 2. Start if not already running.
|
||||
if not _server_running():
|
||||
print(f'[INFO] Starting PostgreSQL on port {PORT} ...')
|
||||
PG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(pg_ctl),
|
||||
'start',
|
||||
'-D',
|
||||
str(DATADIR),
|
||||
'-l',
|
||||
str(PG_LOG),
|
||||
'-w', # wait for readiness
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print('[ERROR] pg_ctl start failed.')
|
||||
# Also show the server log — it often has the real error.
|
||||
try:
|
||||
log_text = PG_LOG.read_text(encoding='utf-8')
|
||||
if log_text.strip():
|
||||
print(f'Server log ({PG_LOG}):')
|
||||
print(log_text)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sys.exit(1)
|
||||
print(f'[INFO] PostgreSQL is up (pid in {PG_LOG}).')
|
||||
else:
|
||||
print(f'[INFO] PostgreSQL is already running on port {PORT}.')
|
||||
|
||||
# 3. Create the application database (idempotent).
|
||||
_create_database()
|
||||
|
||||
# 4. Register cleanup.
|
||||
if not _started:
|
||||
_started = True
|
||||
atexit.register(_stop)
|
||||
with contextlib.suppress(ValueError, OSError):
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
||||
|
||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||
return DATABASE_URL
|
||||
@@ -20,6 +20,7 @@ from app.models._constants import (
|
||||
GAME_PLATFORMS,
|
||||
PLATFORM_CODES,
|
||||
TRN_URLS,
|
||||
EVALUATION_CRITERIA,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -93,3 +94,6 @@ from app.models.contract import Contract
|
||||
from app.models.team_note import TeamNote
|
||||
from app.models.personal_note import PersonalNote
|
||||
from app.models.one_on_one_request import OneOnOneRequest
|
||||
from app.models.admin_settings import AppSettings
|
||||
from app.models.backup_record import BackupRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
tryout_managers = db.Table(
|
||||
'tryout_managers',
|
||||
db.Column(
|
||||
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
|
||||
|
||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||
|
||||
# Ordered list of (field_name, human_label) pairs for the player evaluation
|
||||
# score criteria. Kept in a single place so the evaluation forms, batch
|
||||
# evaluation page, and any future reporting all stay in sync.
|
||||
EVALUATION_CRITERIA = [
|
||||
('mecanics_score', 'Mecanics'),
|
||||
('cohesion_score', 'Cohesion'),
|
||||
('communication_score', 'Communication'),
|
||||
('gamesense_score', 'Gamesense'),
|
||||
('versatility_score', 'Versatility'),
|
||||
('discipline_score', 'Discipline'),
|
||||
('analysis_score', 'Analysis'),
|
||||
('sport_ethics_score', 'Sport Ethics'),
|
||||
('mental_score', 'Mental'),
|
||||
]
|
||||
|
||||
ESPORT_GAMES = [
|
||||
'Valorant',
|
||||
'League of Legends',
|
||||
@@ -56,9 +71,9 @@ PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket L
|
||||
|
||||
TRN_URLS = {
|
||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
|
||||
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
|
||||
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform_code}/{username}',
|
||||
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
||||
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Global application settings stored as key-value pairs in the database."""
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class AppSettings(db.Model):
|
||||
"""Key-value store for global application settings.
|
||||
|
||||
Stores toggles and configuration that admins control through the
|
||||
admin panel, such as whether tryouts are open, season state, etc.
|
||||
"""
|
||||
|
||||
__tablename__ = 'app_settings'
|
||||
|
||||
key = db.Column(db.String(100), primary_key=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Well-known keys (documented here for discoverability)
|
||||
# ------------------------------------------------------------------
|
||||
# tryouts_open – "true" / "false" (default: "true")
|
||||
# document_uploads_enabled – "true" / "false" (default: "true")
|
||||
# season_active – "true" / "false" (default: "false")
|
||||
# season_name – e.g. "Fall 2026"
|
||||
# season_start – ISO date string
|
||||
# season_end – ISO date string
|
||||
|
||||
@staticmethod
|
||||
def get(key, default=None):
|
||||
"""Return the value for *key*, or *default* if not set."""
|
||||
row = db.session.get(AppSettings, key)
|
||||
return row.value if row is not None else default
|
||||
|
||||
@staticmethod
|
||||
def set(key, value):
|
||||
"""Upsert a setting."""
|
||||
row = db.session.get(AppSettings, key)
|
||||
if row is None:
|
||||
row = AppSettings(key=key, value=str(value) if value is not None else None)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.value = str(value) if value is not None else None
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_bool(key, default=False):
|
||||
"""Return a boolean setting."""
|
||||
val = AppSettings.get(key)
|
||||
if val is None:
|
||||
return default
|
||||
return val.lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
@staticmethod
|
||||
def set_bool(key, value):
|
||||
"""Store a boolean setting as 'true' / 'false'."""
|
||||
AppSettings.set(key, 'true' if value else 'false')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Audit log for tracking sensitive administrative actions."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""Append-only log of critical admin actions for accountability."""
|
||||
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
action = db.Column(db.String(100), nullable=False)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
ip_address = db.Column(db.String(64), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
|
||||
|
||||
@staticmethod
|
||||
def record(user_id, action, details=None, ip_address=None):
|
||||
"""Create a new audit log entry."""
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
return entry
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BaseAvailability(db.Model):
|
||||
@@ -13,5 +12,5 @@ class BaseAvailability(db.Model):
|
||||
day_of_week = db.Column(db.Integer, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Records of database backups created through the admin panel."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BackupRecord(db.Model):
|
||||
"""Metadata for a database backup stored on disk."""
|
||||
|
||||
__tablename__ = 'backup_records'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
size_bytes = db.Column(db.BigInteger, nullable=True)
|
||||
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Contract(db.Model):
|
||||
@@ -23,7 +22,7 @@ class Contract(db.Model):
|
||||
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
signed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
||||
@@ -57,7 +56,7 @@ class Contract(db.Model):
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
player = User.query.get(self.player_id)
|
||||
player = db.session.get(User, self.player_id)
|
||||
if player and player.get_org_teams():
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Player evaluation record."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
@@ -25,8 +24,8 @@ class Evaluation(db.Model):
|
||||
overall_score = db.Column(db.Float, nullable=True)
|
||||
comments = db.Column(db.Text, nullable=True)
|
||||
position_recommendation = db.Column(db.String(50), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BaseMatch(db.Model):
|
||||
@@ -12,10 +11,10 @@ class BaseMatch(db.Model):
|
||||
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
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)
|
||||
end_time = db.Column(db.Time, nullable=True)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='scheduled')
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class OneOnOneRequest(db.Model):
|
||||
@@ -13,12 +12,12 @@ class OneOnOneRequest(db.Model):
|
||||
player_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)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
points = db.Column(db.Text, nullable=True)
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
responded_at = db.Column(db.DateTime, nullable=True)
|
||||
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
||||
coach_rejection_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import org_team_coaches, org_team_managers
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class OrgTeam(db.Model):
|
||||
@@ -13,7 +12,7 @@ class OrgTeam(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Many-to-many junction: player to org-team."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TeamPlayer(db.Model):
|
||||
@@ -14,7 +13,7 @@ class TeamPlayer(db.Model):
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
||||
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Abstract base class for match participant models."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class BaseParticipant(db.Model):
|
||||
@@ -11,4 +10,4 @@ class BaseParticipant(db.Model):
|
||||
__abstract__ = True
|
||||
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class PersonalNote(db.Model):
|
||||
@@ -13,8 +12,8 @@ class PersonalNote(db.Model):
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
@@ -13,7 +12,7 @@ class Team(db.Model):
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', backref='created_teams')
|
||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TeamMember(db.Model):
|
||||
@@ -13,6 +12,6 @@ class TeamMember(db.Model):
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TeamNote(db.Model):
|
||||
@@ -13,8 +12,8 @@ class TeamNote(db.Model):
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
team = db.relationship('OrgTeam', backref='team_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import tryout_coaches
|
||||
from app.models._associations import tryout_coaches, tryout_managers
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class Tryout(db.Model):
|
||||
@@ -14,23 +13,28 @@ class Tryout(db.Model):
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
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)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='upcoming')
|
||||
max_players = db.Column(db.Integer, nullable=True)
|
||||
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)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
manager_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
coach_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
||||
manager = db.relationship(
|
||||
'User', foreign_keys=[manager_id], backref='managed_tryouts'
|
||||
) # deprecated
|
||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
@@ -48,3 +52,17 @@ class Tryout(db.Model):
|
||||
if self.end_date is not None:
|
||||
return self.end_date < today
|
||||
return self.date < today
|
||||
|
||||
def get_managers(self):
|
||||
"""Managers attached to this tryout, both legacy and many-to-many."""
|
||||
manager_list = list(self.managers)
|
||||
if not manager_list and self.manager:
|
||||
return [self.manager]
|
||||
return manager_list
|
||||
|
||||
def get_coaches(self):
|
||||
"""Coaches attached to this tryout, both legacy and many-to-many."""
|
||||
coach_list = list(self.coaches)
|
||||
if not coach_list and self.coach:
|
||||
return [self.coach]
|
||||
return coach_list
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class TryoutRegistration(db.Model):
|
||||
@@ -12,6 +11,6 @@ class TryoutRegistration(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
registered_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
status = db.Column(db.String(20), default='registered')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -21,7 +21,11 @@ class Manager(User):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
||||
return (
|
||||
tryout.created_by == self.id
|
||||
or tryout.manager_id == self.id
|
||||
or any(m.id == self.id for m in tryout.managers)
|
||||
)
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
@@ -32,7 +36,13 @@ class Manager(User):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return (
|
||||
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
|
||||
Tryout.query.filter(
|
||||
or_(
|
||||
Tryout.created_by == self.id,
|
||||
Tryout.manager_id == self.id,
|
||||
Tryout.managers.any(id=self.id),
|
||||
)
|
||||
)
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Base User model — shared fields and polymorphic configuration."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_login import UserMixin
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
@@ -25,7 +24,7 @@ class User(UserMixin, db.Model):
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
is_active_account = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Admin panel routes for system-level management.
|
||||
|
||||
Provides backup/restore, tryout open/close toggling, season lifecycle
|
||||
management, team wiping, and audit log viewing. All routes are restricted
|
||||
to administrators.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin,
|
||||
AppSettings,
|
||||
AuditLog,
|
||||
BackupRecord,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
User,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.supporting_scripts.backup import (
|
||||
BackupError,
|
||||
backup_database,
|
||||
create_backup_dir,
|
||||
parse_database_url,
|
||||
)
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def require_admin():
|
||||
"""Return True if current user is an Admin, else flash and redirect."""
|
||||
if isinstance(current_user, Admin):
|
||||
return True
|
||||
flash('Only the president can access the admin panel.', 'danger')
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Best-effort client IP for audit logging."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
def _log(action, details=None):
|
||||
"""Record an audit log entry for the current user."""
|
||||
AuditLog.record(
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
details=details,
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the admin panel dashboard."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
stats = {
|
||||
'total_users': User.query.count(),
|
||||
'total_players': User.query.filter_by(role='player').count(),
|
||||
'total_org_teams': OrgTeam.query.count(),
|
||||
'active_tryouts': Tryout.query.filter_by(status='in_progress').count(),
|
||||
'upcoming_tryouts': Tryout.query.filter_by(status='upcoming').count(),
|
||||
}
|
||||
|
||||
settings = {
|
||||
'tryouts_open': AppSettings.get_bool('tryouts_open', default=True),
|
||||
'document_uploads_enabled': AppSettings.get_bool('document_uploads_enabled', default=True),
|
||||
'season_active': AppSettings.get_bool('season_active', default=False),
|
||||
'season_name': AppSettings.get('season_name') or 'Not set',
|
||||
'season_start': AppSettings.get('season_start') or 'Not set',
|
||||
'season_end': AppSettings.get('season_end') or 'Not set',
|
||||
}
|
||||
|
||||
backups = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(20).all()
|
||||
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()
|
||||
|
||||
return render_template(
|
||||
'pages/admin.html',
|
||||
stats=stats,
|
||||
settings=settings,
|
||||
backups=backups,
|
||||
audit_logs=audit_logs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_backup_record(backup_type='manual', notes=None):
|
||||
"""Run a database backup and return a BackupRecord, or raise BackupError."""
|
||||
create_backup_dir()
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
file_path = backup_database(conn)
|
||||
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
record = BackupRecord(
|
||||
filename=filename,
|
||||
file_path=file_path,
|
||||
size_bytes=size,
|
||||
backup_type=backup_type,
|
||||
notes=notes,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
return record
|
||||
|
||||
|
||||
@admin_bp.route('/backup/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_backup():
|
||||
"""Create a manual database backup."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
notes = request.form.get('notes', '').strip() or None
|
||||
try:
|
||||
record = _create_backup_record(backup_type='manual', notes=notes)
|
||||
except BackupError as e:
|
||||
flash(f'Backup failed: {e}', 'danger')
|
||||
_log('backup_failed', f'Error: {e}')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
|
||||
flash(f'Backup created successfully: {record.filename}', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/download')
|
||||
@login_required
|
||||
def download_backup(backup_id):
|
||||
"""Download a backup file."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if not os.path.exists(record.file_path):
|
||||
flash('Backup file is missing from disk.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_downloaded', f'File: {record.filename}')
|
||||
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_backup(backup_id):
|
||||
"""Delete a backup file and its record."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if os.path.exists(record.file_path):
|
||||
try:
|
||||
os.remove(record.file_path)
|
||||
except OSError as e:
|
||||
flash(f'Could not remove backup file: {e}', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_deleted', f'File: {record.filename}')
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
flash(f'Backup {record.filename} deleted.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
||||
@login_required
|
||||
def restore_backup(backup_id):
|
||||
"""Restore a selected backup.
|
||||
|
||||
A safety backup of the current state is created first, then the
|
||||
selected dump is restored via pg_restore.
|
||||
"""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if not os.path.exists(record.file_path):
|
||||
flash('Backup file is missing from disk.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
# Create a safety backup of the current state before restoring.
|
||||
try:
|
||||
_create_backup_record(
|
||||
backup_type='pre_restore',
|
||||
notes='Automatic safety backup before restoring ' + record.filename,
|
||||
)
|
||||
except BackupError as e:
|
||||
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
# Restore using pg_restore
|
||||
import subprocess
|
||||
|
||||
from app.supporting_scripts.backup import (
|
||||
PG_RESTORE,
|
||||
dump_environment,
|
||||
parse_database_url,
|
||||
)
|
||||
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
cmd = [
|
||||
PG_RESTORE,
|
||||
'--host',
|
||||
conn['host'],
|
||||
'--port',
|
||||
conn['port'],
|
||||
'--username',
|
||||
conn['user'],
|
||||
'--dbname',
|
||||
conn['dbname'],
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
'--no-owner',
|
||||
record.file_path,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=dump_environment(conn),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or 'pg_restore failed')
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
|
||||
flash(f'Restore failed: {e}', 'danger')
|
||||
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_restored', f'File: {record.filename}')
|
||||
flash(
|
||||
f'Backup {record.filename} restored successfully. The database has been rolled back.',
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tryouts open/close toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/toggle-tryouts', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_tryouts():
|
||||
"""Toggle the global tryout open/close state."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
current = AppSettings.get_bool('tryouts_open', default=True)
|
||||
new_value = not current
|
||||
AppSettings.set_bool('tryouts_open', new_value)
|
||||
|
||||
state = 'opened' if new_value else 'closed'
|
||||
_log('tryouts_toggled', f'Tryouts {state}')
|
||||
flash(f'Tryouts are now {state}.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Season lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/season/start', methods=['POST'])
|
||||
@login_required
|
||||
def start_season():
|
||||
"""Begin a new regular season."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if AppSettings.get_bool('season_active', default=False):
|
||||
flash('A season is already active. End it before starting a new one.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
name = request.form.get('season_name', '').strip()
|
||||
start_date = request.form.get('season_start', '').strip()
|
||||
if not start_date:
|
||||
flash('A season start date is required.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
try:
|
||||
datetime.strptime(start_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid season start date format.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
AppSettings.set('season_name', name or 'Untitled Season')
|
||||
AppSettings.set('season_start', start_date)
|
||||
AppSettings.set('season_end', None)
|
||||
AppSettings.set_bool('season_active', True)
|
||||
|
||||
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
|
||||
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/season/end', methods=['POST'])
|
||||
@login_required
|
||||
def end_season():
|
||||
"""End the current regular season."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if not AppSettings.get_bool('season_active', default=False):
|
||||
flash('No season is currently active.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
end_date = request.form.get('season_end', '').strip()
|
||||
if not end_date:
|
||||
flash('A season end date is required.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
try:
|
||||
datetime.strptime(end_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid season end date format.', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
name = AppSettings.get('season_name')
|
||||
AppSettings.set('season_end', end_date)
|
||||
AppSettings.set_bool('season_active', False)
|
||||
|
||||
_log('season_ended', f'Season: {name}, End: {end_date}')
|
||||
flash(f'Season "{name}" has ended.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_document_uploads():
|
||||
"""Enable or disable document uploads for non-admin users."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
current = AppSettings.get_bool('document_uploads_enabled', default=True)
|
||||
new_value = not current
|
||||
AppSettings.set_bool('document_uploads_enabled', new_value)
|
||||
|
||||
state = 'enabled' if new_value else 'disabled'
|
||||
_log('document_uploads_toggled', f'Document uploads {state}')
|
||||
flash(f'Document uploads are now {state}.', 'success')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wipe teams for new season
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/teams/wipe', methods=['POST'])
|
||||
@login_required
|
||||
def wipe_teams():
|
||||
"""Wipe team rosters and season matches for a new season.
|
||||
|
||||
Players are removed from org teams (TeamPlayer records), regular-season
|
||||
matches and their participants are deleted. OrgTeam structures, coaches,
|
||||
and managers are preserved.
|
||||
"""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
confirm = request.form.get('confirm', '').strip()
|
||||
if confirm != 'WIPE':
|
||||
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
# Safety backup before destructive operation
|
||||
try:
|
||||
_create_backup_record(
|
||||
backup_type='pre_wipe',
|
||||
notes='Automatic safety backup before wiping teams',
|
||||
)
|
||||
except BackupError as e:
|
||||
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
roster_count = TeamPlayer.query.count()
|
||||
match_count = TeamMatch.query.count()
|
||||
|
||||
# Delete team match participants first (FK order)
|
||||
TeamMatchParticipant.query.delete()
|
||||
TeamMatch.query.delete()
|
||||
TeamPlayer.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
_log('teams_wiped', f'Removed {roster_count} roster entries and {match_count} season matches')
|
||||
flash(
|
||||
f'Teams wiped for the new season. Removed {roster_count} roster entries '
|
||||
f'and {match_count} regular-season matches.',
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
+19
-12
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
@@ -18,9 +18,11 @@ from flask_login import current_user, login_required, login_user, logout_user
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import check_password, db, hash_password, limiter
|
||||
from app.forms import form_gamertags
|
||||
from app.i18n import LOCALE_SESSION_KEY
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import ESPORT_GAMES, Player, User
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
||||
|
||||
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||
@@ -293,7 +295,7 @@ def login():
|
||||
)
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
|
||||
log_auth_event(
|
||||
'account.throttled',
|
||||
username=username,
|
||||
@@ -393,6 +395,13 @@ def register():
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
try:
|
||||
submitted_gamertags = form_gamertags(selected_games)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
# The OAuth identity is server-side state. It used to be copied into
|
||||
# hidden inputs and read back from request.form, which let anyone
|
||||
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||
@@ -445,16 +454,14 @@ def register():
|
||||
# Create UserGamertag records for each selected game
|
||||
from app.models import UserGamertag
|
||||
|
||||
for game in selected_games:
|
||||
field_name = f'gamertag_{game}'
|
||||
gamertag_value = request.form.get(field_name, '').strip()
|
||||
if gamertag_value:
|
||||
gamertag = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=gamertag_value,
|
||||
)
|
||||
db.session.add(gamertag)
|
||||
for game, gamertag_data in submitted_gamertags.items():
|
||||
gamertag = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=gamertag_data['gamertag'],
|
||||
platform=gamertag_data['platform'],
|
||||
)
|
||||
db.session.add(gamertag)
|
||||
db.session.commit()
|
||||
|
||||
# Clear Discord OAuth data from session after successful registration
|
||||
|
||||
+174
-18
@@ -13,6 +13,7 @@ from sqlalchemy.orm import aliased
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
EVALUATION_CRITERIA,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Evaluation,
|
||||
@@ -22,11 +23,54 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.responders import respond_after_change
|
||||
from app.validators import EvaluationSchema
|
||||
|
||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||
|
||||
|
||||
def validate_score(value):
|
||||
"""Convert a raw form value to an int 1..10, or None if blank/invalid.
|
||||
|
||||
Args:
|
||||
value: Raw value from request.form — a string, or None for a missing field.
|
||||
|
||||
Returns:
|
||||
int | None: Score 1-10, or None.
|
||||
"""
|
||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||
return None
|
||||
try:
|
||||
score = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if score < 1 or score > 10:
|
||||
return None
|
||||
return score
|
||||
|
||||
|
||||
def _apply_evaluation(evaluation, scores, comments, position):
|
||||
"""Write scores, comments and recommended position onto an Evaluation row.
|
||||
|
||||
Args:
|
||||
evaluation: The Evaluation instance (new or existing).
|
||||
scores: Dict mapping criterion name to int or None.
|
||||
comments: Free-text comment string, or None.
|
||||
position: Recommended position string, or None.
|
||||
"""
|
||||
evaluation.apply_scores(scores)
|
||||
evaluation.comments = comments
|
||||
evaluation.position_recommendation = position
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load a set of users once for aggregate/list views."""
|
||||
wanted = {user_id for user_id in user_ids if user_id}
|
||||
if not wanted:
|
||||
return {}
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
@evaluations_bp.route('')
|
||||
@login_required
|
||||
def list_evaluations():
|
||||
@@ -85,9 +129,10 @@ def list_evaluations():
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = User.query.get(row.player_id)
|
||||
p = players_by_id.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p,
|
||||
@@ -126,7 +171,7 @@ def evaluate_player(tryout_id, player_id):
|
||||
flash(_('You do not have permission to evaluate players.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -140,12 +185,12 @@ def evaluate_player(tryout_id, player_id):
|
||||
)
|
||||
if not is_registered:
|
||||
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 = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
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(
|
||||
tryout_id=tryout_id,
|
||||
@@ -160,8 +205,10 @@ def evaluate_player(tryout_id, player_id):
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).all()
|
||||
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
|
||||
evaluators = [
|
||||
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
|
||||
{'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e}
|
||||
for e in all_evaluations
|
||||
]
|
||||
|
||||
return render_template(
|
||||
@@ -197,7 +244,7 @@ def evaluate_player(tryout_id, player_id):
|
||||
evaluation.position_recommendation = data['position_recommendation']
|
||||
|
||||
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()
|
||||
|
||||
@@ -210,21 +257,130 @@ def players_to_evaluate(tryout_id):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
players = []
|
||||
for reg in registrations:
|
||||
p = User.query.get(reg.player_id)
|
||||
if p and isinstance(p, Player):
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=p.id,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
||||
players_by_id = _users_by_id(reg.player_id for reg in registrations)
|
||||
evaluated_player_ids = {
|
||||
player_id
|
||||
for (player_id,) in db.session.query(Evaluation.player_id)
|
||||
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
|
||||
.all()
|
||||
}
|
||||
players = [
|
||||
{
|
||||
'player': player,
|
||||
'evaluated': player.id in evaluated_player_ids,
|
||||
'registration': registration,
|
||||
}
|
||||
for registration in registrations
|
||||
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
|
||||
]
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def batch_evaluate(tryout_id):
|
||||
"""Evaluate multiple players at once in a tryout.
|
||||
|
||||
GET renders a single form listing every selected player with their
|
||||
evaluation criteria. POST saves (creates or updates) all of them.
|
||||
"""
|
||||
if not current_user.can_evaluate():
|
||||
flash('You do not have permission to evaluate players.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
# Resolve selected player ids (query string on GET, hidden fields on POST).
|
||||
player_ids = []
|
||||
for raw in request.values.getlist('player_ids'):
|
||||
try:
|
||||
pid = int(raw)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if pid not in player_ids:
|
||||
player_ids.append(pid)
|
||||
|
||||
if not player_ids:
|
||||
flash('Please select at least one player to evaluate.', 'warning')
|
||||
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||
|
||||
players = []
|
||||
for pid in player_ids:
|
||||
player = User.query.get(pid)
|
||||
if not player or not isinstance(player, Player):
|
||||
continue
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=pid,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
continue
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=pid,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
existing_scores = {
|
||||
field_name: getattr(existing, field_name) if existing else None
|
||||
for field_name, _ in EVALUATION_CRITERIA
|
||||
}
|
||||
players.append(
|
||||
{
|
||||
'player': player,
|
||||
'existing': existing,
|
||||
'existing_scores': existing_scores,
|
||||
}
|
||||
)
|
||||
|
||||
if not players:
|
||||
flash('No valid players selected for evaluation.', 'danger')
|
||||
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||
|
||||
if request.method == 'POST':
|
||||
saved = 0
|
||||
for entry in players:
|
||||
pid = entry['player'].id
|
||||
scores = {
|
||||
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||
for field_name, _ in EVALUATION_CRITERIA
|
||||
}
|
||||
comments = request.form.get(f'comments_{pid}')
|
||||
position = request.form.get(f'position_recommendation_{pid}')
|
||||
|
||||
existing = entry['existing']
|
||||
if existing:
|
||||
_apply_evaluation(existing, scores, comments, position)
|
||||
else:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id,
|
||||
player_id=pid,
|
||||
evaluator_id=current_user.id,
|
||||
)
|
||||
_apply_evaluation(evaluation, scores, comments, position)
|
||||
db.session.add(evaluation)
|
||||
saved += 1
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_template(
|
||||
'pages/batch_evaluate.html',
|
||||
tryout=tryout,
|
||||
players=players,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
+7
-9
@@ -223,20 +223,18 @@ def dashboard():
|
||||
elif isinstance(user, Scout):
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['avg_scores'] = (
|
||||
top_rows = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
User,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.group_by(Evaluation.player_id)
|
||||
.order_by(func.avg(Evaluation.overall_score).desc())
|
||||
.join(Evaluation, Evaluation.player_id == User.id)
|
||||
.filter(User.role == 'player')
|
||||
.group_by(User.id)
|
||||
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
stats['top_players'] = []
|
||||
for row in stats['avg_scores']:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
stats['top_players'].append((p, round(row.avg_score, 1)))
|
||||
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
|
||||
|
||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||
|
||||
+259
-110
@@ -5,7 +5,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
|
||||
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_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
@@ -26,11 +26,14 @@ from app.models import (
|
||||
PlayerDisponibility,
|
||||
Scout,
|
||||
Team,
|
||||
TeamMatch,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
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.validators import MatchEditSchema, MatchSchema
|
||||
|
||||
@@ -46,6 +49,25 @@ def match_form_payload():
|
||||
return form_payload(list_fields=('player_ids',), optional_blank=())
|
||||
|
||||
|
||||
def registered_players(tryout_id):
|
||||
"""Players registered for one tryout, loaded in a single query.
|
||||
|
||||
The create form previously called ``User.query.get`` twice per
|
||||
registration (once in the filter and once in the result expression),
|
||||
and the edit form called it once per row. Besides scaling linearly, both
|
||||
paths could return duplicates while DB-006 is still pending. The join is
|
||||
bounded and ``distinct`` preserves the form's intended one-option-per-
|
||||
player contract until the database constraint lands.
|
||||
"""
|
||||
return (
|
||||
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
|
||||
.filter(TryoutRegistration.tryout_id == tryout_id)
|
||||
.order_by(User.username)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
#: How long a match lasts when the form gives a start and no end.
|
||||
DEFAULT_MATCH_MINUTES = 30
|
||||
|
||||
@@ -55,6 +77,25 @@ def default_end_time(date, start_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):
|
||||
"""Attach participants to a match, per its type.
|
||||
|
||||
@@ -146,13 +187,17 @@ def api_events():
|
||||
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
|
||||
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 = []
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||
|
||||
if tryouts_by_id:
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||
if window_start:
|
||||
query = query.filter(Match.date >= window_start)
|
||||
@@ -206,30 +251,71 @@ def api_events():
|
||||
|
||||
user_participant = mine_by_match.get(match.id)
|
||||
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'description': match.description or '',
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
if user_participant
|
||||
else False,
|
||||
},
|
||||
}
|
||||
)
|
||||
event = {
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'description': match.description or '',
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
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)
|
||||
if isinstance(current_user, Player):
|
||||
@@ -244,23 +330,22 @@ def api_events():
|
||||
one_on_ones = []
|
||||
|
||||
for ooo in one_on_ones:
|
||||
events.append(
|
||||
{
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'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 = {
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'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)
|
||||
|
||||
@@ -270,7 +355,7 @@ def api_events():
|
||||
@login_required
|
||||
def api_events_for_tryout(tryout_id):
|
||||
"""API endpoint returning calendar events for a specific tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
|
||||
is_registered = False
|
||||
@@ -332,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
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
}
|
||||
)
|
||||
event = {
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'participants': participants_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)
|
||||
|
||||
@@ -359,21 +443,17 @@ def api_events_for_tryout(tryout_id):
|
||||
@login_required
|
||||
def create_match(tryout_id):
|
||||
"""Create a new match / scrimmage within a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
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:
|
||||
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()
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
all_players = [
|
||||
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)
|
||||
]
|
||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||
all_players = registered_players(tryout_id)
|
||||
prefill_date = request.args.get('date', '')
|
||||
|
||||
def rerender():
|
||||
@@ -428,7 +508,7 @@ def create_match(tryout_id):
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
@@ -437,21 +517,19 @@ def create_match(tryout_id):
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing match."""
|
||||
match = Match.query.get_or_404(match_id)
|
||||
match = db.get_or_404(Match, match_id)
|
||||
tryout = match.tryout
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
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:
|
||||
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()
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
|
||||
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
|
||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||
all_players = registered_players(tryout.id)
|
||||
current_player_ids = [p.player_id for p in match.participants.all()]
|
||||
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
|
||||
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
|
||||
@@ -492,6 +570,12 @@ def edit_match(match_id):
|
||||
flash_validation_errors(err)
|
||||
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
|
||||
# each field was read meant a form rejected halfway had already
|
||||
# changed the record in the session.
|
||||
@@ -503,41 +587,106 @@ def edit_match(match_id):
|
||||
match.location = data['location']
|
||||
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_participant_ids = []
|
||||
kept_ids = set()
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
||||
if teams_changed:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
match.team1_id = data['team1_id']
|
||||
match.team2_id = data['team2_id']
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
for player_id, participant in list(existing.items()):
|
||||
if player_id in desired:
|
||||
kept_ids.add(player_id)
|
||||
notified_player_ids.append(player_id)
|
||||
notified_participant_ids.append(participant.id)
|
||||
else:
|
||||
# Same teams: the roster stands, but everyone is told again,
|
||||
# because the date or the time may have moved.
|
||||
for team_id in (match.team1_id, match.team2_id):
|
||||
if team_id:
|
||||
notified_player_ids.extend(
|
||||
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
||||
)
|
||||
else:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||
removed_ids.append(participant.id)
|
||||
db.session.delete(participant)
|
||||
|
||||
# New players get new participant rows.
|
||||
for player_id in desired - set(existing.keys()):
|
||||
participant = MatchParticipant(match_id=match.id, player_id=player_id)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_player_ids.append(player_id)
|
||||
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()
|
||||
|
||||
notify_participants(
|
||||
title=match.title,
|
||||
date=match.date,
|
||||
start_time=match.start_time,
|
||||
end_time=match.end_time,
|
||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
||||
fallback_id=match.id,
|
||||
)
|
||||
# Purge Discord pending entries for participants that no longer exist.
|
||||
if removed_ids:
|
||||
from app.discord import purge_pending_for_participants
|
||||
|
||||
purge_pending_for_participants(removed_ids)
|
||||
|
||||
# Decide whether and what to notify.
|
||||
if time_changed:
|
||||
# All current participants get the "modified" message.
|
||||
all_current = list(zip(notified_player_ids, notified_participant_ids, 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')
|
||||
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()
|
||||
|
||||
@@ -569,14 +718,14 @@ def api_manageable_tryouts():
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a match."""
|
||||
match = Match.query.get_or_404(match_id)
|
||||
match = db.get_or_404(Match, match_id)
|
||||
tryout = match.tryout
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
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:
|
||||
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
|
||||
# keeps its value, and deleting it here would destroy unrelated content.
|
||||
@@ -589,7 +738,7 @@ def delete_match(match_id):
|
||||
db.session.delete(match)
|
||||
db.session.commit()
|
||||
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):
|
||||
@@ -656,10 +805,10 @@ def api_available_players(date, time):
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle attendance_confirmed for a match participant."""
|
||||
match = Match.query.get_or_404(match_id)
|
||||
match = db.get_or_404(Match, match_id)
|
||||
tryout = match.tryout
|
||||
|
||||
participant = MatchParticipant.query.get_or_404(participant_id)
|
||||
participant = db.get_or_404(MatchParticipant, participant_id)
|
||||
if participant.match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
|
||||
+40
-15
@@ -3,9 +3,7 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
@@ -15,6 +13,7 @@ from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Admin,
|
||||
AppSettings,
|
||||
Coach,
|
||||
Manager,
|
||||
OrgTeam,
|
||||
@@ -25,8 +24,10 @@ from app.models import (
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.matches import default_end_time
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import TeamMatchSchema
|
||||
|
||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||
@@ -41,6 +42,17 @@ def can_manage_team_match(team):
|
||||
return can_manage_org_team(current_user, team)
|
||||
|
||||
|
||||
def season_locked():
|
||||
"""Return True when the regular season is inactive for non-admins.
|
||||
|
||||
Admins bypass the lock. Coaches and managers may only schedule regular
|
||||
season matches while the season is active.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return False
|
||||
return not AppSettings.get_bool('season_active', default=False)
|
||||
|
||||
|
||||
@team_matches_bp.route('')
|
||||
@login_required
|
||||
def list_matches():
|
||||
@@ -100,7 +112,7 @@ def list_matches():
|
||||
teams=teams,
|
||||
match_data=match_data,
|
||||
pagination=matches_page,
|
||||
now=datetime.utcnow(),
|
||||
now=utc_now_naive(),
|
||||
)
|
||||
|
||||
|
||||
@@ -108,10 +120,16 @@ def list_matches():
|
||||
@login_required
|
||||
def create_match(team_id):
|
||||
"""Create a new regular-season team match."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if season_locked():
|
||||
flash(
|
||||
'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):
|
||||
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()
|
||||
prefill_date = request.args.get('date', '')
|
||||
@@ -204,7 +222,7 @@ def create_match(team_id):
|
||||
flash(
|
||||
_('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)
|
||||
|
||||
@@ -213,12 +231,12 @@ def create_match(team_id):
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing team match."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
if not can_manage_team_match(team):
|
||||
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':
|
||||
# The three date and time fields used to be checked one at a time,
|
||||
@@ -248,7 +266,7 @@ def edit_match(match_id):
|
||||
|
||||
db.session.commit()
|
||||
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(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
@@ -259,15 +277,22 @@ def edit_match(match_id):
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a team match."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
if season_locked():
|
||||
flash(
|
||||
'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):
|
||||
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.commit()
|
||||
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')
|
||||
@@ -293,10 +318,10 @@ def api_manageable_teams():
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle is_confirmed for a team match participant."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
participant = TeamMatchParticipant.query.get_or_404(participant_id)
|
||||
participant = db.get_or_404(TeamMatchParticipant, participant_id)
|
||||
if participant.team_match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
|
||||
+82
-74
@@ -3,9 +3,7 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
@@ -29,7 +27,9 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
from app.responders import respond_after_change
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
@@ -77,12 +77,12 @@ def my_teams():
|
||||
"""View the player's own teams with upcoming matches."""
|
||||
if not isinstance(current_user, Player):
|
||||
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
|
||||
|
||||
player_teams = current_user.get_org_teams()
|
||||
now = datetime.utcnow()
|
||||
now = utc_now_naive()
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
@@ -184,18 +184,18 @@ def create_team():
|
||||
"""Create a new organization team."""
|
||||
if not current_user.can_manage_teams():
|
||||
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:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
name = data['name']
|
||||
if OrgTeam.query.filter_by(name=name).first():
|
||||
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)
|
||||
manager = _staff_member(data['manager_id'], Manager)
|
||||
@@ -216,28 +216,28 @@ def create_team():
|
||||
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def edit_team(team_id):
|
||||
"""Edit an existing organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
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:
|
||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
name = data['name']
|
||||
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
|
||||
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
|
||||
|
||||
@@ -270,7 +270,7 @@ def edit_team(team_id):
|
||||
|
||||
db.session.commit()
|
||||
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'])
|
||||
@@ -294,11 +294,11 @@ def delete_team(team_id):
|
||||
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
|
||||
deletion narrows with it instead of staying the one way in.
|
||||
"""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
|
||||
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')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
name = team.name
|
||||
|
||||
@@ -329,29 +329,29 @@ def delete_team(team_id):
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def add_coach(team_id):
|
||||
"""Add a coach to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
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']:
|
||||
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'])
|
||||
if not coach or not _assignable(coach, Coach):
|
||||
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():
|
||||
flash(
|
||||
@@ -362,7 +362,7 @@ def add_coach(team_id):
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
team.coaches.append(coach)
|
||||
if not team.coach_id:
|
||||
@@ -372,29 +372,29 @@ def add_coach(team_id):
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.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>/add_manager', methods=['POST'])
|
||||
@login_required
|
||||
def add_manager(team_id):
|
||||
"""Add a manager to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
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']:
|
||||
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'])
|
||||
if not manager or not _assignable(manager, Manager):
|
||||
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():
|
||||
flash(
|
||||
@@ -405,7 +405,7 @@ def add_manager(team_id):
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
team.managers.append(manager)
|
||||
if not team.manager_id:
|
||||
@@ -415,21 +415,21 @@ def add_manager(team_id):
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.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_coach', methods=['POST'])
|
||||
@login_required
|
||||
def remove_coach(team_id):
|
||||
"""Remove a coach from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
if data['coach_id']:
|
||||
coach = db.session.get(User, data['coach_id'])
|
||||
@@ -443,21 +443,21 @@ def remove_coach(team_id):
|
||||
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def remove_manager(team_id):
|
||||
"""Remove a manager from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamStaffSchema())
|
||||
if data is None:
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
if data['manager_id']:
|
||||
manager = db.session.get(User, data['manager_id'])
|
||||
@@ -471,30 +471,30 @@ def remove_manager(team_id):
|
||||
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def add_player(team_id):
|
||||
"""Add a player to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
data = _posted(TeamPlayerSchema())
|
||||
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']:
|
||||
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']
|
||||
player = db.session.get(User, data['player_id'])
|
||||
if not player or not _assignable(player, Player):
|
||||
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()
|
||||
if existing:
|
||||
@@ -502,32 +502,32 @@ def add_player(team_id):
|
||||
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
||||
'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)
|
||||
db.session.add(tp)
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def remove_player(team_id, player_id):
|
||||
"""Remove a player from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(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()
|
||||
if not tp:
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
db.session.delete(tp)
|
||||
db.session.commit()
|
||||
@@ -535,7 +535,7 @@ def remove_player(team_id, player_id):
|
||||
_('%(username)s removed from %(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>/toggle_status/<int:player_id>', methods=['POST'])
|
||||
@@ -543,7 +543,7 @@ def remove_player(team_id, player_id):
|
||||
@login_required
|
||||
def toggle_player_status(team_id, player_id):
|
||||
"""Toggle a player's status between starter and substitute."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
@@ -567,33 +567,37 @@ def toggle_player_status(team_id, player_id):
|
||||
@login_required
|
||||
def add_team_note(team_id):
|
||||
"""Add a team improvement note (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
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'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_player_note(team_id, player_id):
|
||||
"""Add a personal note for a player (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
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 = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
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()
|
||||
if not tp:
|
||||
@@ -601,12 +605,16 @@ def add_player_note(team_id, player_id):
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
+345
-67
@@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations.
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
@@ -17,6 +16,7 @@ from app.models import (
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
AppSettings,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
@@ -32,7 +32,17 @@ from app.models import (
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.validators import PlayerSelectionSchema, TryoutSchema
|
||||
from app.responders import respond_after_change
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import (
|
||||
PlayerSelectionSchema,
|
||||
TryoutRegistrationStatusSchema,
|
||||
TryoutSchema,
|
||||
TryoutStatusSchema,
|
||||
TryoutTeamMemberPositionSchema,
|
||||
TryoutTeamMemberSchema,
|
||||
TryoutTeamSchema,
|
||||
)
|
||||
|
||||
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
|
||||
|
||||
@@ -42,9 +52,20 @@ def can_manage():
|
||||
return isinstance(current_user, (Admin, Manager))
|
||||
|
||||
|
||||
def tryouts_locked():
|
||||
"""Return True when tryouts are globally closed to coaches/managers.
|
||||
|
||||
Admins are always allowed to bypass the lock. Coaches and managers can
|
||||
only make changes when the global tryout switch is open.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return False
|
||||
return not AppSettings.get_bool('tryouts_open', default=True)
|
||||
|
||||
|
||||
def tryout_form_payload():
|
||||
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
||||
return form_payload(list_fields=('coach_ids',), optional_blank=())
|
||||
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
|
||||
|
||||
|
||||
def coaches_from_ids(coach_ids):
|
||||
@@ -60,6 +81,13 @@ def coaches_from_ids(coach_ids):
|
||||
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
|
||||
|
||||
|
||||
def managers_from_ids(manager_ids):
|
||||
"""The manager accounts behind these ids, filtered by role."""
|
||||
if not manager_ids:
|
||||
return []
|
||||
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load these users in one query, keyed by id.
|
||||
|
||||
@@ -79,6 +107,25 @@ def _users_by_id(user_ids):
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
def registration_lock_statement(tryout_id):
|
||||
"""The PostgreSQL row lock used by both registration entry points."""
|
||||
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
|
||||
|
||||
|
||||
def locked_tryout_or_404(tryout_id):
|
||||
"""Load and row-lock a tryout while a registration slot is decided.
|
||||
|
||||
PostgreSQL serializes concurrent registration attempts on this row. The
|
||||
duplicate check, capacity count and insert that follow therefore form
|
||||
one decision instead of three independently racing statements. SQLite
|
||||
ignores ``FOR UPDATE`` in tests, but production does not.
|
||||
"""
|
||||
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
|
||||
if tryout is None:
|
||||
abort(404)
|
||||
return tryout
|
||||
|
||||
|
||||
@tryouts_bp.route('')
|
||||
@login_required
|
||||
def list_tryouts():
|
||||
@@ -87,13 +134,20 @@ def list_tryouts():
|
||||
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
|
||||
"""
|
||||
tryouts = current_user.get_visible_tryouts()
|
||||
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
|
||||
return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive())
|
||||
|
||||
|
||||
@tryouts_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_tryout():
|
||||
"""Create a new tryout event. Requires Admin or Manager."""
|
||||
if tryouts_locked():
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
if not can_manage():
|
||||
flash(_('You do not have permission to create tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -134,12 +188,12 @@ def create_tryout():
|
||||
created_by=current_user.id,
|
||||
status='upcoming',
|
||||
target_org_team_id=data['target_org_team_id'],
|
||||
manager_id=data['manager_id'],
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tryout created successfully!'), 'success')
|
||||
@@ -152,7 +206,14 @@ def create_tryout():
|
||||
@login_required
|
||||
def edit_tryout(tryout_id):
|
||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||
@@ -195,8 +256,14 @@ def edit_tryout(tryout_id):
|
||||
tryout.location = data['location']
|
||||
tryout.max_players = data['max_players']
|
||||
tryout.target_org_team_id = data['target_org_team_id']
|
||||
tryout.manager_id = data['manager_id']
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
|
||||
# Only update staff lists when the form explicitly sends them.
|
||||
# An absent checkbox group (all unchecked or JS failed) means
|
||||
# "don't change", not "remove everyone".
|
||||
if 'coach_ids' in request.form:
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
if 'manager_ids' in request.form:
|
||||
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tryout updated successfully!'), 'success')
|
||||
@@ -209,14 +276,12 @@ def edit_tryout(tryout_id):
|
||||
@login_required
|
||||
def view_tryout(tryout_id):
|
||||
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
can_view = False
|
||||
if isinstance(current_user, Admin):
|
||||
can_view = True
|
||||
elif isinstance(current_user, Manager):
|
||||
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
|
||||
elif isinstance(current_user, Coach):
|
||||
elif isinstance(current_user, (Manager, Coach)):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Player):
|
||||
is_registered = (
|
||||
@@ -282,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])
|
||||
for row in member_rows:
|
||||
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]
|
||||
|
||||
@@ -398,7 +468,7 @@ def view_tryout(tryout_id):
|
||||
matches=matches,
|
||||
match_data=match_data,
|
||||
game_positions=GAME_POSITIONS,
|
||||
now=datetime.utcnow(),
|
||||
now=utc_now_naive(),
|
||||
)
|
||||
|
||||
|
||||
@@ -406,56 +476,74 @@ def view_tryout(tryout_id):
|
||||
@login_required
|
||||
def register_for_tryout(tryout_id):
|
||||
"""Register a player for a tryout. Only Players can self-register."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not isinstance(current_user, Player):
|
||||
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)
|
||||
|
||||
if tryout.status not in ['upcoming', 'in_progress']:
|
||||
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(
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
if existing:
|
||||
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:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
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)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_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 redirect(url_for('tryouts.list_tryouts'))
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
||||
tryout.status = new_status
|
||||
db.session.commit()
|
||||
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
try:
|
||||
data = TryoutStatusSchema().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))
|
||||
|
||||
tryout.status = data['status']
|
||||
db.session.commit()
|
||||
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
|
||||
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'])
|
||||
@login_required
|
||||
def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = Tryout.query.get_or_404(tryout_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 redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -463,31 +551,42 @@ def update_registration_status(tryout_id, player_id):
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first_or_404()
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['registered', 'attended', 'no_show']:
|
||||
registration.status = new_status
|
||||
db.session.commit()
|
||||
flash(_('Registration status updated.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
try:
|
||||
data = TryoutRegistrationStatusSchema().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))
|
||||
|
||||
registration.status = data['status']
|
||||
db.session.commit()
|
||||
flash(_('Registration status updated.'), 'success')
|
||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
||||
@login_required
|
||||
def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = locked_tryout_or_404(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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
try:
|
||||
data = PlayerSelectionSchema().load(form_payload())
|
||||
except ValidationError as 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']:
|
||||
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
|
||||
# account that has not been deactivated. The select this comes from now
|
||||
@@ -495,7 +594,7 @@ def register_player(tryout_id):
|
||||
player = db.session.get(User, data['player_id'])
|
||||
if not player or not isinstance(player, Player) or not player.is_active_account:
|
||||
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()
|
||||
if existing:
|
||||
@@ -503,31 +602,38 @@ def register_player(tryout_id):
|
||||
_('%(username)s is already registered for this tryout.', username=player.username),
|
||||
'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:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
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)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_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 redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
@@ -551,36 +657,54 @@ def remove_player(tryout_id, player_id):
|
||||
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
tryout = Tryout.query.get_or_404(tryout_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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
team_name = request.form.get('team_name')
|
||||
if team_name:
|
||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
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 = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(team_name)s" created!', 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>/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_team(tryout_id, team_id):
|
||||
"""Add a player to a tryout team."""
|
||||
team = Team.query.get_or_404(team_id)
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
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 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
|
||||
# allowed to manage tryout A was enough to modify a team belonging to
|
||||
@@ -588,10 +712,12 @@ def add_to_team(tryout_id, team_id):
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
if not player_id:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
try:
|
||||
data = TryoutTeamMemberSchema().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))
|
||||
player_id = data['player_id']
|
||||
|
||||
# Only players registered for this tryout may be placed on its teams.
|
||||
is_registered = (
|
||||
@@ -600,25 +726,177 @@ def add_to_team(tryout_id, team_id):
|
||||
)
|
||||
if not is_registered:
|
||||
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))
|
||||
|
||||
position = request.form.get('position', '')
|
||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||
if existing:
|
||||
flash(_('Player is already on this team.'), 'info')
|
||||
else:
|
||||
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
|
||||
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
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'])
|
||||
@login_required
|
||||
def delete_tryout(tryout_id):
|
||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_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(_('You do not have permission to delete this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
+35
-11
@@ -4,7 +4,6 @@ Nothing here touches the blueprint: these are plain functions, so a test
|
||||
can call them with a request context and nothing else.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from app.extensions import db
|
||||
@@ -13,7 +12,7 @@ from app.extensions import db
|
||||
# routes needed them as well (ARCH-005). Importing them from here still
|
||||
# works, so the thirty call sites in this package did not have to move.
|
||||
from app.forms import flash_validation_errors, form_payload # noqa: F401
|
||||
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
|
||||
from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag
|
||||
|
||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
||||
@@ -40,6 +39,10 @@ def pdf_upload_error(file, allowed_extensions):
|
||||
could put an arbitrary file on the server under a name the application
|
||||
later hands back for download (SEC-021).
|
||||
|
||||
The check now goes beyond the header: it parses the upload with pypdf
|
||||
and requires at least one parseable page, so a file with ``%PDF-``
|
||||
prepended or a renamed non-PDF is rejected.
|
||||
|
||||
Args:
|
||||
file: The uploaded FileStorage, or None.
|
||||
allowed_extensions: Extensions to accept, lowercase and without dot.
|
||||
@@ -54,29 +57,50 @@ def pdf_upload_error(file, allowed_extensions):
|
||||
if not (stem and dot) or extension.lower() not in allowed_extensions:
|
||||
return _('Only PDF files are allowed for contracts.')
|
||||
|
||||
# Fast-fail: the PDF signature must appear at the start of the stream.
|
||||
head = file.stream.read(len(PDF_SIGNATURE))
|
||||
file.stream.seek(0)
|
||||
if head != PDF_SIGNATURE:
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
|
||||
# Full-content validation: parse with pypdf and require at least one
|
||||
# page. A file with a forged header but no real PDF structure (e.g. a
|
||||
# renamed executable with ``%PDF-`` prepended) will fail here.
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PyPdfError
|
||||
|
||||
reader = PdfReader(file.stream, strict=False)
|
||||
if len(reader.pages) == 0:
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
except (PyPdfError, ValueError, TypeError):
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
finally:
|
||||
file.stream.seek(0)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def update_user_gamertags(user, selected_games):
|
||||
"""Update gamertags for a user based on form input."""
|
||||
"""Update gamertags for a user from validated dynamic form fields."""
|
||||
from app.forms import form_gamertags
|
||||
|
||||
submitted = form_gamertags(selected_games)
|
||||
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||
for game in selected_games:
|
||||
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
||||
platform = (
|
||||
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
||||
)
|
||||
payload = submitted.get(game)
|
||||
existing = existing_gamertags.get(game)
|
||||
if gamertag:
|
||||
if payload:
|
||||
if existing:
|
||||
existing.gamertag = gamertag
|
||||
existing.platform = platform
|
||||
existing.gamertag = payload['gamertag']
|
||||
existing.platform = payload['platform']
|
||||
else:
|
||||
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
|
||||
gt = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=payload['gamertag'],
|
||||
platform=payload['platform'],
|
||||
)
|
||||
db.session.add(gt)
|
||||
elif existing:
|
||||
db.session.delete(existing)
|
||||
|
||||
@@ -44,6 +44,7 @@ from app.routes.users._shared import (
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.storage import discard_documents
|
||||
from app.timeslots import total_hours
|
||||
from app.validators import CreateUserSchema, EditUserSchema
|
||||
|
||||
|
||||
@@ -59,7 +60,39 @@ def list_users():
|
||||
# query without a stable ORDER BY can show the same row twice and never
|
||||
# show another (MNT-14).
|
||||
page = paginate(User.query.order_by(User.role, User.username, User.id))
|
||||
return render_template('pages/users.html', users=page.items, pagination=page, roles=USER_TYPES)
|
||||
|
||||
# Build a per-user hours map for the current page with two bounded
|
||||
# queries (one per availability table), avoiding an N+1 per row.
|
||||
user_ids = [u.id for u in page.items]
|
||||
hours_by_user: dict[int, float] = {}
|
||||
|
||||
for slots, fk in [
|
||||
(
|
||||
PlayerDisponibility.query.filter(
|
||||
PlayerDisponibility.player_id.in_(user_ids),
|
||||
).all(),
|
||||
'player_id',
|
||||
),
|
||||
(
|
||||
CoachAvailability.query.filter(
|
||||
CoachAvailability.coach_id.in_(user_ids),
|
||||
).all(),
|
||||
'coach_id',
|
||||
),
|
||||
]:
|
||||
buckets: dict[int, list] = {}
|
||||
for slot in slots:
|
||||
buckets.setdefault(getattr(slot, fk), []).append(slot)
|
||||
for uid, group in buckets.items():
|
||||
hours_by_user[uid] = total_hours(group)
|
||||
|
||||
return render_template(
|
||||
'pages/users.html',
|
||||
users=page.items,
|
||||
pagination=page,
|
||||
roles=USER_TYPES,
|
||||
hours=hours_by_user,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@@ -70,7 +103,7 @@ def edit_user(user_id):
|
||||
flash(_('Only the president can edit users.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
actor_name, actor_id = current_user.username, current_user.id
|
||||
@@ -121,6 +154,12 @@ def edit_user(user_id):
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return _rerender()
|
||||
|
||||
try:
|
||||
update_user_gamertags(user, selected_games)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return _rerender()
|
||||
|
||||
role_changed = user.role != role
|
||||
previous_role = user.role
|
||||
|
||||
@@ -183,8 +222,6 @@ def edit_user(user_id):
|
||||
user.discord_user_id = discord_user_id or None
|
||||
user.league_os_profile = league_os_profile or None
|
||||
|
||||
update_user_gamertags(user, selected_games)
|
||||
|
||||
# Blank means "keep the current password"; anything else has already
|
||||
# been checked against the policy by the schema.
|
||||
password = validated.get('password')
|
||||
@@ -249,7 +286,7 @@ def delete_user(user_id):
|
||||
flash(_('You cannot delete your own account.'), 'danger')
|
||||
return redirect(url_for('users.list_users'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
|
||||
Evaluation.query.filter(
|
||||
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
|
||||
@@ -375,5 +412,20 @@ def create_user():
|
||||
@login_required
|
||||
def view_user(user_id):
|
||||
"""View a public profile for any user."""
|
||||
user = User.query.get_or_404(user_id)
|
||||
return render_template('pages/view_user.html', profile_user=user)
|
||||
user = db.get_or_404(User, user_id)
|
||||
|
||||
# Load availability for players and coaches so the public profile
|
||||
# can render a read-only weekly grid.
|
||||
availability = None
|
||||
if user.role == 'player':
|
||||
availability = user.disponibilities
|
||||
elif user.role == 'coach':
|
||||
availability = user.coach_availabilities
|
||||
availability_hours = total_hours(availability) if availability else None
|
||||
|
||||
return render_template(
|
||||
'pages/view_user.html',
|
||||
profile_user=user,
|
||||
availability=availability,
|
||||
availability_hours=availability_hours,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,7 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
|
||||
sessions.
|
||||
"""
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask import jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
@@ -193,7 +192,7 @@ def clear_disponibilities():
|
||||
@login_required
|
||||
def delete_disponibility(disponibility_id):
|
||||
"""Delete a disponibility block."""
|
||||
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
|
||||
disponibility = db.get_or_404(PlayerDisponibility, disponibility_id)
|
||||
if disponibility.player_id != current_user.id:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
db.session.delete(disponibility)
|
||||
@@ -201,60 +200,50 @@ def delete_disponibility(disponibility_id):
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
||||
@users_bp.route('/coach-availability', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def manage_coach_availability():
|
||||
"""Manage coach availability for One on One sessions."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage availability.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
|
||||
# Validate everything before deleting anything.
|
||||
#
|
||||
# This route replaces the coach's availability: it deleted every
|
||||
# existing slot and then re-added the ones it could parse, skipping
|
||||
# the rest in silence and answering `{'success': true}`. A payload
|
||||
# the browser mangled therefore wiped a coach's bookable hours and
|
||||
# reported success — and one-on-one requests are refused against
|
||||
# exactly this table, so the coach became unbookable with nothing to
|
||||
# show for it. Refusing the whole batch is the only safe answer when
|
||||
# the operation is a replacement (MNT-12).
|
||||
if rejected:
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Invalid slots; nothing was changed.',
|
||||
'rejected': rejected,
|
||||
}
|
||||
), 400
|
||||
# Validate everything before deleting anything.
|
||||
#
|
||||
# This route replaces the coach's availability: it deleted every
|
||||
# existing slot and then re-added the ones it could parse, skipping
|
||||
# the rest in silence and answering `{'success': true}`. A payload
|
||||
# the browser mangled therefore wiped a coach's bookable hours and
|
||||
# reported success — and one-on-one requests are refused against
|
||||
# exactly this table, so the coach became unbookable with nothing to
|
||||
# show for it. Refusing the whole batch is the only safe answer when
|
||||
# the operation is a replacement (MNT-12).
|
||||
if rejected:
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Invalid slots; nothing was changed.',
|
||||
'rejected': rejected,
|
||||
}
|
||||
), 400
|
||||
|
||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||
|
||||
for slot in accepted:
|
||||
start_time = slot['start_time']
|
||||
db.session.add(
|
||||
CoachAvailability(
|
||||
coach_id=current_user.id,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
for slot in accepted:
|
||||
start_time = slot['start_time']
|
||||
db.session.add(
|
||||
CoachAvailability(
|
||||
coach_id=current_user.id,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'saved': len(accepted)})
|
||||
|
||||
existing_availability = CoachAvailability.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).all()
|
||||
|
||||
return render_template(
|
||||
'pages/coach_availability.html', existing_availability=existing_availability
|
||||
)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'saved': len(accepted)})
|
||||
|
||||
|
||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||
from flask import flash, render_template, request, send_file, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
@@ -12,14 +11,23 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
||||
from app.models.admin_settings import AppSettings
|
||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users._shared import (
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
pdf_upload_error,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.storage import CONTRACTS_DIR, document_path
|
||||
from app.storage import (
|
||||
CONTRACTS_DIR,
|
||||
GoogleDriveStorageError,
|
||||
is_google_drive_path,
|
||||
open_document,
|
||||
store_uploaded_document,
|
||||
)
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import UploadContractSchema
|
||||
|
||||
|
||||
@@ -43,6 +51,17 @@ def manageable_players():
|
||||
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
|
||||
|
||||
def document_uploads_locked():
|
||||
"""Return True when document uploads are globally disabled.
|
||||
|
||||
Admins are always allowed to bypass the lock. Managers, coaches, and
|
||||
players are blocked when the toggle is off.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return False
|
||||
return not AppSettings.get_bool('document_uploads_enabled', default=True)
|
||||
|
||||
|
||||
@users_bp.route('/contracts')
|
||||
@login_required
|
||||
def list_contracts():
|
||||
@@ -84,7 +103,11 @@ def upload_contract():
|
||||
"""Upload a contract for a player."""
|
||||
if not isinstance(current_user, (Admin, Manager, Coach)):
|
||||
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():
|
||||
flash(_('Document uploads are currently disabled.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
players = manageable_players()
|
||||
|
||||
@@ -103,15 +126,15 @@ def upload_contract():
|
||||
|
||||
if not can_manage_player_contract(current_user, player_id):
|
||||
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')
|
||||
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
return respond_after_change(url_for('users.upload_contract'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
player_teams = player.get_org_teams()
|
||||
team = player_teams[0] if player_teams else None
|
||||
|
||||
@@ -126,9 +149,13 @@ def upload_contract():
|
||||
if team:
|
||||
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
||||
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file.save(absolute_path)
|
||||
try:
|
||||
stored_path = store_uploaded_document(file, relative_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(
|
||||
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
|
||||
)
|
||||
return respond_after_change(url_for('users.upload_contract'))
|
||||
|
||||
contract = Contract(
|
||||
player_id=player_id,
|
||||
@@ -136,7 +163,7 @@ def upload_contract():
|
||||
uploaded_by_id=current_user.id,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=relative_path,
|
||||
file_path=stored_path,
|
||||
notes=notes if notes else None,
|
||||
)
|
||||
db.session.add(contract)
|
||||
@@ -145,7 +172,7 @@ def upload_contract():
|
||||
_('Contract uploaded successfully for %(username)s!', username=player.username),
|
||||
'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)
|
||||
|
||||
@@ -154,58 +181,75 @@ def upload_contract():
|
||||
@login_required
|
||||
def upload_signed_contract(contract_id):
|
||||
"""Upload a signed contract (player only)."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
contract = db.get_or_404(Contract, contract_id)
|
||||
if not contract.can_upload_signed(current_user):
|
||||
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():
|
||||
flash(_('Document uploads are currently disabled.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
file = request.files.get('signed_file')
|
||||
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
||||
if error:
|
||||
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_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
file.save(document_path(signed_path))
|
||||
# Legacy local records keep their signed copy beside the original. Drive
|
||||
# identifiers are opaque rather than filenames, so new Drive records use
|
||||
# a fresh logical path and receive a second Drive ID.
|
||||
signed_path = (
|
||||
os.path.join(CONTRACTS_DIR, signed_filename)
|
||||
if is_google_drive_path(contract.file_path)
|
||||
else contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
)
|
||||
try:
|
||||
stored_signed_path = store_uploaded_document(file, signed_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
|
||||
contract.signed_filename = signed_filename
|
||||
contract.signed_file_path = signed_path
|
||||
contract.signed_file_path = stored_signed_path
|
||||
contract.status = 'signed'
|
||||
contract.signed_at = datetime.utcnow()
|
||||
contract.signed_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
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')
|
||||
@login_required
|
||||
def download_contract(contract_id):
|
||||
"""Download a contract file."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
contract = db.get_or_404(Contract, contract_id)
|
||||
if not contract.can_view(current_user):
|
||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.original_filename,
|
||||
)
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
try:
|
||||
document = open_document(contract.file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
return send_file(document, as_attachment=True, download_name=contract.original_filename)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download_signed')
|
||||
@login_required
|
||||
def download_signed_contract(contract_id):
|
||||
"""Download a signed contract file."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
contract = db.get_or_404(Contract, contract_id)
|
||||
if not contract.can_view(current_user):
|
||||
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:
|
||||
flash(_('No signed contract available.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.signed_file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.signed_filename,
|
||||
)
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
try:
|
||||
document = open_document(contract.signed_file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return respond_after_change(url_for('users.list_contracts'))
|
||||
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
||||
|
||||
+127
-65
@@ -7,23 +7,33 @@ it reads exactly what the coach routes write.
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Coach,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
Team,
|
||||
TeamMember,
|
||||
TeamNote,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
|
||||
from app.permissions import (
|
||||
coach_can_access_player,
|
||||
coach_org_teams,
|
||||
coach_player_ids,
|
||||
coach_tryouts,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import NoteContentSchema, PersonalNoteSchema
|
||||
|
||||
|
||||
@users_bp.route('/my-notes')
|
||||
@@ -116,23 +126,25 @@ def notes_dashboard():
|
||||
)
|
||||
|
||||
# For context selectors in the form
|
||||
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
|
||||
# previous selector mixed the two namespaces and could either attach the
|
||||
# note to an unrelated team with the same integer id or fail its FK.
|
||||
# Every context list now comes from the tryouts this coach may manage.
|
||||
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
|
||||
tryout_ids = [tryout.id for tryout in tryouts]
|
||||
matches = (
|
||||
Match.query.filter(
|
||||
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
||||
)
|
||||
Match.query.filter(Match.tryout_id.in_(tryout_ids))
|
||||
.order_by(Match.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
tryouts = (
|
||||
Tryout.query.filter_by(
|
||||
created_by=current_user.id,
|
||||
)
|
||||
.order_by(Tryout.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
teams = (
|
||||
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
|
||||
return render_template(
|
||||
'pages/notes.html',
|
||||
@@ -165,21 +177,25 @@ def manage_team_notes():
|
||||
org_teams = coach_org_teams(current_user)
|
||||
if not org_teams:
|
||||
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]
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(
|
||||
org_team_id=org_team.id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes saved successfully!'), 'success')
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
note = TeamNote(
|
||||
org_team_id=org_team.id,
|
||||
coach_id=current_user.id,
|
||||
content=data['content'],
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes saved successfully!'), 'success')
|
||||
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -195,31 +211,31 @@ def manage_personal_notes():
|
||||
flash(_('Only coaches can manage personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
player_id = data['player_id']
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
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):
|
||||
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(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
content=data['content'],
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
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'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -235,37 +251,61 @@ def add_personal_note():
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
match_id = request.form.get('match_id', type=int)
|
||||
tryout_id = request.form.get('tryout_id', type=int)
|
||||
team_id_str = request.form.get('team_id')
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
player_id = data['player_id']
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
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):
|
||||
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']:
|
||||
match = db.get_or_404(Match, data['match_id'])
|
||||
if not current_user.can_manage_this_tryout(match.tryout):
|
||||
flash(_('You cannot use that match as note context.'), 'danger')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
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')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
if data['tryout_id']:
|
||||
tryout = db.get_or_404(Tryout, data['tryout_id'])
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You cannot use that tryout as note context.'), 'danger')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
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')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
if data['team_id']:
|
||||
team = db.get_or_404(Team, data['team_id'])
|
||||
if not current_user.can_manage_this_tryout(team.tryout):
|
||||
flash(_('You cannot use that team as note context.'), 'danger')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
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')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
match_id=match_id if match_id else None,
|
||||
tryout_id=tryout_id if tryout_id else None,
|
||||
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
||||
content=data['content'],
|
||||
match_id=data['match_id'],
|
||||
tryout_id=data['tryout_id'],
|
||||
team_id=data['team_id'],
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
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'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -281,7 +321,10 @@ def add_note_from_tryout(tryout_id):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
preselected_player_id = request.args.get('player_id', type=int)
|
||||
|
||||
# Get registrations as players for the select list
|
||||
@@ -289,21 +332,29 @@ def add_note_from_tryout(tryout_id):
|
||||
players = [r.player for r in registrations if r.player]
|
||||
|
||||
if request.method == 'POST':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
player_id = data['player_id']
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
if data['tryout_id'] not in (None, tryout_id):
|
||||
flash(_('Invalid tryout context.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
|
||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
content=data['content'],
|
||||
tryout_id=tryout_id,
|
||||
)
|
||||
db.session.add(note)
|
||||
@@ -334,7 +385,10 @@ def add_note_from_match(match_id):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
match_obj = Match.query.get_or_404(match_id)
|
||||
match_obj = db.get_or_404(Match, match_id)
|
||||
if not current_user.can_manage_this_tryout(match_obj.tryout):
|
||||
flash(_('You do not have permission to add notes for this match.'), 'danger')
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
# Get participants as players for the select list
|
||||
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
|
||||
@@ -343,21 +397,29 @@ def add_note_from_match(match_id):
|
||||
preselected_player_id = request.args.get('player_id', type=int)
|
||||
|
||||
if request.method == 'POST':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
player_id = data['player_id']
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
if data['match_id'] not in (None, match_id):
|
||||
flash(_('Invalid match context.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
|
||||
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
|
||||
flash(_('That player did not participate in this match.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
content=data['content'],
|
||||
match_id=match_id,
|
||||
)
|
||||
db.session.add(note)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""One-on-one sessions between a player and their coach."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
@@ -10,9 +8,11 @@ from marshmallow import ValidationError
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.services.notifications import send_discord_notification
|
||||
from app.validators import OneOnOneRequestSchema
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
|
||||
@@ -65,13 +65,13 @@ def one_on_one():
|
||||
if request.method == 'POST':
|
||||
if not coach:
|
||||
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:
|
||||
data = OneOnOneRequestSchema().load(form_payload())
|
||||
except ValidationError as 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']
|
||||
start_time = data['start_time']
|
||||
@@ -91,7 +91,7 @@ def one_on_one():
|
||||
|
||||
if not is_available:
|
||||
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(
|
||||
player_id=current_user.id,
|
||||
@@ -119,7 +119,7 @@ def one_on_one():
|
||||
)
|
||||
|
||||
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
|
||||
from datetime import date as date_cls
|
||||
@@ -166,24 +166,24 @@ def accept_one_on_one(request_id):
|
||||
flash(_('Only coaches can accept One on One requests.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
request_obj = OneOnOneRequest.query.get_or_404(request_id)
|
||||
request_obj = db.get_or_404(OneOnOneRequest, request_id)
|
||||
|
||||
if request_obj.coach_id != current_user.id:
|
||||
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':
|
||||
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
|
||||
request_obj.status = 'approved'
|
||||
request_obj.responded_at = datetime.utcnow()
|
||||
request_obj.responded_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
from app.discord import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
@@ -205,7 +205,7 @@ def accept_one_on_one(request_id):
|
||||
),
|
||||
'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'])
|
||||
@@ -216,28 +216,33 @@ def reject_one_on_one(request_id):
|
||||
flash(_('Only coaches can reject One on One requests.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
request_obj = OneOnOneRequest.query.get_or_404(request_id)
|
||||
request_obj = db.get_or_404(OneOnOneRequest, request_id)
|
||||
|
||||
if request_obj.coach_id != current_user.id:
|
||||
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':
|
||||
flash(_('This request has already been processed.'), 'info')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
rejection_reason = request.form.get('rejection_reason', '').strip()
|
||||
try:
|
||||
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
rejection_reason = data['rejection_reason']
|
||||
player = request_obj.player
|
||||
|
||||
request_obj.status = 'rejected'
|
||||
request_obj.responded_at = datetime.utcnow()
|
||||
request_obj.responded_at = utc_now_naive()
|
||||
if rejection_reason:
|
||||
request_obj.coach_rejection_message = rejection_reason
|
||||
db.session.commit()
|
||||
|
||||
# Notify player via Discord (same message as if rejected through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
from app.discord import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
@@ -260,4 +265,4 @@ def reject_one_on_one(request_id):
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
return respond_after_change(url_for('users.notes_dashboard'))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""The signed-in user's own profile."""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask import flash, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
@@ -16,6 +16,7 @@ from app.models import (
|
||||
Player,
|
||||
User,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users._shared import (
|
||||
flash_validation_errors,
|
||||
form_payload,
|
||||
@@ -98,6 +99,18 @@ def edit_profile():
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
try:
|
||||
update_user_gamertags(current_user, selected_games)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
current_user.username = username
|
||||
current_user.full_name = full_name
|
||||
current_user.email = email
|
||||
@@ -106,8 +119,6 @@ def edit_profile():
|
||||
current_user.discord_username = discord_username or None
|
||||
current_user.league_os_profile = league_os_profile or None
|
||||
|
||||
update_user_gamertags(current_user, selected_games)
|
||||
|
||||
# Blank means "keep the current password"; anything else has already
|
||||
# been checked against the policy by the schema.
|
||||
password = validated.get('password')
|
||||
@@ -119,7 +130,7 @@ def edit_profile():
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Profile updated successfully!'), 'success')
|
||||
return redirect(url_for('users.profile'))
|
||||
return respond_after_change(url_for('users.profile'))
|
||||
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
|
||||
@@ -53,7 +53,7 @@ def send_discord_notification(
|
||||
|
||||
if coach_discord_id:
|
||||
try:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
from app.discord import send_one_on_one_dm
|
||||
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name,
|
||||
@@ -76,7 +76,7 @@ def send_discord_notification(
|
||||
# coach has no id of their own. Queueing it cannot raise (see _enqueue).
|
||||
if DISCORD_WEBHOOK_URL.isdigit():
|
||||
if not coach_discord_id:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
from app.discord import send_one_on_one_dm
|
||||
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name,
|
||||
|
||||
@@ -15,7 +15,7 @@ two.
|
||||
|
||||
import logging
|
||||
|
||||
from app.discord_bot import send_schedule_notification
|
||||
from app.discord import send_schedule_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,7 +60,9 @@ def zip_participants(player_ids, participant_ids):
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -73,6 +75,8 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
|
||||
attendance lands on the right row.
|
||||
fallback_id: Reference to use when a participant row has no id —
|
||||
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:
|
||||
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_time=event_time,
|
||||
reference_id=participant_id or fallback_id,
|
||||
change_kind=change_kind,
|
||||
)
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
@@ -1256,6 +1256,64 @@ a:hover { color: var(--primary-dark); }
|
||||
color: white !important;
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
/* Read-only weekly availability grid (public profile) */
|
||||
.readonly-availability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ra-day-column {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.ra-day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 6px;
|
||||
color: var(--primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ra-slot {
|
||||
padding: 4px 6px;
|
||||
margin: 3px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ra-slot.selected {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.ra-slot.ra-empty {
|
||||
color: var(--gray-400);
|
||||
border: 1px dashed var(--gray-300);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.readonly-availability-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.readonly-availability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.merged-disponibility-time-block.high-availability {
|
||||
background: var(--success);
|
||||
@@ -2037,3 +2095,30 @@ a:hover { color: var(--primary-dark); }
|
||||
.honeypot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Batch Evaluation - 2 cards wide layout */
|
||||
.batch-eval-form {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.batch-eval-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.batch-eval-grid .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
|
||||
.batch-eval-grid .form-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.batch-eval-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+212
-17
@@ -420,22 +420,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
initDraggableBlocks();
|
||||
|
||||
/**
|
||||
* Auto-dismiss flash alerts after 5 seconds.
|
||||
* Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
|
||||
*/
|
||||
const alerts = document.querySelectorAll('.alert-dismissible');
|
||||
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);
|
||||
});
|
||||
armAlertAutoDismiss();
|
||||
});
|
||||
/* =========================================================================
|
||||
Declarative behaviours — replacing inline event handlers
|
||||
@@ -530,13 +517,31 @@ document.addEventListener('change', function (event) {
|
||||
* Confirmation before a destructive submit.
|
||||
*
|
||||
* <form data-confirm="Delete this match?">
|
||||
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
|
||||
* data-confirm-input-message="Type DELETE to continue."
|
||||
* data-confirm="Delete this record?">
|
||||
*
|
||||
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
||||
* markup where it can be translated.
|
||||
*/
|
||||
document.addEventListener('submit', function (event) {
|
||||
const form = event.target.closest('[data-confirm]');
|
||||
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputName = form.getAttribute('data-confirm-input');
|
||||
if (inputName) {
|
||||
const input = form.elements.namedItem(inputName);
|
||||
const expectedValue = form.getAttribute('data-confirm-value') || '';
|
||||
if (!input || input.value.trim() !== expectedValue) {
|
||||
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.confirm(form.getAttribute('data-confirm'))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -575,7 +580,7 @@ document.addEventListener('input', function (event) {
|
||||
document.addEventListener('change', function (event) {
|
||||
const control = event.target.closest('[data-submit-on-change]');
|
||||
if (control && control.form) {
|
||||
control.form.submit();
|
||||
submitForm(control.form);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -595,3 +600,193 @@ document.addEventListener('change', function (event) {
|
||||
window.location.href = select.getAttribute('data-navigate')
|
||||
.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 = '×';
|
||||
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;
|
||||
+82
-7
@@ -28,8 +28,23 @@ absolute path and are returned untouched, so this change needs no data
|
||||
migration and can ship before Alembic does (DB-002).
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.google_drive import (
|
||||
GoogleDriveStorageError,
|
||||
)
|
||||
from app.google_drive import (
|
||||
delete_file as delete_google_drive_file,
|
||||
)
|
||||
from app.google_drive import (
|
||||
download_file as download_google_drive_file,
|
||||
)
|
||||
from app.google_drive import (
|
||||
upload_file as upload_google_drive_file,
|
||||
)
|
||||
|
||||
#: Environment override for the document root. What a release-directory
|
||||
#: deployment sets, to a path outside the releases — alongside them, not
|
||||
#: inside whichever one is current.
|
||||
@@ -45,6 +60,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
|
||||
#: Sub-directory holding uploaded contracts, under the document root.
|
||||
CONTRACTS_DIR = 'contrats signés'
|
||||
|
||||
#: Database marker for documents stored remotely. Existing rows continue to
|
||||
#: hold relative or absolute filesystem paths, so switching storage does not
|
||||
#: invalidate contracts already uploaded before the Google Drive move.
|
||||
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
|
||||
|
||||
#: Storage backends deliberately stay explicit. Google Drive is the
|
||||
#: production default; local disk exists only for legacy rows and isolated
|
||||
#: test/development environments.
|
||||
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
|
||||
|
||||
|
||||
def project_root():
|
||||
"""Absolute path of the project, derived from this file's location.
|
||||
@@ -91,6 +116,56 @@ def _rooted(env_name, default_name):
|
||||
return os.path.join(project_root(), default_name)
|
||||
|
||||
|
||||
def document_storage_backend():
|
||||
"""Return the configured backend for new document uploads."""
|
||||
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'local').strip().lower()
|
||||
if backend not in {'google_drive', 'local'}:
|
||||
raise ValueError(
|
||||
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
def is_google_drive_path(stored_path):
|
||||
"""Whether a database path references a Google Drive file."""
|
||||
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
|
||||
|
||||
|
||||
def _google_drive_id(stored_path):
|
||||
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
|
||||
if not file_id:
|
||||
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
|
||||
return file_id
|
||||
|
||||
|
||||
def store_uploaded_document(file_storage, relative_path):
|
||||
"""Store an uploaded document and return its durable database reference.
|
||||
|
||||
Local storage retains the relative-path format used by existing rows. A
|
||||
Google Drive upload returns an opaque Drive file identifier prefixed with
|
||||
``gdrive://`` so it cannot be mistaken for a filesystem path.
|
||||
"""
|
||||
if document_storage_backend() == 'local':
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file_storage.save(absolute_path)
|
||||
return relative_path
|
||||
|
||||
file_id = upload_google_drive_file(
|
||||
stream=file_storage.stream,
|
||||
filename=os.path.basename(relative_path),
|
||||
mimetype=file_storage.mimetype or 'application/pdf',
|
||||
)
|
||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||
|
||||
|
||||
def open_document(stored_path):
|
||||
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||
if is_google_drive_path(stored_path):
|
||||
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
|
||||
return document_path(stored_path)
|
||||
|
||||
|
||||
def discard_documents(stored_paths):
|
||||
"""Remove these documents from disk. Returns how many went (DATA-012).
|
||||
|
||||
@@ -107,27 +182,27 @@ def discard_documents(stored_paths):
|
||||
A path that cannot be removed is logged and skipped. Nothing here should
|
||||
be able to abort the deletion of an account.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
removed = 0
|
||||
for stored_path in stored_paths:
|
||||
if not stored_path:
|
||||
continue
|
||||
target = document_path(stored_path)
|
||||
try:
|
||||
os.remove(target)
|
||||
if is_google_drive_path(stored_path):
|
||||
delete_google_drive_file(_google_drive_id(stored_path))
|
||||
else:
|
||||
os.remove(document_path(stored_path))
|
||||
removed += 1
|
||||
except FileNotFoundError:
|
||||
# Already gone. Two contracts sharing a stem, or a previous
|
||||
# attempt: not a problem, and not worth an error line.
|
||||
logger.info('Document already absent: %s', target)
|
||||
except OSError as exc:
|
||||
logger.info('Document already absent: %s', stored_path)
|
||||
except (GoogleDriveStorageError, OSError) as exc:
|
||||
logger.error(
|
||||
'Could not remove %s (%s). It is now an orphan: no database row '
|
||||
'refers to it, so nothing in the application will ever offer to '
|
||||
'delete it again.',
|
||||
target,
|
||||
stored_path,
|
||||
exc,
|
||||
)
|
||||
return removed
|
||||
|
||||
@@ -42,7 +42,11 @@ from urllib.parse import unquote, urlparse
|
||||
# to be — see DOCUMENTS_DIR below.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above
|
||||
from app.storage import ( # noqa: E402 — needs the path above
|
||||
backups_root,
|
||||
document_storage_backend,
|
||||
documents_root,
|
||||
)
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = backups_root()
|
||||
@@ -55,8 +59,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
|
||||
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
||||
# keep uploads outside the releases, and docs/deployment.md now tells the
|
||||
# operator to set it — at which point this script archived a directory the
|
||||
# application had never written to. It does not fail on a missing directory
|
||||
# either; it prints "No documents directory found", skips, and exits 0.
|
||||
# application had never written to. A missing or unreadable document store
|
||||
# is now a failed full-backup run rather than a database-only green result.
|
||||
#
|
||||
# So the more correctly an operator followed the deployment documentation,
|
||||
# the more certainly their contract backups were empty (OBS-006).
|
||||
@@ -262,38 +266,105 @@ def backup_documents():
|
||||
module happened to be imported with.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if there is nothing to
|
||||
archive. Signed contracts live only on disk, so losing this
|
||||
directory loses the documents themselves.
|
||||
str: Path to the created archive.
|
||||
|
||||
Raises:
|
||||
BackupError: If the configured store is absent or cannot be archived.
|
||||
Signed contracts live only on disk, so a database-only run must
|
||||
never be reported as a complete backup.
|
||||
"""
|
||||
documents_dir = documents_root()
|
||||
|
||||
if not os.path.exists(documents_dir):
|
||||
# Says where it looked. The previous message named no path, so an
|
||||
# operator who had moved the documents read it as "there are no
|
||||
# documents" rather than "I am looking in the wrong place".
|
||||
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
|
||||
return None
|
||||
raise BackupError(f'Documents directory does not exist: {documents_dir}')
|
||||
if not os.path.isdir(documents_dir):
|
||||
raise BackupError(f'Documents path is not a directory: {documents_dir}')
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
||||
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
|
||||
# This runs after the database dump has already succeeded. Letting
|
||||
# anything through here would abort the script with a traceback and
|
||||
# take the one part that worked down with it. Reported to stdout, in
|
||||
# the format the rest of this script uses; it has no logger.
|
||||
print(f'[ERROR] Document backup failed: {exc}')
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
|
||||
raise BackupError(f'Document backup failed: {exc}') from exc
|
||||
|
||||
zip_path = f'{archive_basename}.zip'
|
||||
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
|
||||
raise BackupError('Document archiver reported success but produced an empty file.')
|
||||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||
return zip_path
|
||||
|
||||
|
||||
def backup_drive_documents():
|
||||
"""Download every file from the configured Google Drive folder and archive it.
|
||||
|
||||
Called **in addition to** ``backup_documents()`` when the storage backend is
|
||||
``google_drive``. When the backend is ``local`` this function silently
|
||||
skips — local backups already handled by ``backup_documents()``.
|
||||
|
||||
Returns:
|
||||
str | None: Path to the created archive, or None when skipped.
|
||||
|
||||
Raises:
|
||||
BackupError: If Drive is configured but listing or downloading fails.
|
||||
"""
|
||||
if document_storage_backend() != 'google_drive':
|
||||
print('[SKIP] Google Drive is not configured; local documents only.')
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.google_drive import (
|
||||
GoogleDriveStorageError,
|
||||
list_folder_files,
|
||||
)
|
||||
from app.google_drive import (
|
||||
download_file as download_drive_file,
|
||||
)
|
||||
except ImportError:
|
||||
raise BackupError(
|
||||
'Google Drive dependencies are not installed. Install requirements.txt again.'
|
||||
) from None
|
||||
|
||||
print('[INFO] Listing Google Drive contract folder...')
|
||||
try:
|
||||
files = list_folder_files()
|
||||
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
|
||||
raise BackupError(f'Google Drive listing failed: {exc}') from exc
|
||||
|
||||
if not files:
|
||||
print('[INFO] Google Drive folder is empty; no Drive documents to back up.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_drive_{timestamp}')
|
||||
|
||||
download = {}
|
||||
for file_id, name in files:
|
||||
print(f' Downloading {name} ({file_id})...')
|
||||
try:
|
||||
download[name] = download_drive_file(file_id)
|
||||
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
|
||||
raise BackupError(f'Google Drive download failed for {name}: {exc}') from exc
|
||||
|
||||
try:
|
||||
import zipfile
|
||||
|
||||
zip_path = f'{archive_basename}.zip'
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in download.items():
|
||||
zf.writestr(name, data)
|
||||
except Exception as exc: # noqa: BLE001 — boundary
|
||||
raise BackupError(f'Drive document archive failed: {exc}') from exc
|
||||
|
||||
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
|
||||
raise BackupError('Drive document archiver reported success but produced an empty file.')
|
||||
|
||||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
print(f'[OK] Drive documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||
return zip_path
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||
if not os.path.exists(BACKUP_DIR):
|
||||
@@ -361,15 +432,26 @@ def main(argv=None):
|
||||
return 1
|
||||
|
||||
verified = verify_backup(backup_path)
|
||||
backup_documents()
|
||||
documents_ok = True
|
||||
try:
|
||||
backup_documents()
|
||||
except BackupError as exc:
|
||||
documents_ok = False
|
||||
print(f'[ERROR] {exc}')
|
||||
drive_ok = True
|
||||
try:
|
||||
backup_drive_documents()
|
||||
except BackupError as exc:
|
||||
drive_ok = False
|
||||
print(f'[ERROR] {exc}')
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if verified:
|
||||
if verified and documents_ok and drive_ok:
|
||||
print('=== Backup completed successfully ===')
|
||||
return 0
|
||||
|
||||
print('=== Backup FAILED verification — do not rely on this archive ===')
|
||||
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Obtain a Google Drive refresh token for the application.
|
||||
|
||||
Run this once from a development machine that has a browser. It opens a local
|
||||
OAuth redirect server, asks you to log in, and prints the refresh token.
|
||||
|
||||
Usage:
|
||||
python app/supporting_scripts/get_refresh_token.py
|
||||
|
||||
Requires:
|
||||
pip install google-auth-oauthlib
|
||||
|
||||
Before running:
|
||||
1. Create a Google Cloud project and enable the Drive API.
|
||||
2. Create a **Desktop** OAuth 2.0 client ID.
|
||||
3. Add your email as a test user in the OAuth consent screen.
|
||||
4. Run this script and paste the values when prompted.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _prompt(label, env_name, default=""):
|
||||
"""Ask the user for a value, accepting an env-var fallback or a default."""
|
||||
candidate = os.getenv(env_name) or default
|
||||
hint = f" [{candidate}]" if candidate else ""
|
||||
raw = input(f"{label}{hint}: ").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if candidate:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _validate_client_id(client_id):
|
||||
"""Warn if the client ID doesn't look like a real OAuth client ID."""
|
||||
if client_id == "YOUR_CLIENT_ID.apps.googleusercontent.com":
|
||||
print("[ERROR] You must provide a real client ID from the Google Cloud console.")
|
||||
return False
|
||||
if not client_id.endswith(".apps.googleusercontent.com"):
|
||||
print(
|
||||
f"[WARNING] Client ID should end with '.apps.googleusercontent.com'.\n"
|
||||
f" Got: {client_id!r}\n"
|
||||
f" If this is a placeholder, the OAuth request will fail.\n"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_client_secret(secret):
|
||||
if secret == "YOUR_CLIENT_SECRET":
|
||||
print("[ERROR] You must provide a real client secret from the Google Cloud console.")
|
||||
return False
|
||||
# The GOCSPX- prefix is now used by every client type, so it no longer
|
||||
# tells us anything about whether the client was created as a Desktop app.
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Google Drive Refresh Token — Setup")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# When both env vars are set, skip prompts entirely for headless runs.
|
||||
env_id = os.getenv("GOOGLE_DRIVE_CLIENT_ID")
|
||||
env_secret = os.getenv("GOOGLE_DRIVE_CLIENT_SECRET")
|
||||
if env_id and env_secret:
|
||||
client_id = env_id
|
||||
client_secret = env_secret
|
||||
print(f"Using Client ID from environment: {client_id[:30]}...")
|
||||
else:
|
||||
print("Enter your Google Cloud OAuth credentials.")
|
||||
print("You can also set GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET")
|
||||
print("environment variables and re-run to skip the prompts.")
|
||||
print()
|
||||
|
||||
client_id = _prompt("Client ID", "GOOGLE_DRIVE_CLIENT_ID")
|
||||
client_secret = _prompt("Client Secret", "GOOGLE_DRIVE_CLIENT_SECRET")
|
||||
|
||||
if not _validate_client_id(client_id) or not _validate_client_secret(client_secret):
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("Opening browser to authenticate with Google...")
|
||||
print("(If it doesn't open, copy the URL below into your browser.)")
|
||||
print()
|
||||
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
|
||||
SCOPE = "https://www.googleapis.com/auth/drive.file"
|
||||
|
||||
client_config = {
|
||||
"installed": {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": ["http://localhost"],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
|
||||
flow = InstalledAppFlow.from_client_config(client_config, scopes=[SCOPE])
|
||||
credentials = flow.run_local_server(host="localhost", port=8080, open_browser=True)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUCCESS — copy these values into your .env file:")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("DOCUMENT_STORAGE_BACKEND=google_drive")
|
||||
print(f"GOOGLE_DRIVE_CLIENT_ID={credentials.client_id}")
|
||||
print(f"GOOGLE_DRIVE_CLIENT_SECRET={client_secret}")
|
||||
print(f"GOOGLE_DRIVE_REFRESH_TOKEN={credentials.refresh_token}")
|
||||
print("GOOGLE_DRIVE_FOLDER_ID=<get this from the Drive folder URL>")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
|
||||
- Debug mode status
|
||||
- HTTPS configuration
|
||||
- Dependency vulnerabilities
|
||||
- Database connectivity
|
||||
- Required database configuration
|
||||
|
||||
Usage:
|
||||
python security_scan.py [--url http://localhost:5000]
|
||||
@@ -19,6 +19,10 @@ import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
||||
|
||||
|
||||
def check_environment():
|
||||
@@ -31,8 +35,8 @@ def check_environment():
|
||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
critical_vars = ['SECRET_KEY']
|
||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
||||
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
|
||||
recommended_vars = ['CORS_ALLOWED_ORIGINS']
|
||||
all_ok = True
|
||||
|
||||
for var in critical_vars:
|
||||
@@ -58,7 +62,8 @@ def check_environment():
|
||||
# Check FLASK_DEBUG
|
||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||
if debug == 'true':
|
||||
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
|
||||
print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[OK] FLASK_DEBUG is disabled')
|
||||
|
||||
@@ -91,10 +96,11 @@ def check_https_headers(url):
|
||||
all_ok = True
|
||||
|
||||
try:
|
||||
# Create a context that doesn't verify SSL (for local testing)
|
||||
# Keep the default certificate and hostname verification. A scanner
|
||||
# that accepts an invalid certificate can validate headers while the
|
||||
# transport itself is impersonated. Local runs without TLS should use
|
||||
# http:// explicitly or opt out with --skip-http.
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
|
||||
@@ -148,9 +154,9 @@ def check_https_headers(url):
|
||||
all_ok = False
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
||||
print('[SKIP] Run with --url <application_url> to check headers')
|
||||
return True # Not a failure, just can't check
|
||||
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
|
||||
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
||||
return False
|
||||
|
||||
return all_ok
|
||||
|
||||
@@ -167,7 +173,15 @@ def check_dependencies():
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
||||
[
|
||||
sys.executable,
|
||||
'-m',
|
||||
'pip_audit',
|
||||
'--requirement',
|
||||
str(REQUIREMENTS_FILE),
|
||||
'--format',
|
||||
'json',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
@@ -195,14 +209,16 @@ def check_dependencies():
|
||||
if result.stdout:
|
||||
print(f'[INFO] {result.stdout.strip()}')
|
||||
if result.stderr:
|
||||
print(f'[WARN] {result.stderr.strip()}')
|
||||
return True
|
||||
print(f'[FAIL] {result.stderr.strip()}')
|
||||
else:
|
||||
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
||||
return True
|
||||
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[WARN] pip-audit timed out')
|
||||
return True
|
||||
print('[FAIL] pip-audit timed out')
|
||||
return False
|
||||
|
||||
|
||||
def check_file_permissions():
|
||||
|
||||
@@ -90,6 +90,12 @@
|
||||
<span>{{ _('Manage Users') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-cogs"></i>
|
||||
<span>Admin Panel</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' %}
|
||||
<li>
|
||||
@@ -167,7 +173,7 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="content" id="page-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="
|
||||
<form method="POST" data-ajax action="
|
||||
{% if context_type == 'tryout' %}
|
||||
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
||||
{% elif context_type == 'match' %}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Admin Panel{% endblock %}
|
||||
{% block page_title %}Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-primary">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_users }}</h3>
|
||||
<p>Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-success">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_players }}</h3>
|
||||
<p>Players</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-warning">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_org_teams }}</h3>
|
||||
<p>Org Teams</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.active_tryouts }}</h3>
|
||||
<p>Active Tryouts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-secondary">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.upcoming_tryouts }}</h3>
|
||||
<p>Upcoming</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Left column -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-toggle-{% if settings.tryouts_open %}on{% else %}off{% endif %}"></i> Tryouts Access</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Status:
|
||||
<span class="badge badge-{% if settings.tryouts_open %}success{% else %}danger{% endif %}">
|
||||
{% if settings.tryouts_open %}OPEN{% else %}CLOSED{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
|
||||
{% if settings.tryouts_open %}
|
||||
Coaches and managers can create and modify tryouts.
|
||||
{% else %}
|
||||
Only admins can create or modify tryouts. Coaches and managers are locked out.
|
||||
{% endif %}
|
||||
</p>
|
||||
<form method="POST" data-ajax action="{{ url_for('admin.toggle_tryouts') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
|
||||
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-toggle-{% if settings.document_uploads_enabled %}on{% else %}off{% endif %}"></i> Document Uploads</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Status:
|
||||
<span class="badge badge-{% if settings.document_uploads_enabled %}success{% else %}danger{% endif %}">
|
||||
{% if settings.document_uploads_enabled %}ENABLED{% else %}DISABLED{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
|
||||
{% if settings.document_uploads_enabled %}
|
||||
Managers and coaches can upload contracts. Players can upload signed contracts.
|
||||
{% else %}
|
||||
Only admins can upload or sign contracts. Other roles are blocked.
|
||||
{% endif %}
|
||||
</p>
|
||||
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_uploads') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<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 %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-check"></i> Season Management</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Season:
|
||||
<span class="badge badge-{% if settings.season_active %}success{% else %}info{% endif %}">
|
||||
{% if settings.season_active %}ACTIVE{% else %}INACTIVE{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Name:</strong> {{ settings.season_name }}</p>
|
||||
<p><strong>Start:</strong> {{ settings.season_start }}</p>
|
||||
<p><strong>End:</strong> {{ settings.season_end }}</p>
|
||||
|
||||
{% if not settings.season_active %}
|
||||
<hr style="margin: 12px 0;">
|
||||
<form method="POST" data-ajax action="{{ url_for('admin.start_season') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Season Name</label>
|
||||
<input type="text" name="season_name" class="form-input" placeholder="e.g. Fall 2026" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Start Date</label>
|
||||
<input type="date" name="season_start" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Begin Season</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<hr style="margin: 12px 0;">
|
||||
<form method="POST" data-ajax action="{{ url_for('admin.end_season') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>End Date</label>
|
||||
<input type="date" name="season_end" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning">End Season</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wipe Teams -->
|
||||
<div class="card" style="border-left: 4px solid var(--danger);">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-trash-alt" style="color: var(--danger);"></i> Wipe Teams for New Season</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
|
||||
This removes all players from organization teams and deletes all regular-season matches.
|
||||
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('admin.wipe_teams') }}"
|
||||
data-confirm-input="confirm" data-confirm-value="WIPE"
|
||||
data-confirm-input-message="You must type WIPE to confirm."
|
||||
data-confirm="This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Type <strong>WIPE</strong> to confirm:</label>
|
||||
<input type="text" name="confirm" class="form-input" placeholder="WIPE" autocomplete="off" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Wipe Team Rosters</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Backup -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-database"></i> Manual Backup</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
|
||||
Creates a full PostgreSQL database dump (.sql file). Stored on the server.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('admin.create_backup') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Notes (optional)</label>
|
||||
<input type="text" name="notes" class="form-input" placeholder="What's this backup for?">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Backup Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup History -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-history"></i> Backup History & Restore</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td>{{ b.created_at.strftime('%Y-%m-%d %H:%M') if b.created_at else '—' }}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ b.filename }}">{{ b.filename }}</td>
|
||||
<td>{{ (b.size_bytes / 1024)|round(1) }} KB</td>
|
||||
<td><span class="badge badge-info">{{ b.backup_type }}</span></td>
|
||||
<td style="white-space: nowrap;">
|
||||
<a href="{{ url_for('admin.download_backup', backup_id=b.id) }}" class="btn btn-sm btn-primary" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
|
||||
data-confirm="Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
|
||||
data-confirm="Delete backup {{ b.filename }}?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Details</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in audit_logs %}
|
||||
<tr>
|
||||
<td>{{ entry.created_at.strftime('%Y-%m-%d %H:%M') if entry.created_at else '—' }}</td>
|
||||
<td>{{ entry.user.username if entry.user else 'System' }}</td>
|
||||
<td><span class="badge badge-info">{{ entry.action }}</span></td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
|
||||
<td>{{ entry.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Evaluate Players{% 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 %}
|
||||
<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() }}"/>
|
||||
{% for entry in players %}
|
||||
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
|
||||
{% endfor %}
|
||||
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
|
||||
<div class="batch-eval-grid">
|
||||
{% for entry in players %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<i class="fas fa-user"></i> {{ entry.player.username }}
|
||||
{% if entry.existing %}
|
||||
<span class="badge badge-success">Already Evaluated</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Not Evaluated</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="eval-player-info mb-4">
|
||||
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
|
||||
<div>
|
||||
<h3>{{ entry.player.username }}</h3>
|
||||
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set pid = entry.player.id %}
|
||||
{% set existing = entry.existing %}
|
||||
{% set existing_scores = entry.existing_scores %}
|
||||
|
||||
<div class="form-row">
|
||||
{% for field_name, label in evaluation_criteria %}
|
||||
<div class="form-group col-4">
|
||||
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-12">
|
||||
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
|
||||
{% if positions %}
|
||||
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
|
||||
<option value="">-- Select Position --</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="comments_{{ pid }}">Comments</label>
|
||||
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save All Evaluations
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -346,6 +346,14 @@ function showEventModal(event) {
|
||||
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) {
|
||||
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('Manage Availability') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Manage Availability') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> {{ _('Set Your Weekly Availability') }}</h3>
|
||||
<p class="text-muted small">{{ _('Select time slots when you\'re available for One on One sessions') }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="availability-grid" id="availability-grid">
|
||||
<p class="text-muted">{{ _('Loading availability grid...') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-4">
|
||||
<button type="button" class="btn btn-primary" data-action="save-availability">
|
||||
<i class="fas fa-save"></i> {{ _('Save Availability') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
||||
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.availability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
padding: 6px 8px;
|
||||
margin: 4px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.time-slot:hover {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.time-slot.selected {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.time-slot.selected:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
// Time slots from 8:00 AM to 10:00 PM (30-minute intervals)
|
||||
const TIME_SLOTS = [];
|
||||
for (let h = 8; h <= 22; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
||||
const displayHour = h > 12 ? h - 12 : h;
|
||||
const displayAmpm = h >= 12 ? 'PM' : 'AM';
|
||||
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
|
||||
TIME_SLOTS.push({ time: timeStr, display: displayTime });
|
||||
}
|
||||
}
|
||||
|
||||
// Day names
|
||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
// Track selected slots: {day_of_week: [time_strings]}
|
||||
let selectedSlots = {};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadExistingAvailability();
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
function loadExistingAvailability() {
|
||||
// Load from existing data
|
||||
{% for av in existing_availability %}
|
||||
if (!selectedSlots[{{ av.day_of_week }}]) {
|
||||
selectedSlots[{{ av.day_of_week }}] = [];
|
||||
}
|
||||
selectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('availability-grid');
|
||||
let html = '';
|
||||
|
||||
DAYS.forEach((day, dayIndex) => {
|
||||
html += '<div class="day-column">';
|
||||
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
|
||||
|
||||
TIME_SLOTS.forEach(slot => {
|
||||
const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time);
|
||||
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
|
||||
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" data-action="toggle-slot">' + slot.display + '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleSlot(element) {
|
||||
const dayOfWeek = Number(element.getAttribute('data-day'));
|
||||
const timeStr = element.getAttribute('data-time');
|
||||
if (!selectedSlots[dayOfWeek]) {
|
||||
selectedSlots[dayOfWeek] = [];
|
||||
}
|
||||
|
||||
const index = selectedSlots[dayOfWeek].indexOf(timeStr);
|
||||
if (index === -1) {
|
||||
selectedSlots[dayOfWeek].push(timeStr);
|
||||
element.classList.add('selected');
|
||||
} else {
|
||||
selectedSlots[dayOfWeek].splice(index, 1);
|
||||
element.classList.remove('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function saveAvailability() {
|
||||
const slots = [];
|
||||
for (let day in selectedSlots) {
|
||||
selectedSlots[day].forEach(time => {
|
||||
slots.push({ day_of_week: parseInt(day), start_time: time });
|
||||
});
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.manage_coach_availability") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ slots: slots })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
flash('Availability saved!', 'success');
|
||||
} else {
|
||||
flash(data.error || 'Error saving availability.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Save error:', error);
|
||||
flash('Error saving availability.', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function clearAllAvailability() {
|
||||
if (!confirm('Are you sure you want to clear all your availability slots?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.clear_coach_availability") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
selectedSlots = {};
|
||||
renderGrid();
|
||||
flash('Availability cleared!', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flash(message, type) {
|
||||
const flashContainer = document.querySelector('.flash-messages');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-' + type + ' alert-dismissible';
|
||||
alert.innerHTML = '<span>' + message + '</span><button type="button" class="alert-close" data-action="remove-element">×</button>';
|
||||
flashContainer.appendChild(alert);
|
||||
}
|
||||
|
||||
// Auto-save on change (debounced)
|
||||
let saveTimeout;
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('time-slot')) {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(saveAvailability, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Behaviours are declared in the markup with data-action / data-change and
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'save-availability': saveAvailability,
|
||||
'clear-availability': clearAllAvailability,
|
||||
'toggle-slot': toggleSlot,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -94,8 +94,8 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="signed_file">{{ _('Select Signed Contract File') }}</label>
|
||||
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
||||
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
|
||||
<input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
{% 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() }}"/>
|
||||
|
||||
<div class="form-row">
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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() }}"/>
|
||||
{% if is_practice %}
|
||||
<input type="hidden" name="title" value="Practice">
|
||||
@@ -450,11 +450,18 @@
|
||||
var playerDataById = {
|
||||
player_data: {
|
||||
{%- for p in all_players %}
|
||||
{{ p.id }}: "{{ p.username | escape }}",
|
||||
{{ p.id }}: {{ p.username | tojson }},
|
||||
{%- endfor %}
|
||||
}
|
||||
};
|
||||
|
||||
var HTML_ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function (character) {
|
||||
return HTML_ESCAPES[character];
|
||||
});
|
||||
}
|
||||
|
||||
// All registered player IDs
|
||||
var allRegisteredPlayers = [
|
||||
{%- for p in all_players %}
|
||||
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var playerName = playerDataById.player_data[pid];
|
||||
if (playerName) {
|
||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
||||
html += playerName;
|
||||
html += escapeHtml(playerName);
|
||||
html += '<span class="remove-btn">↺</span>';
|
||||
html += '</div>';
|
||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var playerName = playerDataById.player_data[pid];
|
||||
if (playerName) {
|
||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
||||
html += playerName;
|
||||
html += escapeHtml(playerName);
|
||||
html += '<span class="remove-btn">↺</span>';
|
||||
html += '</div>';
|
||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
|
||||
}
|
||||
|
||||
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
|
||||
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
|
||||
'data-action="toggle-time-slot">' +
|
||||
slotData.display +
|
||||
'<span class="merged-disponibility-count">' + count + '</span>' +
|
||||
'</div>';
|
||||
@@ -920,7 +927,7 @@ function updatePlayerPool() {
|
||||
var availabilityClass = isAvailable ? 'available' : 'unavailable';
|
||||
|
||||
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
|
||||
html += '<span class="player-name">' + playerName + '</span>';
|
||||
html += '<span class="player-name">' + escapeHtml(playerName) + '</span>';
|
||||
html += '<div class="player-actions">';
|
||||
html += '<button type="button" class="btn btn-sm btn-primary" data-action="assign-team" data-team-side="1">{{ _('T1') }}</button>';
|
||||
html += '<button type="button" class="btn btn-sm btn-secondary" data-action="assign-team" data-team-side="2">{{ _('T2') }}</button>';
|
||||
@@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) {
|
||||
if (!playerName) return;
|
||||
|
||||
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
|
||||
html += playerName;
|
||||
html += escapeHtml(playerName);
|
||||
html += '<span class="remove-btn">↺</span>';
|
||||
html += '</div>';
|
||||
|
||||
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
|
||||
var playerName = playerDataById.player_data[pid];
|
||||
if (playerName) {
|
||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
||||
html += playerName;
|
||||
html += escapeHtml(playerName);
|
||||
html += '<span class="remove-btn">↺</span>';
|
||||
html += '</div>';
|
||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
|
||||
var playerName = playerDataById.player_data[pid];
|
||||
if (playerName) {
|
||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
||||
html += playerName;
|
||||
html += escapeHtml(playerName);
|
||||
html += '<span class="remove-btn">↺</span>';
|
||||
html += '</div>';
|
||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||
@@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) {
|
||||
registerActions({
|
||||
'toggle-match-type': toggleMatchType,
|
||||
'clear-time-selection': clearTimeSelection,
|
||||
'toggle-time-slot': function (element) {
|
||||
toggleTimeSlot(parseInt(element.getAttribute('data-day'), 10),
|
||||
element.getAttribute('data-time'),
|
||||
element);
|
||||
},
|
||||
'update-randomize-preview': updateRandomizePreview,
|
||||
'randomize-teams': randomizeTeams,
|
||||
'return-to-pool': returnToPool,
|
||||
|
||||
@@ -192,7 +192,7 @@ function togglePresence(btn) {
|
||||
btn.classList.add('btn-outline');
|
||||
btn.innerHTML = 'Confirm';
|
||||
}
|
||||
location.reload();
|
||||
window.TT.refreshPage(window.location.pathname);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error:', error);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
</div>
|
||||
<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() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -41,7 +41,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -145,7 +145,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{% 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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-success" title="{{ _('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>
|
||||
<button type="button" class="modal-close" data-action="hide-reject-modal">×</button>
|
||||
</div>
|
||||
<form id="rejectForm" method="POST" action="">
|
||||
<form id="rejectForm" method="POST" data-ajax action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% 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() }}"/>
|
||||
|
||||
<div class="form-row">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -6,54 +6,84 @@
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
|
||||
<h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Player') }}</th>
|
||||
<th>{{ _('Contact') }}</th>
|
||||
<th>{{ _('Attendance') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
|
||||
<form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
|
||||
<th>Player</th>
|
||||
<th>Contact</th>
|
||||
<th>Attendance</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
<span>{{ entry.player.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ entry.player.email }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.evaluated %}
|
||||
<span class="badge badge-success">Evaluated</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Not Evaluated</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ entry.player.email }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.evaluated %}
|
||||
<span class="badge badge-success">Evaluated</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Not Evaluated</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">No players registered for this tryout.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-clipboard-check"></i> Evaluate Selected
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function toggleAll() {
|
||||
var boxes = document.querySelectorAll('.player-check');
|
||||
var master = document.getElementById('select-all');
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
boxes[i].checked = master.checked;
|
||||
}
|
||||
}
|
||||
|
||||
registerActions({
|
||||
'toggle-all': toggleAll,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -393,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCoachAvailability();
|
||||
renderCoachGrid();
|
||||
});
|
||||
|
||||
// Behaviours are declared in the markup with data-action / data-change and
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'clear-availability': clearAllAvailability,
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -608,7 +615,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'clear-disponibilities': clearDisponibilities,
|
||||
'clear-availability': clearAllAvailability,
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
|
||||
</div>
|
||||
<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() }}"/>
|
||||
|
||||
<div class="form-row">
|
||||
|
||||
@@ -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') }}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
@@ -170,8 +170,8 @@ function togglePresence(btn) {
|
||||
btn.classList.add('btn-outline');
|
||||
btn.innerHTML = 'Confirm';
|
||||
}
|
||||
// Reload to update presence bar
|
||||
location.reload();
|
||||
// Update in place to preserve state
|
||||
window.TT.refreshPage(window.location.pathname);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error:', error);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<h3>{{ _('Create New Team') }}</h3>
|
||||
</div>
|
||||
<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() }}"/>
|
||||
<div class="form-row">
|
||||
<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 '' }}">
|
||||
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||
</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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i> {{ _('Delete') }}
|
||||
@@ -89,7 +89,7 @@
|
||||
<span class="staff-tag">
|
||||
{{ c.username }}
|
||||
{% 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="coach_id" value="{{ c.id }}"/>
|
||||
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">×</button>
|
||||
@@ -111,7 +111,7 @@
|
||||
<span class="staff-tag manager-tag">
|
||||
{{ m.username }}
|
||||
{% 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="manager_id" value="{{ m.id }}"/>
|
||||
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">×</button>
|
||||
@@ -180,7 +180,7 @@
|
||||
<td>{{ entry.player.phone or '-' }}</td>
|
||||
{% if can_manage_team %}
|
||||
<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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
|
||||
@@ -210,7 +210,7 @@
|
||||
<hr class="my-3">
|
||||
<div class="add-player-section">
|
||||
<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() }}"/>
|
||||
<select name="player_id" class="form-select" required>
|
||||
<option value="">{{ _('-- Select a player --') }}</option>
|
||||
@@ -258,7 +258,7 @@
|
||||
<button class="modal-close" data-action="hide-edit-form">×</button>
|
||||
</div>
|
||||
<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="sync_staff" value="1"/>
|
||||
|
||||
@@ -300,6 +300,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function showCreateForm() {
|
||||
document.getElementById('createTeamForm').classList.remove('hidden');
|
||||
|
||||
@@ -67,15 +67,22 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="manager_id">{{ _('Assigned Manager') }}</label>
|
||||
<select id="manager_id" name="manager_id" class="form-select">
|
||||
<option value="">{{ _('-- No manager assigned --') }}</option>
|
||||
<label>{{ _('Assigned Managers') }}</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for manager in managers %}
|
||||
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
|
||||
{{ manager.username }}
|
||||
</option>
|
||||
{% set is_checked = false %}
|
||||
{% if tryout %}
|
||||
{% for m in tryout.get_managers() %}
|
||||
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="manager_ids" value="{{ manager.id }}" {% if is_checked %}checked{% endif %}>
|
||||
<span>{{ manager.username }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contract_file">{{ _('Contract File') }}</label>
|
||||
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
||||
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
|
||||
<input type="file" id="contract_file" name="contract_file" accept=".pdf" required>
|
||||
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<th>{{ _('Username') }}</th>
|
||||
<th>{{ _('Email') }}</th>
|
||||
<th>{{ _('Role') }}</th>
|
||||
<th>{{ _('Hours') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Joined') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
@@ -38,6 +39,13 @@
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.email }}</td>
|
||||
<td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></td>
|
||||
<td>
|
||||
{% if u.role in ('player', 'coach') %}
|
||||
{{ '%.1f'|format(hours.get(u.id, 0.0)) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_active_account %}
|
||||
<span class="badge badge-success">Active</span>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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() }}"/>
|
||||
<select name="status" data-submit-on-change class="form-select">
|
||||
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option>
|
||||
@@ -70,16 +70,22 @@
|
||||
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Manager</span>
|
||||
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
|
||||
<span class="detail-label">Manager(s)</span>
|
||||
<span class="detail-value">
|
||||
{% set mgrs = tryout.get_managers() %}
|
||||
{% if mgrs %}
|
||||
{{ mgrs | map(attribute='username') | join(', ') }}
|
||||
{% else %}
|
||||
Not assigned
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Coaches</span>
|
||||
<span class="detail-value">
|
||||
{% if tryout.coaches %}
|
||||
{{ tryout.coaches | map(attribute='username') | join(', ') }}
|
||||
{% elif tryout.coach %}
|
||||
{{ tryout.coach.username }}
|
||||
{% set cos = tryout.get_coaches() %}
|
||||
{% if cos %}
|
||||
{{ cos | map(attribute='username') | join(', ') }}
|
||||
{% else %}
|
||||
Not assigned
|
||||
{% endif %}
|
||||
@@ -102,7 +108,7 @@
|
||||
{% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %}
|
||||
<div class="card mb-4">
|
||||
<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() }}"/>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }}
|
||||
@@ -118,7 +124,7 @@
|
||||
<h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3>
|
||||
</div>
|
||||
<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() }}"/>
|
||||
<select name="player_id" class="form-select" required>
|
||||
<option value="">{{ _('-- Select a player --') }}</option>
|
||||
@@ -167,7 +173,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{% 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() }}"/>
|
||||
<select name="status" data-submit-on-change class="form-select form-select-sm">
|
||||
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option>
|
||||
@@ -199,7 +205,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}">
|
||||
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
|
||||
@@ -232,7 +238,7 @@
|
||||
<div class="card-body">
|
||||
{% if can_edit and not tryout.is_ended %}
|
||||
<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="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2">
|
||||
<button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button>
|
||||
@@ -243,7 +249,14 @@
|
||||
|
||||
{% for team in team_data %}
|
||||
<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">
|
||||
{% for member in team.members %}
|
||||
<li>
|
||||
@@ -257,32 +270,96 @@
|
||||
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
{% 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 }}">×</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 %}
|
||||
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
|
||||
{% endfor %}
|
||||
@@ -452,6 +529,12 @@
|
||||
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||
</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 %}
|
||||
<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') }}
|
||||
@@ -534,6 +617,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.presence-toggle-btn {
|
||||
padding: 2px 7px;
|
||||
@@ -559,8 +645,56 @@
|
||||
transform: scale(1.08);
|
||||
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>
|
||||
<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 hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
||||
|
||||
@@ -583,41 +717,58 @@ function toggleTryoutPresence(matchId, participantId, btn) {
|
||||
btn.classList.add('presence-pending-btn');
|
||||
btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
|
||||
}
|
||||
location.reload();
|
||||
window.TT.refreshPage(window.location.pathname);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error toggling tryout presence:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Mini calendar for matches
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mini calendar for matches. Re-initialised on fragment:updated so the
|
||||
// calendar survives AJAX edits that swap the page content in place.
|
||||
var miniCalendar = null;
|
||||
|
||||
function initMiniCalendar() {
|
||||
var miniCalendarEl = document.getElementById('mini-calendar');
|
||||
if (miniCalendarEl) {
|
||||
var 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();
|
||||
if (!miniCalendarEl) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
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
|
||||
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
|
||||
registerActions({
|
||||
'show-create-team': showCreateTeam,
|
||||
'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) {
|
||||
toggleTryoutPresence(
|
||||
element.getAttribute('data-match-id'),
|
||||
|
||||
@@ -81,6 +81,31 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if profile_user.role in ('player', 'coach') and availability %}
|
||||
<hr class="my-4">
|
||||
<h4 class="mb-3"><i class="fas fa-clock"></i> {{ _('Availability') }}
|
||||
<small class="text-muted">({{ '%.1f'|format(availability_hours or 0.0) }} h)</small>
|
||||
</h4>
|
||||
<div class="readonly-availability-grid">
|
||||
{% set day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] %}
|
||||
{% for day_idx in range(7) %}
|
||||
<div class="ra-day-column">
|
||||
<div class="ra-day-header">{{ day_names[day_idx][:3] }}</div>
|
||||
{% set day_slots = availability | selectattr('day_of_week', 'equalto', day_idx) | list %}
|
||||
{% if day_slots %}
|
||||
{% for slot in day_slots | sort(attribute='start_time') %}
|
||||
<div class="ra-slot selected">
|
||||
{{ slot.start_time.strftime('%H:%M') }}–{{ slot.end_time.strftime('%H:%M') }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ra-slot ra-empty">—</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Time helpers with explicit storage semantics.
|
||||
|
||||
The deployed schema currently stores timestamps in ``DateTime`` columns
|
||||
without timezone information. Until the real PostgreSQL schema is restored
|
||||
and migrated, application timestamps must therefore remain naive values.
|
||||
They are nevertheless generated from an aware UTC clock so the convention is
|
||||
explicit and does not rely on the deprecated :meth:`datetime.utcnow` API.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def utc_now_naive() -> datetime:
|
||||
"""Return the current UTC instant without ``tzinfo`` for legacy columns.
|
||||
|
||||
Replace this compatibility boundary with aware UTC values when the
|
||||
corresponding columns are migrated to timezone-aware types.
|
||||
"""
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -58,3 +58,39 @@ def day_name(day_of_week):
|
||||
if 0 <= day_of_week < len(DAY_NAMES):
|
||||
return DAY_NAMES[day_of_week]
|
||||
return f'Day {day_of_week}'
|
||||
|
||||
|
||||
def slot_duration_minutes(start_time, end_time):
|
||||
"""Minutes a slot spans, wrapping past midnight like slot_end does.
|
||||
|
||||
Works on SQLite where ``Time`` columns are stored as text — the
|
||||
arithmetic stays in pure Python so it is portable across databases.
|
||||
|
||||
Args:
|
||||
start_time: A ``datetime.time``.
|
||||
end_time: A ``datetime.time`` (may be earlier if the slot wraps
|
||||
past midnight).
|
||||
|
||||
Returns:
|
||||
int: Duration in whole minutes.
|
||||
"""
|
||||
anchor = date_cls(2000, 1, 1)
|
||||
start_dt = datetime.combine(anchor, start_time)
|
||||
end_dt = datetime.combine(anchor, end_time)
|
||||
if end_dt <= start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
return int((end_dt - start_dt).total_seconds() // 60)
|
||||
|
||||
|
||||
def total_hours(slots):
|
||||
"""Aggregate slot durations into a single float, rounded to one decimal.
|
||||
|
||||
Args:
|
||||
slots: Iterable of objects with ``start_time`` and ``end_time``
|
||||
attributes (``PlayerDisponibility`` or ``CoachAvailability``).
|
||||
|
||||
Returns:
|
||||
float: Total hours across all slots, e.g. ``8.5``.
|
||||
"""
|
||||
total_minutes = sum(slot_duration_minutes(s.start_time, s.end_time) for s in slots)
|
||||
return round(total_minutes / 60, 1)
|
||||
|
||||
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
+153
-1
@@ -23,7 +23,7 @@ from marshmallow import (
|
||||
validates_schema,
|
||||
)
|
||||
|
||||
from app.models import ESPORT_GAMES, USER_TYPES
|
||||
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
||||
|
||||
# =============================================================================
|
||||
# Custom Validators
|
||||
@@ -276,6 +276,36 @@ class RegisterSchema(StripMixin):
|
||||
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password')
|
||||
|
||||
|
||||
class GamertagSchema(StripMixin):
|
||||
"""One dynamic per-game identity submitted beside an account form."""
|
||||
|
||||
game = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')),
|
||||
)
|
||||
gamertag = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(
|
||||
min=1,
|
||||
max=120,
|
||||
error=_l('Gamertag must be between 1 and 120 characters.'),
|
||||
),
|
||||
)
|
||||
platform = fields.String(
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
validate=validate.Length(max=30, error=_l('Platform must be 30 characters or less.')),
|
||||
)
|
||||
|
||||
@validates_schema
|
||||
def validate_platform_for_game(self, data, **kwargs):
|
||||
"""A forged platform must belong to the selected game's list."""
|
||||
platform = data.get('platform')
|
||||
allowed = GAME_PLATFORMS.get(data.get('game'), ())
|
||||
if platform and platform not in allowed:
|
||||
raise ValidationError(_l('Unknown platform for this game.'), field_name='platform')
|
||||
|
||||
|
||||
class CreateUserSchema(StripMixin):
|
||||
"""Validate president-created user form input.
|
||||
|
||||
@@ -517,6 +547,127 @@ class TeamPlayerSchema(PlayerSelectionSchema):
|
||||
)
|
||||
|
||||
|
||||
TRYOUT_STATUSES = ('upcoming', 'in_progress', 'completed')
|
||||
TRYOUT_REGISTRATION_STATUSES = ('registered', 'attended', 'no_show')
|
||||
|
||||
|
||||
class TryoutStatusSchema(StripMixin):
|
||||
"""A state transition requested from the tryout detail page."""
|
||||
|
||||
status = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(TRYOUT_STATUSES, error=_l('Unknown tryout status.')),
|
||||
)
|
||||
|
||||
|
||||
class TryoutRegistrationStatusSchema(StripMixin):
|
||||
"""Attendance state for one tryout registration."""
|
||||
|
||||
status = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
TRYOUT_REGISTRATION_STATUSES,
|
||||
error=_l('Unknown registration status.'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TryoutTeamSchema(StripMixin):
|
||||
"""A tryout-local team created from its compact inline form."""
|
||||
|
||||
team_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(
|
||||
min=1,
|
||||
max=100,
|
||||
error=_l('Team name must be between 1 and 100 characters.'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TryoutTeamMemberSchema(StripMixin):
|
||||
"""A registered player and their optional position on a tryout team."""
|
||||
|
||||
player_id = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(min=1),
|
||||
error_messages={
|
||||
'invalid': _l('Invalid player selection.'),
|
||||
'required': _l('Player must be selected.'),
|
||||
},
|
||||
)
|
||||
position = fields.String(
|
||||
load_default='',
|
||||
validate=validate.Length(
|
||||
max=50,
|
||||
error=_l('Position must be 50 characters or less.'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
"""Bounded text stored as a team or personal coaching note."""
|
||||
|
||||
content = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(
|
||||
min=1,
|
||||
max=5000,
|
||||
error=_l('Note content must be between 1 and 5000 characters.'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PersonalNoteSchema(NoteContentSchema):
|
||||
"""A personal note with at most one optional, typed context."""
|
||||
|
||||
player_id = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(min=1),
|
||||
error_messages={
|
||||
'invalid': _l('Invalid player selection.'),
|
||||
'required': _l('Player must be selected.'),
|
||||
},
|
||||
)
|
||||
match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
||||
tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
||||
team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
||||
|
||||
@validates_schema
|
||||
def validate_one_context(self, data, **kwargs):
|
||||
"""A note cannot claim several unrelated contexts at once."""
|
||||
contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')]
|
||||
if sum(value is not None for value in contexts) > 1:
|
||||
raise ValidationError(
|
||||
_l('Select at most one note context.'),
|
||||
field_name='context',
|
||||
)
|
||||
|
||||
|
||||
class OneOnOneRejectionSchema(StripMixin):
|
||||
"""Optional explanation sent to a player when a request is rejected."""
|
||||
|
||||
rejection_reason = fields.String(
|
||||
load_default='',
|
||||
validate=validate.Length(
|
||||
max=2000,
|
||||
error=_l('Rejection reason must be 2000 characters or less.'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OneOnOneRequestSchema(StripMixin):
|
||||
"""A player asking their coach for a session (MNT-12).
|
||||
|
||||
@@ -810,6 +961,7 @@ class TryoutSchema(StripMixin):
|
||||
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
||||
manager_id = fields.Integer(allow_none=True, load_default=None)
|
||||
coach_ids = fields.List(fields.Integer(), load_default=list)
|
||||
manager_ids = fields.List(fields.Integer(), load_default=list)
|
||||
|
||||
@validates_schema
|
||||
def validate_span(self, data, **kwargs):
|
||||
|
||||
-66
@@ -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()
|
||||
@@ -15,6 +15,7 @@ incident.
|
||||
|---|---|---|
|
||||
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record |
|
||||
| `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths |
|
||||
| `documents_backup_drive_<timestamp>.zip` | Google Drive contract folder | Drive-stored contracts downloaded and archived when `DOCUMENT_STORAGE_BACKEND=google_drive` |
|
||||
|
||||
Losing either one alone loses data. A database restore without the document
|
||||
archive leaves contract rows pointing at files that no longer exist.
|
||||
|
||||
@@ -115,7 +115,7 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` :
|
||||
|---|---|---|
|
||||
| `DB-004` | Retirer `create_all()` de `create_app()` | Tant qu'il est là, deux mécanismes décrivent le schéma |
|
||||
| `DB-005` | Cascades de suppression au niveau base | Les cascades ORM sont en place ; PostgreSQL ne les connaît pas |
|
||||
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Le plafond d'inscriptions est aujourd'hui un `count()` suivi d'un `add()` : deux requêtes simultanées passent toutes les deux |
|
||||
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Les deux routes verrouillent désormais la ligne `Tryout` avant le contrôle de doublon, le `count()` et l'`add()` : PostgreSQL sérialise donc leurs décisions de capacité. La contrainte reste nécessaire pour les scripts, imports et futurs chemins d'écriture qui ne passent pas par ces routes |
|
||||
| `DB-007` | Index, `CheckConstraint` sur les statuts, `server_default` | — |
|
||||
| `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement |
|
||||
| `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
|
||||
|
||||
+28
-6
@@ -67,6 +67,28 @@ documents every variable. Copying it verbatim gives a configuration that
|
||||
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
|
||||
than one that starts and is wide open (OPS-003).
|
||||
|
||||
### Google Drive contract storage
|
||||
|
||||
New contracts are stored in the owner's personal Google Drive when
|
||||
`DOCUMENT_STORAGE_BACKEND=google_drive`. Enable the Google Drive API in a
|
||||
Google Cloud project, create an OAuth client for the owner, and authorize it
|
||||
once with the `drive.file` scope. Configure the resulting values outside the
|
||||
repository:
|
||||
|
||||
```text
|
||||
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||
GOOGLE_DRIVE_FOLDER_ID=<owner folder id>
|
||||
GOOGLE_DRIVE_CLIENT_ID=<OAuth client id>
|
||||
GOOGLE_DRIVE_CLIENT_SECRET=<OAuth client secret>
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN=<owner refresh token>
|
||||
```
|
||||
|
||||
The refresh token can create, download, and delete contracts created by this
|
||||
application. Keep all four values out of source control. The database stores
|
||||
only `gdrive://<file-id>` references, so existing local-file rows continue to
|
||||
work after the change. Use `DOCUMENT_STORAGE_BACKEND=local` only for tests,
|
||||
local development, or while migrating legacy files.
|
||||
|
||||
### Binding and proxy trust — read this before going live (OPS-002)
|
||||
|
||||
Two variables decide whether the rate limiter, the account lockout and the
|
||||
@@ -282,11 +304,11 @@ have sent new contracts to a new tree and made the existing ones unreadable
|
||||
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
|
||||
backup script kept its own copy of the document path — so it archived
|
||||
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
|
||||
was therefore enough, on its own, to make every contract backup empty; the
|
||||
script prints `No documents directory…` and still exits 0, so a scheduled
|
||||
task watching the exit code would have seen green indefinitely. All three
|
||||
roots now come from `app/storage.py`, and the backup run prints the document
|
||||
source it used.
|
||||
was therefore enough, on its own, to make every contract backup empty. All
|
||||
three roots now come from `app/storage.py`, and the backup run prints the
|
||||
document source it used. A missing or unarchivable document store makes the
|
||||
run exit non-zero even when the database dump itself is valid, so a scheduler
|
||||
cannot report a database-only recovery point as a complete backup.
|
||||
|
||||
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
|
||||
and check the `Document source:` line and the size of the resulting
|
||||
@@ -327,4 +349,4 @@ Monitor these logs regularly for suspicious activity.
|
||||
- Run `python security_scan.py` after any configuration changes
|
||||
- Test backup restoration quarterly
|
||||
- Review and rotate `SECRET_KEY` if compromised
|
||||
- Keep Python and system packages updated
|
||||
- Keep Python and system packages updated
|
||||
|
||||
@@ -82,8 +82,9 @@ Run through this checklist before deploying to production.
|
||||
|
||||
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
|
||||
- [ ] File extensions restricted to PDF only
|
||||
- [ ] File content validated by parsing with pypdf (`PdfReader`) — header (`%PDF-`) + at least one parseable page
|
||||
- [ ] Files stored with UUID filenames (not original names)
|
||||
- [ ] Upload directory outside web root
|
||||
- [ ] Upload directory outside web root (or Google Drive in production)
|
||||
|
||||
## Phase 13 — Logging & Monitoring
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user