34 Commits
Author SHA1 Message Date
cedrick2711 5249331661 test docu, non fonctionnel pour l'instant
CI - Security, Lint & Tests / validate (push) Failing after 1m7s
2026-09-15 12:28:12 -04:00
cedrick2711 abf2f06642 premiere iteration de la gestion des documents
CI - Security, Lint & Tests / validate (push) Failing after 1m20s
2026-09-11 09:51:48 -04:00
cedrick2711 bd9ed4bdc7 changer le look du calendar légèrement et régler problème avec déconnection des test
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-10 10:43:47 -04:00
cedrick2711 a9b33b30f5 forcer l'affichage des nombre un jusqu'a un chiffre apres la virgule
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 13:54:51 -04:00
cedrick2711 32c112ceea Finit les changement de visualisation des evaluations
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 13:40:35 -04:00
cedrick2711 30069d782f changement visualistation des evalutations
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 12:15:25 -04:00
cedrick2711 d994d88c12 Fix probleme d'evaluation
CI - Security, Lint & Tests / validate (push) Failing after 1m2s
2026-09-09 09:25:17 -04:00
cedrick2711 b9791d1547 chhangement du comportement du discord bot sur modif des matchs
CI - Security, Lint & Tests / validate (push) Failing after 59s
2026-09-08 12:46:44 -04:00
cedrick2711 4a2e2f053f Changement du refresh handler, soft reset d'ajouter sur beaucoup de formulaire de modification
CI - Security, Lint & Tests / validate (push) Failing after 1m3s
2026-09-08 11:27:53 -04:00
cedrick2711 e2e2b1b07e premier fix pour le discord bot qui envoie des reminders sur des versions de matchs qui n'existent plus
CI - Security, Lint & Tests / validate (push) Failing after 1m5s
2026-09-06 13:49:07 -04:00
cedrick2711 e4a271cfd8 changer gestion des team intra tryout
CI - Security, Lint & Tests / validate (push) Failing after 1m4s
2026-09-05 14:38:31 -04:00
cedrick2711 742995d3cd fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs
CI - Security, Lint & Tests / validate (push) Failing after 1m0s
2026-09-05 12:50:48 -04:00
cedrick2711 cf551867c9 changement d'un traduction pour la légende des disponibilité qui est ambigu
CI - Security, Lint & Tests / validate (push) Failing after 1m12s
2026-09-01 14:55:57 -04:00
cedrick2711 b43c5b1734 meme commit que avant
CI - Security, Lint & Tests / validate (push) Failing after 58s
2026-09-01 14:17:47 -04:00
cedrick2711 85eca9edd4 re-régler problème de disponibilité
Creation d'un fichier pour setup postgres en local pour dev
2026-09-01 14:17:44 -04:00
cedrick2711 c7b321a636 petit changement pour 429 error handler (temp fix)
CI - Security, Lint & Tests / validate (push) Failing after 59s
2026-08-27 09:25:24 -04:00
cedrick2711 90c00ad29b régler problème URL lol
CI - Security, Lint & Tests / validate (push) Failing after 1m2s
2026-08-26 22:46:24 -04:00
cedrick2711 bfb25f4d16 ajout d'un toggle pour permission d'upload des documents dans admin pannel
CI - Security, Lint & Tests / validate (push) Failing after 1m3s
ajout de compteur d'heure disponible
ajout du calendrier de dispo dans les profils publics
2026-08-26 22:33:24 -04:00
cedrick2711 820cccb25e changement vers google drive storage pour les contrats.
CI - Security, Lint & Tests / validate (push) Failing after 1m15s
Tentative d'obtenir un token de connection échoue
2026-08-26 20:52:40 -04:00
cedrick2711 38defc53a5 debut changement vers google drive
CI - Security, Lint & Tests / validate (push) Failing after 1m14s
2026-08-25 19:01:28 -04:00
cedrick2711 d18979a3e9 ajout de tests
CI - Security, Lint & Tests / validate (push) Failing after 56s
2026-08-25 15:53:46 -04:00
cedrick2711 1549fbaef3 fix merge conflict
CI - Security, Lint & Tests / validate (push) Failing after 54s
2026-08-25 14:03:24 -04:00
cedrick2711 c72ed9b0b1 Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-25 14:03:07 -04:00
cedrick2711 6232b77094 ajout d'un paneau admin 2026-08-25 13:29:22 -04:00
cedrick2711 a616c79663 regler probleme avec la creation d'equipe et 1 manager par tryout changer pour plusieurs
CI - Security, Lint & Tests / validate (push) Failing after 58s
2026-08-19 21:16:25 -04:00
cedrick2711 48ca62cdd0 Merge branch 'audit/securite-maintenabilite-standards' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards
CI - Security, Lint & Tests / validate (push) Failing after 54s
2026-08-19 19:11:38 -04:00
cedrick2711 1bef716a12 Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards 2026-08-19 19:09:55 -04:00
cedrick2711 a0e31d1a2f ajout dune page batch evaluation 2026-08-17 18:16:22 -04:00
GGThed e15b3c1293 fix(audit): centraliser l'horloge UTC
CI - Security, Lint & Tests / validate (push) Failing after 16m22s
2026-08-17 15:21:08 -04:00
GGThed d7a8907953 fix(audit): moderniser les accès ORM
CI - Security, Lint & Tests / validate (push) Failing after 19m37s
2026-08-17 15:02:29 -04:00
GGThed 105a72700f fix(audit): fermer les frontieres restantes 2026-08-17 14:34:06 -04:00
GGThed f84cb4e3b6 fix(audit): durcir les validations de securite 2026-08-17 13:51:37 -04:00
cedrick2711 9fc4ba0b98 Merge branch 'main' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-08 10:18:51 -04:00
cedrick2711 53e390672e changer ou modifier les dispo 2026-08-05 21:37:15 -04:00
134 changed files with 11367 additions and 2207 deletions
+45
View File
@@ -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 -1
View File
@@ -2,7 +2,7 @@ name: CI - Security & Lint
on: on:
push: push:
branches: [main, master] branches: [main, master, 'audit/**']
pull_request: pull_request:
branches: [main, master] branches: [main, master]
workflow_dispatch: # Allow manual triggers workflow_dispatch: # Allow manual triggers
@@ -94,6 +94,7 @@ jobs:
- name: Run security scan - name: Run security scan
env: env:
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false' FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http run: python app/supporting_scripts/security_scan.py --skip-http
+3
View File
@@ -98,6 +98,9 @@ coverage.xml
# ============================================================================= # =============================================================================
# Base SQLite locale (une base de démo a déjà été committée par le passé, # Base SQLite locale (une base de démo a déjà été committée par le passé,
# voir l'historique du commit `4fd27ba`). # voir l'historique du commit `4fd27ba`).
# Données PostgreSQL locales — gérées par app/local_postgres.py
instance/pgdata/
instance/pg.log
instance/ instance/
*.db *.db
*.sqlite *.sqlite
+29 -9
View File
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# ============================================================================= # =============================================================================
# Optional — storage # Contract storage — Google Drive
# ============================================================================= # =============================================================================
# Where uploaded contracts live. Empty means `documents/` beside the # New contracts are uploaded to the owner's Google Drive. The OAuth client,
# application. Set it to a path OUTSIDE the deployment directory if you move # refresh token, and folder ID are secrets/configuration: do not commit them.
# to a release-directory layout, or a deployment will take the documents with # Use `local` only for isolated development and legacy-file maintenance.
# it (OPS-011, app/storage.py). DOCUMENT_STORAGE_BACKEND=google_drive
#
# IMPORTANT: the backup script reads this same variable. Before wave J it # Target folder in the owner's personal Drive. The application keeps every
# did not, and archived `./documents` regardless — so setting this here and # contract directly in this folder and stores only each Drive file ID in the
# nowhere else produced empty contract backups that still exited 0. # 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= DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both # 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 # FORCE_HTTPS=false no redirect to HTTPS
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback # 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 # `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
# development session cannot accidentally inherit a production binding. # development session cannot accidentally inherit a production binding.
+52
View File
@@ -26,8 +26,10 @@ from sqlalchemy import text
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from app import i18n from app import i18n
from app.api import json_endpoint
from app.extensions import babel, csrf, db, limiter, login_manager from app.extensions import babel, csrf, db, limiter, login_manager
from app.pagination import page_url from app.pagination import page_url
from app.responders import current_revision, is_ajax_submit
load_dotenv() load_dotenv()
@@ -347,6 +349,42 @@ def create_app(config=None):
response.headers['X-Request-Id'] = g.get('request_id', '-') response.headers['X-Request-Id'] = g.get('request_id', '-')
return response return response
@app.before_request
def reject_stale_ajax_write():
"""Reject an AJAX edit whose view is stale, instead of overwriting.
The AJAX form handler in main.js sends the revision it last saw in the
X-Revision header. If another client has committed since then the
server's revision has moved on, and clobbering their change would be
silent last-write-wins. Answer 409 so the client asks the user to
reload the page first.
"""
if request.method != 'POST':
return None
if not is_ajax_submit():
return None
sent = request.headers.get('X-Revision')
if sent is None:
return None
try:
sent = int(sent)
except (TypeError, ValueError):
return None
if sent != current_revision():
return (
jsonify(
{
'error': 'conflict',
'message': _(
'This page was changed by someone else. Reload to '
'see the latest changes before saving.'
),
}
),
409,
)
return None
@app.url_defaults @app.url_defaults
def version_static_urls(endpoint, values): def version_static_urls(endpoint, values):
"""Stamp every static URL with the file's modification time. """Stamp every static URL with the file's modification time.
@@ -403,6 +441,7 @@ def create_app(config=None):
configure_logging(app) configure_logging(app)
from app.routes.admin import admin_bp
from app.routes.auth import auth_bp from app.routes.auth import auth_bp
from app.routes.evaluations import evaluations_bp from app.routes.evaluations import evaluations_bp
from app.routes.main import main_bp from app.routes.main import main_bp
@@ -420,6 +459,7 @@ def create_app(config=None):
app.register_blueprint(teams_bp) app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp) app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp) app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
# Register custom Jinja filters # Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br app.jinja_env.filters['nl2br'] = nl2br
@@ -529,6 +569,18 @@ def create_app(config=None):
return jsonify(health_data), 200 return jsonify(health_data), 200
@app.route('/api/revision')
@json_endpoint
def data_revision():
"""The current dataset revision, for the AJAX form handler in main.js.
One primary-key lookup in app_settings. The client compares this with
the revision it last saw to decide whether a focus refresh is needed,
and sends it back with every AJAX mutation so a stale edit is rejected
with a 409 instead of overwriting someone else's change.
"""
return jsonify({'revision': current_revision()})
# ========================================================================= # =========================================================================
# Custom Error Handlers # Custom Error Handlers
# ========================================================================= # =========================================================================
+85 -12
View File
@@ -65,6 +65,8 @@ from discord.ext import commands
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.time_utils import utc_now_naive
load_dotenv() load_dotenv()
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
@@ -490,6 +492,19 @@ class TeamTryoutsBot(commands.Bot):
await self._send_schedule_notification(**item['data']) await self._send_schedule_notification(**item['data'])
elif kind == 'one_on_one_response': elif kind == 'one_on_one_response':
await self._send_one_on_one_response_dm(**item['data']) await self._send_one_on_one_response_dm(**item['data'])
elif kind == 'purge_pending':
participant_ids = item.get('data', {}).get('participant_ids', [])
if participant_ids:
changed = False
for msg_id, record in list(self.pending_requests.items()):
if (
record.get('type') == 'schedule_addition'
and record.get('id') in participant_ids
):
del self.pending_requests[msg_id]
changed = True
if changed:
self._save_pending()
else: else:
logger.error('Unknown queue item type %r; the notification was dropped', kind) logger.error('Unknown queue item type %r; the notification was dropped', kind)
@@ -671,6 +686,7 @@ class TeamTryoutsBot(commands.Bot):
event_date: str, event_date: str,
event_time: str, event_time: str,
reference_id: int, reference_id: int,
change_kind: str = 'scheduled',
) -> int: ) -> int:
"""Send a schedule addition notification to a player. """Send a schedule addition notification to a player.
@@ -681,11 +697,13 @@ class TeamTryoutsBot(commands.Bot):
event_date: Date string. event_date: Date string.
event_time: Time string. event_time: Time string.
reference_id: ID of the MatchParticipant or TryoutRegistration record. reference_id: ID of the MatchParticipant or TryoutRegistration record.
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
""" """
# Look up the DB user to get their Discord user ID # Look up the DB user to get their Discord user ID
from app.extensions import db
from app.models import User as DBUser from app.models import User as DBUser
db_user = DBUser.query.get(user_id) db_user = db.session.get(DBUser, user_id)
if not db_user: if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification") logger.warning(f"DB user {user_id} not found for schedule notification")
return None return None
@@ -696,6 +714,19 @@ class TeamTryoutsBot(commands.Bot):
event_name = "Match" if event_type == 'match' else "Tryout" event_name = "Match" if event_type == 'match' else "Tryout"
if change_kind == 'modified':
message = (
f"📅 **{event_name} Modified**\n\n"
f"The {event_type} date and/or time has been modified for "
f"**{event_title}**.\n"
f"The new date and/or time is:\n"
f"**Date:** {event_date}\n"
f"**Time:** {event_time}\n\n"
"Please confirm your attendance:\n"
f"{CHECK_EMOJI} - Confirm attendance\n"
f"{CROSS_EMOJI} - Decline"
)
else:
message = ( message = (
f"📅 **{event_name} Scheduled**\n\n" f"📅 **{event_name} Scheduled**\n\n"
f"You have been added to the following {event_type}:\n" f"You have been added to the following {event_type}:\n"
@@ -754,7 +785,7 @@ class TeamTryoutsBot(commands.Bot):
from app.models import OneOnOneRequest from app.models import OneOnOneRequest
try: try:
request = OneOnOneRequest.query.get(request_id) request = db.session.get(OneOnOneRequest, request_id)
if not request: if not request:
# The row is gone; no reaction on this message can ever mean # The row is gone; no reaction on this message can ever mean
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS # anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
@@ -779,7 +810,7 @@ class TeamTryoutsBot(commands.Bot):
coach_obj = request.coach coach_obj = request.coach
request.status = 'approved' request.status = 'approved'
request.responded_at = datetime.utcnow() request.responded_at = utc_now_naive()
except SQLAlchemyError: except SQLAlchemyError:
db.session.rollback() db.session.rollback()
logger.exception('Could not read One on One request %s to approve it', request_id) logger.exception('Could not read One on One request %s to approve it', request_id)
@@ -825,7 +856,7 @@ class TeamTryoutsBot(commands.Bot):
from app.models import OneOnOneRequest from app.models import OneOnOneRequest
try: try:
request = OneOnOneRequest.query.get(request_id) request = db.session.get(OneOnOneRequest, request_id)
if not request: if not request:
logger.info( logger.info(
'One on One request %s no longer exists; its pending message was dropped.', 'One on One request %s no longer exists; its pending message was dropped.',
@@ -874,7 +905,7 @@ class TeamTryoutsBot(commands.Bot):
try: try:
request.status = 'rejected' request.status = 'rejected'
request.responded_at = datetime.utcnow() request.responded_at = utc_now_naive()
if refusal_note: if refusal_note:
request.coach_rejection_message = refusal_note request.coach_rejection_message = refusal_note
except SQLAlchemyError: except SQLAlchemyError:
@@ -918,12 +949,13 @@ class TeamTryoutsBot(commands.Bot):
Returns: Returns:
tuple: (row, player_id) — either may be None. tuple: (row, player_id) — either may be None.
""" """
from app.extensions import db
from app.models import MatchParticipant, TryoutRegistration from app.models import MatchParticipant, TryoutRegistration
if event_type == 'match': if event_type == 'match':
row = MatchParticipant.query.get(reference_id) row = db.session.get(MatchParticipant, reference_id)
elif event_type == 'tryout': elif event_type == 'tryout':
row = TryoutRegistration.query.get(reference_id) row = db.session.get(TryoutRegistration, reference_id)
else: else:
row = None row = None
return row, getattr(row, 'player_id', None) return row, getattr(row, 'player_id', None)
@@ -937,11 +969,12 @@ class TeamTryoutsBot(commands.Bot):
attendance handlers did not (OPS-009) — same message shape, same attendance handlers did not (OPS-009) — same message shape, same
threat, one of them checked. The asymmetry was the bug. threat, one of them checked. The asymmetry was the bug.
""" """
from app.extensions import db
from app.models import User from app.models import User
if not player_id: if not player_id:
return False return False
owner = User.query.get(player_id) owner = db.session.get(User, player_id)
return bool(owner and owner.discord_user_id == str(reacting_user.id)) return bool(owner and owner.discord_user_id == str(reacting_user.id))
async def handle_attendance_confirm(self, player, message_id, reference_id, channel): async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
@@ -987,6 +1020,12 @@ class TeamTryoutsBot(commands.Bot):
player.id, player.id,
reference_id, reference_id,
) )
else:
# The row no longer exists (e.g. match was edited, participant
# was deleted). Silently drop the stale pending entry so the
# reaction is not answerable again. No user-facing reply.
self._forget_pending(message_id)
return
except SQLAlchemyError: except SQLAlchemyError:
db.session.rollback() db.session.rollback()
logger.exception( logger.exception(
@@ -1035,6 +1074,11 @@ class TeamTryoutsBot(commands.Bot):
db.session.delete(row) db.session.delete(row)
else: else:
row.status = 'no_show' row.status = 'no_show'
else:
# The row no longer exists. Silently drop the stale pending
# entry so the reaction is not answerable again.
self._forget_pending(message_id)
return
except SQLAlchemyError: except SQLAlchemyError:
db.session.rollback() db.session.rollback()
logger.exception( logger.exception(
@@ -1217,10 +1261,11 @@ class TeamTryoutsBot(commands.Bot):
async def send_match_reminder(self, player, match): async def send_match_reminder(self, player, match):
"""Send match reminder to player. True if it was delivered.""" """Send match reminder to player. True if it was delivered."""
date_str = match.date.strftime('%A, %B %d, %Y') if match.date else 'TBD'
message = ( message = (
"🔔 **Match Reminder**\n\n" "🔔 **Match Reminder**\n\n"
f"Your match **{match.title}** is scheduled for tomorrow:\n" f"Your match **{match.title}** is scheduled for tomorrow:\n"
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n" f"**Date:** {date_str}\n"
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - " f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n" f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
f"**Location:** {match.location or 'TBD'}\n\n" f"**Location:** {match.location or 'TBD'}\n\n"
@@ -1236,10 +1281,11 @@ class TeamTryoutsBot(commands.Bot):
async def send_tryout_reminder(self, player, tryout): async def send_tryout_reminder(self, player, tryout):
"""Send tryout reminder to player. True if it was delivered.""" """Send tryout reminder to player. True if it was delivered."""
date_str = tryout.date.strftime('%A, %B %d, %Y') if tryout.date else 'TBD'
message = ( message = (
"🔔 **Tryout Reminder**\n\n" "🔔 **Tryout Reminder**\n\n"
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n" f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n" f"**Date:** {date_str}\n"
f"**Location:** {tryout.location or 'TBD'}\n\n" f"**Location:** {tryout.location or 'TBD'}\n\n"
"Please confirm your attendance in the app." "Please confirm your attendance in the app."
) )
@@ -1309,10 +1355,11 @@ class TeamTryoutsBot(commands.Bot):
async def send_one_on_one_reminder(self, player, session): async def send_one_on_one_reminder(self, player, session):
"""Send One on One reminder to player. True if it was delivered.""" """Send One on One reminder to player. True if it was delivered."""
date_str = session.date.strftime('%A, %B %d, %Y') if session.date else 'TBD'
message = ( message = (
"🔔 **One on One Reminder**\n\n" "🔔 **One on One Reminder**\n\n"
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n" f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n" f"**Date:** {date_str}\n"
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n" f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n" f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
"Please prepare for your session!" "Please prepare for your session!"
@@ -1412,8 +1459,13 @@ def send_schedule_notification(
event_date: str, event_date: str,
event_time: str, event_time: str,
reference_id: int, reference_id: int,
change_kind: str = 'scheduled',
) -> bool: ) -> bool:
"""Queue a schedule addition notification to be sent by the bot.""" """Queue a schedule addition notification to be sent by the bot.
Args:
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
"""
return _enqueue( return _enqueue(
'schedule_addition', 'schedule_addition',
{ {
@@ -1423,11 +1475,32 @@ def send_schedule_notification(
'event_date': event_date, 'event_date': event_date,
'event_time': event_time, 'event_time': event_time,
'reference_id': reference_id, 'reference_id': reference_id,
'change_kind': change_kind,
}, },
description=f'the {event_type} notification for user {user_id}', description=f'the {event_type} notification for user {user_id}',
) )
def purge_pending_for_participants(participant_ids):
"""Queue a purge of pending Discord entries for stale participant rows.
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: list[int] of MatchParticipant primary keys that no
longer exist.
"""
if not participant_ids:
return None
return _enqueue(
'purge_pending',
{'participant_ids': participant_ids},
description=f'purge of {len(participant_ids)} stale participant records',
)
def send_one_on_one_response( def send_one_on_one_response(
player_discord_id: str, player_discord_id: str,
player_full_name: str, player_full_name: str,
+1 -1
View File
@@ -22,7 +22,7 @@ babel = Babel()
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in # 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 # 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. # `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): def hash_password(password):
+30
View File
@@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass
if not payload.get(name): if not payload.get(name):
payload.pop(name, None) payload.pop(name, None)
return payload 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
+209
View File
@@ -0,0 +1,209 @@
"""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 root_folder_id():
"""The configured Drive folder that holds every application document.
This is the folder the owner set up (the ``signés`` folder); new
documents land directly in it and signed copies are filed in nested
folders below it.
"""
return _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
def get_or_create_folder(name, parent_id):
"""Return the Drive folder named *name* under *parent_id*, creating it.
Drive has no ``mkdir -p``; creating a folder is a write, so a signed
copy filed under ``<team>/<role>/<username>`` walks the chain once per
upload. A folder already present is reused rather than duplicated.
Args:
name: Folder name to find or create.
parent_id: Drive id of the parent folder.
Returns:
str: The Drive id of the folder.
"""
try:
service = _drive_service()
query = (
f"name='{name}' and mimeType='application/vnd.google-apps.folder' "
f"and '{parent_id}' in parents and trashed=false"
)
response = service.files().list(q=query, fields='files(id, name)', pageSize=10).execute()
existing = response.get('files', [])
if existing:
return existing[0]['id']
folder = (
service.files()
.create(
body={
'name': name,
'mimeType': 'application/vnd.google-apps.folder',
'parents': [parent_id],
},
fields='id',
)
.execute()
)
return folder['id']
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not create the document folder.') from exc
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, parents=None):
"""Upload a PDF to the configured owner folder and return its Drive ID.
Args:
stream: The binary stream to upload.
filename: Name used for the uploaded file.
mimetype: MIME type of the file.
parents: Drive ids of the parent folders. Defaults to the root
folder when omitted, which is how the legacy contract flow
still works.
"""
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': parents or [root_folder_id()],
},
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+442
View File
@@ -0,0 +1,442 @@
"""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 an ``atexit`` handler so it fires on normal exit. Signal
handling goes through ``_handle_stop_signal``, which also stops the
server but then re-raises so the process actually exits.
"""
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)
def _handle_stop_signal(signum, frame):
"""Stop PostgreSQL, then let the interpreter shut down normally.
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
system service, then returns without exiting, leaving the dev server
running against a stopped database and the terminal frozen. Re-raising
``KeyboardInterrupt`` hands control back to the framework, which exits;
the ``atexit`` registration then runs ``_stop`` again (idempotent).
"""
_stop()
raise KeyboardInterrupt
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
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, _handle_stop_signal)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
return DATABASE_URL
+18
View File
@@ -20,6 +20,7 @@ from app.models._constants import (
GAME_PLATFORMS, GAME_PLATFORMS,
PLATFORM_CODES, PLATFORM_CODES,
TRN_URLS, TRN_URLS,
EVALUATION_CRITERIA,
) )
# ========================================================================= # =========================================================================
@@ -93,3 +94,20 @@ from app.models.contract import Contract
from app.models.team_note import TeamNote from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
# =========================================================================
# Layer 12: documents (canonical document + versions + assignments)
# =========================================================================
from app.models.document import (
ACKNOWLEDGMENT_KINDS,
ASSIGNMENT_STATUSES,
DOCUMENT_KIND_ROLES,
DOCUMENT_KINDS,
SIGNATURE_KINDS,
Document,
DocumentAssignment,
DocumentVersion,
)
+10
View File
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True '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
),
)
+17 -2
View File
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout'] 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 = [ ESPORT_GAMES = [
'Valorant', 'Valorant',
'League of Legends', 'League of Legends',
@@ -56,9 +71,9 @@ PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket L
TRN_URLS = { TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', '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}', '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}', 'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{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}', 'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
+56
View File
@@ -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')
+32
View File
@@ -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
+3 -4
View File
@@ -1,8 +1,7 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" """Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class BaseAvailability(db.Model): class BaseAvailability(db.Model):
@@ -13,5 +12,5 @@ class BaseAvailability(db.Model):
day_of_week = db.Column(db.Integer, nullable=False) day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False) start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
+21
View File
@@ -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')
+3 -4
View File
@@ -1,8 +1,7 @@
"""Contract documents for players to sign.""" """Contract documents for players to sign."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class Contract(db.Model): class Contract(db.Model):
@@ -23,7 +22,7 @@ class Contract(db.Model):
status = db.Column(db.String(20), default='pending') status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True) 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) signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts') player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
@@ -57,7 +56,7 @@ class Contract(db.Model):
if isinstance(user, Admin): if isinstance(user, Admin):
return True return True
if isinstance(user, Manager): 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(): if player and player.get_org_teams():
return True return True
if isinstance(user, Coach): if isinstance(user, Coach):
+173
View File
@@ -0,0 +1,173 @@
"""Documents, versions, and per-user assignments.
The old contract flow stored one uploaded file per player even when the
file was identical for everyone, and Drive received no team/role structure
at all. This module replaces that with three two-level concepts:
- ``Document`` the canonical document (e.g. ``Contrat joueur``).
One row per kind; the file is stored once, never
once per person.
- ``DocumentVersion`` an immutable published revision. Uploading a new
PDF is publishing a new version, which is the twice
a year update cycle.
- ``DocumentAssignment`` a per-person obligation. ``UNIQUE(document_id,
user_id)`` is what guarantees a player signs a
document exactly once, whatever the number of teams
they sit on.
Signing obligations are role-scoped, not team-scoped: the team folder in
Drive is a *filing* concern for the signed copy; the obligation itself is
created once per eligible person.
"""
from app.extensions import db
from app.time_utils import utc_now_naive
#: Every document kind this application can hold.
DOCUMENT_KINDS = (
'player',
'coach',
'manager',
'team',
'code_of_conduct',
'ethics_code',
)
#: Kinds whose completion is an uploaded signed PDF.
SIGNATURE_KINDS = ('player', 'coach', 'manager', 'team')
#: Kinds whose completion is read-and-acknowledge only (no file).
ACKNOWLEDGMENT_KINDS = ('code_of_conduct', 'ethics_code')
#: Kind -> set of roles eligible to complete it. ``team`` and the two
#: codes span roles; the three role contracts are single-role by nature.
DOCUMENT_KIND_ROLES = {
'player': ('player',),
'coach': ('coach',),
'manager': ('manager',),
'team': ('player', 'coach', 'manager'),
'code_of_conduct': ('player', 'coach', 'manager'),
'ethics_code': ('player', 'coach', 'manager'),
}
#: Possible values for DocumentAssignment.status.
ASSIGNMENT_STATUSES = ('pending', 'signed', 'acknowledged')
class Document(db.Model):
"""A canonical document, stored once, completed by eligible people."""
__tablename__ = 'documents'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
kind = db.Column(db.String(30), nullable=False, default='player')
#: Admins choose when the document is downloadable by team members. The
#: old ``document_uploads_enabled`` global toggle is replaced by this
#: per-document switch for the new flow.
is_published = db.Column(db.Boolean, nullable=False, default=False)
current_version_id = db.Column(db.Integer, db.ForeignKey('document_versions.id'), nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=utc_now_naive)
versions = db.relationship(
'DocumentVersion',
backref='document',
lazy='dynamic',
order_by='DocumentVersion.version',
foreign_keys='DocumentVersion.document_id',
)
current_version = db.relationship(
'DocumentVersion',
foreign_keys=[current_version_id],
uselist=False,
)
assignments = db.relationship(
'DocumentAssignment',
back_populates='document',
lazy='dynamic',
order_by='DocumentAssignment.created_at',
foreign_keys='DocumentAssignment.document_id',
)
creator = db.relationship('User', foreign_keys=[created_by])
@property
def requires_signature(self):
"""Whether completion means an uploaded signed PDF."""
return self.kind in SIGNATURE_KINDS
def completion_counts(self):
"""(completed, pending) over every assignment of this document.
``completed`` covers both signed and acknowledged assignments.
"""
rows = (
db.session.query(DocumentAssignment.status, db.func.count())
.filter(DocumentAssignment.document_id == self.id)
.group_by(DocumentAssignment.status)
.all()
)
totals = dict(rows)
completed = totals.get('signed', 0) + totals.get('acknowledged', 0)
return completed, totals.get('pending', 0)
def next_version_number(self):
"""The version number a freshly published revision should carry."""
latest = self.versions.order_by(DocumentVersion.version.desc()).first()
return (latest.version + 1) if latest else 1
class DocumentVersion(db.Model):
"""One immutable published revision of a document."""
__tablename__ = 'document_versions'
id = db.Column(db.Integer, primary_key=True)
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
version = db.Column(db.Integer, nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
stored_filename = db.Column(db.String(255), nullable=False)
#: ``gdrive://<file-id>`` for Drive storage, or a path relative to the
#: document root for the local backend.
file_path = db.Column(db.String(500), nullable=False)
notes = db.Column(db.Text, nullable=True)
published_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
published_at = db.Column(db.DateTime, default=utc_now_naive)
publisher = db.relationship('User', foreign_keys=[published_by])
__table_args__ = (
db.UniqueConstraint('document_id', 'version', name='unique_document_version'),
)
class DocumentAssignment(db.Model):
"""One person's obligation to sign or acknowledge a document."""
__tablename__ = 'document_assignments'
id = db.Column(db.Integer, primary_key=True)
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
completed_version_id = db.Column(
db.Integer, db.ForeignKey('document_versions.id'), nullable=True
)
status = db.Column(db.String(20), nullable=False, default='pending')
signed_filename = db.Column(db.String(255), nullable=True)
signed_file_path = db.Column(db.String(500), nullable=True)
completed_at = db.Column(db.DateTime, nullable=True)
#: The team whose Drive folder files the signed copy. A member on
#: several teams still files under one canonical team only.
filed_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
created_at = db.Column(db.DateTime, default=utc_now_naive)
document = db.relationship('Document', back_populates='assignments', foreign_keys=[document_id])
user = db.relationship('User', foreign_keys=[user_id], backref='document_assignments')
completed_version = db.relationship('DocumentVersion', foreign_keys=[completed_version_id])
filed_team = db.relationship('OrgTeam', foreign_keys=[filed_team_id])
__table_args__ = (db.UniqueConstraint('document_id', 'user_id', name='unique_document_user'),)
@property
def completed(self):
return self.status in ('signed', 'acknowledged')
+3 -4
View File
@@ -1,8 +1,7 @@
"""Player evaluation record.""" """Player evaluation record."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class Evaluation(db.Model): class Evaluation(db.Model):
@@ -25,8 +24,8 @@ class Evaluation(db.Model):
overall_score = db.Column(db.Float, nullable=True) overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True) comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True) position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
+3 -4
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match models (Match + TeamMatch).""" """Abstract base class for match models (Match + TeamMatch)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class BaseMatch(db.Model): class BaseMatch(db.Model):
@@ -12,10 +11,10 @@ class BaseMatch(db.Model):
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=True) start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True) end_time = db.Column(db.Time, nullable=True)
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled') status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), 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)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Request from player to coach for a One on One session.""" """Request from player to coach for a One on One session."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class OneOnOneRequest(db.Model): class OneOnOneRequest(db.Model):
@@ -13,12 +12,12 @@ class OneOnOneRequest(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=False) start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True) points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending') 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) responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True) discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True) coach_rejection_message = db.Column(db.Text, nullable=True)
+2 -3
View File
@@ -1,9 +1,8 @@
"""Persistent organisation team (e.g. Varsity, JV).""" """Persistent organisation team (e.g. Varsity, JV)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers from app.models._associations import org_team_coaches, org_team_managers
from app.time_utils import utc_now_naive
class OrgTeam(db.Model): class OrgTeam(db.Model):
@@ -13,7 +12,7 @@ class OrgTeam(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True) name = db.Column(db.String(100), nullable=False, unique=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
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) coach_id = db.Column(db.Integer, db.ForeignKey('users.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)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Many-to-many junction: player to org-team.""" """Many-to-many junction: player to org-team."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TeamPlayer(db.Model): 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) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter') status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True) 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') player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match participant models.""" """Abstract base class for match participant models."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class BaseParticipant(db.Model): class BaseParticipant(db.Model):
@@ -11,4 +10,4 @@ class BaseParticipant(db.Model):
__abstract__ = True __abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 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)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Personal notes from coach to individual player.""" """Personal notes from coach to individual player."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class PersonalNote(db.Model): class PersonalNote(db.Model):
@@ -13,8 +12,8 @@ class PersonalNote(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" """Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class Team(db.Model): class Team(db.Model):
@@ -13,7 +12,7 @@ class Team(db.Model):
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), 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') creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic') members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Link between a player and a tryout-specific team.""" """Link between a player and a tryout-specific team."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TeamMember(db.Model): class TeamMember(db.Model):
@@ -13,6 +12,6 @@ class TeamMember(db.Model):
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True) 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") player = db.relationship('User', overlaps="player_ref,team_assignments")
+3 -4
View File
@@ -1,8 +1,7 @@
"""Team improvement notes from coach.""" """Team improvement notes from coach."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TeamNote(db.Model): 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) 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) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
team = db.relationship('OrgTeam', backref='team_notes') team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id]) coach = db.relationship('User', foreign_keys=[coach_id])
+25 -7
View File
@@ -1,9 +1,8 @@
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
from datetime import datetime
from app.extensions import db 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): class Tryout(db.Model):
@@ -14,23 +13,28 @@ class Tryout(db.Model):
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
game = db.Column(db.String(50), nullable=False) game = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False, index=True)
end_date = db.Column(db.Date, nullable=True) end_date = db.Column(db.Date, nullable=True)
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='upcoming') status = db.Column(db.String(20), default='upcoming')
max_players = db.Column(db.Integer, nullable=True) max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
coach_id = db.Column( coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration ) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') manager = db.relationship(
'User', foreign_keys=[manager_id], backref='managed_tryouts'
) # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts') coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts') coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', 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: if self.end_date is not None:
return self.end_date < today return self.end_date < today
return self.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
+2 -3
View File
@@ -1,8 +1,7 @@
"""Registration linking a player to a tryout.""" """Registration linking a player to a tryout."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TryoutRegistration(db.Model): class TryoutRegistration(db.Model):
@@ -12,6 +11,6 @@ class TryoutRegistration(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.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') status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
+12 -2
View File
@@ -21,7 +21,11 @@ class Manager(User):
return True return True
def can_manage_this_tryout(self, tryout): 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): def can_manage_this_org_team(self, org_team):
return True return True
@@ -32,7 +36,13 @@ class Manager(User):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return ( 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) .order_by(Tryout.date)
.all() .all()
) )
+2 -3
View File
@@ -1,10 +1,9 @@
"""Base User model — shared fields and polymorphic configuration.""" """Base User model — shared fields and polymorphic configuration."""
from datetime import datetime
from flask_login import UserMixin from flask_login import UserMixin
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
@@ -25,7 +24,7 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True) phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=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) failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True) locked_until = db.Column(db.DateTime, nullable=True)
+84
View File
@@ -0,0 +1,84 @@
"""Helpers for mutating routes that should not force a full page reload.
The site is server-rendered, and every mutating route used to end with
``return redirect(url_for(...))``, so each change reloaded the whole page:
scroll reset, open dialogs closed, and chaining several edits meant waiting
for a full render between each one.
These helpers let a route answer an AJAX submit (the ``data-ajax`` forms in
main.js) with a small JSON payload instead of a redirect, while the plain
HTML form path keeps behaving exactly as before. The client then fetches the
page again and swaps only the content region, so the page never reloads.
The counterpart that performs the swap lives in app/static/js/main.js.
"""
from flask import get_flashed_messages, jsonify, redirect, request, session
from app.models import AppSettings
#: Key holding a coarse, monotonically increasing stamp for the whole dataset.
#: It advances after every committed mutation so a stale client can be told
#: (with a 409) that someone else has changed what it is looking at.
REVISION_KEY = 'data_revision'
def is_ajax_submit():
"""Whether this POST came from the fetch-based form handler in main.js."""
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def current_revision():
"""The current revision stamp, as an int (0 when nothing has been bumped)."""
try:
return int(AppSettings.get(REVISION_KEY, '0') or '0')
except (TypeError, ValueError):
return 0
def bump_revision():
"""Advance the revision stamp after a committed change.
Stored through AppSettings so it survives restarts and is shared by every
worker, unlike a module-level counter.
"""
AppSettings.set(REVISION_KEY, str(current_revision() + 1))
def _flashed_success():
"""Whether a success flash is pending, without consuming it.
The HTML path still redirects and must leave the flash for the next page
to render; peeking at the session is the only way to know without clearing
it. Only a successful commit advances the revision -- a rejected edit must
not make other users reload for nothing.
"""
flashes = session.get('_flashes', [])
return any(category == 'success' for category, _message in flashes)
def respond_after_change(redirect_url):
"""Answer a committed (or rejected) mutation.
For a normal HTML form submit this is exactly the ``redirect(...)`` the
routes used to return. For an AJAX submit it returns JSON telling the
client where to fetch the refreshed fragment and which flash messages to
show, so the page can update in place instead of reloading.
"""
if _flashed_success():
bump_revision()
if is_ajax_submit():
messages = [
{'category': category, 'text': message}
for category, message in get_flashed_messages(with_categories=True)
]
return jsonify(
{
'ok': True,
'redirect': redirect_url,
'messages': messages,
'revision': current_revision(),
}
)
return redirect(redirect_url)
+613
View File
@@ -0,0 +1,613 @@
"""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
import uuid
from datetime import datetime
from flask import (
Blueprint,
flash,
redirect,
render_template,
request,
send_file,
url_for,
)
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from werkzeug.utils import secure_filename
from app.extensions import db
from app.forms import flash_validation_errors
from app.models import (
DOCUMENT_KIND_ROLES,
DOCUMENT_KINDS,
Admin,
AppSettings,
AuditLog,
BackupRecord,
Document,
DocumentAssignment,
DocumentVersion,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
Tryout,
User,
)
from app.responders import respond_after_change
from app.routes.users._shared import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
from app.services.documents import backfill_assignments_for_document
from app.storage import GoogleDriveStorageError, open_document, store_master_document
from app.supporting_scripts.backup import (
BackupError,
backup_database,
create_backup_dir,
parse_database_url,
)
from app.validators import DocumentCreateSchema
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'))
# ---------------------------------------------------------------------------
# Documents (canonical document store)
# ---------------------------------------------------------------------------
@admin_bp.route('/documents')
@login_required
def documents():
"""List every document with its published flag and completion counts."""
if not require_admin():
return redirect(url_for('main.dashboard'))
documents = Document.query.order_by(Document.kind, Document.title).all()
return render_template(
'pages/admin_documents.html',
documents=documents,
kinds=DOCUMENT_KINDS,
kind_roles=DOCUMENT_KIND_ROLES,
)
@admin_bp.route('/documents/create', methods=['POST'])
@login_required
def create_document():
"""Create a new canonical document (no file yet; publish one next)."""
if not require_admin():
return redirect(url_for('main.dashboard'))
try:
data = DocumentCreateSchema().load(request.form)
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('admin.documents'))
document = Document(
title=data['title'],
kind=data['kind'],
created_by=current_user.id,
)
db.session.add(document)
db.session.commit()
_log('document_created', f'{data["kind"]}: {data["title"]}')
flash(_('Document "%(title)s" created.', title=data['title']), 'success')
return respond_after_change(url_for('admin.documents'))
@admin_bp.route('/documents/<int:doc_id>/publish-version', methods=['POST'])
@login_required
def publish_document_version(doc_id):
"""Store a new PDF once and record it as the current version."""
if not require_admin():
return redirect(url_for('main.dashboard'))
document = db.get_or_404(Document, doc_id)
file = request.files.get('version_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error:
flash(error, 'danger')
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
original_filename = secure_filename(file.filename)
stored_filename = f'{uuid.uuid4()}.pdf'
try:
stored_path = store_master_document(file, stored_filename)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
version = DocumentVersion(
document_id=document.id,
version=document.next_version_number(),
original_filename=original_filename,
stored_filename=stored_filename,
file_path=stored_path,
notes=request.form.get('notes', '').strip() or None,
published_by=current_user.id,
)
db.session.add(version)
db.session.flush()
document.current_version_id = version.id
db.session.commit()
backfill_assignments_for_document(document)
_log('document_version_published', f'{document.title} v{version.version}')
flash(
_(
'Version %(number)s of "%(title)s" published.',
number=version.version,
title=document.title,
),
'success',
)
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
@admin_bp.route('/documents/<int:doc_id>/toggle-published', methods=['POST'])
@login_required
def toggle_document_published(doc_id):
"""Flip whether team members can see and download the document."""
if not require_admin():
return redirect(url_for('main.dashboard'))
document = db.get_or_404(Document, doc_id)
document.is_published = not document.is_published
db.session.commit()
if document.is_published:
# Enabling a document must also backfill anyone who is nowadays
# eligible but was not when versions were published earlier.
created = backfill_assignments_for_document(document)
_log('document_published', f'{document.title} (assignments: {created})')
flash(
_('"%(title)s" is now downloadable by team members.', title=document.title),
'success',
)
else:
_log('document_unpublished', document.title)
flash(
_('"%(title)s" is no longer downloadable.', title=document.title),
'success',
)
return respond_after_change(url_for('admin.documents'))
@admin_bp.route('/documents/version/<int:version_id>/download')
@login_required
def download_document_version(version_id):
"""Admin download of a stored document revision."""
if not require_admin():
return redirect(url_for('main.dashboard'))
version = db.get_or_404(DocumentVersion, version_id)
try:
document = open_document(version.file_path)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('admin.documents'))
return send_file(document, as_attachment=True, download_name=version.original_filename)
@admin_bp.route('/documents/<int:doc_id>')
@login_required
def document_detail(doc_id):
"""Revisions and per-person completion for one document."""
if not require_admin():
return redirect(url_for('main.dashboard'))
document = db.get_or_404(Document, doc_id)
versions = document.versions.order_by(DocumentVersion.version).all()
assignments = (
db.session.query(DocumentAssignment, User)
.join(User, User.id == DocumentAssignment.user_id)
.filter(DocumentAssignment.document_id == document.id)
.order_by(User.username)
.all()
)
return render_template(
'pages/admin_document_detail.html',
document=document,
versions=versions,
assignments=assignments,
kinds=DOCUMENT_KINDS,
)
+14 -7
View File
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
import os import os
import secrets import secrets
import time import time
from datetime import datetime, timedelta from datetime import timedelta
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
import requests import requests
@@ -18,9 +18,11 @@ from flask_login import current_user, login_required, login_user, logout_user
from marshmallow import ValidationError from marshmallow import ValidationError
from app.extensions import check_password, db, hash_password, limiter 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.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User 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 from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token. #: Session key holding the pending OAuth2 anti-forgery token.
@@ -293,7 +295,7 @@ def login():
) )
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_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( log_auth_event(
'account.throttled', 'account.throttled',
username=username, username=username,
@@ -393,6 +395,13 @@ def register():
full_name = validated['full_name'] full_name = validated['full_name']
phone = validated.get('phone') phone = validated.get('phone')
selected_games = validated.get('games', []) 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 # The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone # hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005). # replace the verified Discord account before submitting (SEC-AUTH-005).
@@ -445,14 +454,12 @@ def register():
# Create UserGamertag records for each selected game # Create UserGamertag records for each selected game
from app.models import UserGamertag from app.models import UserGamertag
for game in selected_games: for game, gamertag_data in submitted_gamertags.items():
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
if gamertag_value:
gamertag = UserGamertag( gamertag = UserGamertag(
user_id=user.id, user_id=user.id,
game=game, game=game,
gamertag=gamertag_value, gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
) )
db.session.add(gamertag) db.session.add(gamertag)
db.session.commit() db.session.commit()
+407 -77
View File
@@ -3,118 +3,326 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
import math
from flask import Blueprint, flash, redirect, render_template, request, url_for from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
from sqlalchemy import func from sqlalchemy import false, func, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from app.extensions import db from app.extensions import db
from app.forms import flash_validation_errors, form_payload from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
EVALUATION_CRITERIA,
GAME_POSITIONS, GAME_POSITIONS,
Admin, Admin,
Coach,
Evaluation, Evaluation,
Manager,
Player, Player,
Scout,
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
) )
from app.pagination import paginate from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE
from app.responders import respond_after_change
from app.time_utils import utc_now_naive
from app.validators import EvaluationSchema from app.validators import EvaluationSchema
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def _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()}
def validate_score(score_value):
"""Turn a raw form value into a criterion score (1..10), or None.
A blank value means "not assessed". Anything else outside 1..10 is a
validation error, matching the single-player form's ``EvaluationSchema``
-- a coach must not be able to quietly turn 11 or 0 into "no score"
(QUA-003).
"""
if score_value is None or str(score_value).strip() == '':
return None
try:
score = int(score_value)
except (TypeError, ValueError) as exc:
raise ValidationError('A score must be a whole number from 1 to 10.') from exc
if not 1 <= score <= 10:
raise ValidationError('Scores run from 1 to 10.')
return score
def _apply_evaluation(evaluation, scores, comments, position):
"""Write validated scores, comments and position onto an Evaluation."""
evaluation.apply_scores(scores)
evaluation.comments = comments
evaluation.position_recommendation = position
def _visible_evaluations_query(user):
"""Evaluations the acting user is allowed to see, as a query.
Admins and scouts see everything. Managers and coaches see the
evaluations authored by managers and coaches inside the tryouts they
manage; players never reach the evaluation pages.
"""
if isinstance(user, (Admin, Scout)):
return Evaluation.query
tryout_ids = [t.id for t in user.get_visible_tryouts()]
if not tryout_ids:
return Evaluation.query.filter(false())
evaluator_alias = aliased(User, name='visible_evaluator')
return (
Evaluation.query
.join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(evaluator_alias.role.in_(['manager', 'coach']))
.filter(Evaluation.tryout_id.in_(tryout_ids))
)
def _visible_evaluation_ids(user):
"""A ``select`` of the ids of evaluations the acting user may see."""
ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery()
return select(ids.c.id)
def _average(values):
"""Mean of the non-None values, rounded to one decimal (or None)."""
values = [value for value in values if value is not None]
if not values:
return None
return round(sum(values) / len(values), 1)
def _aggregate(evaluations):
"""Per-criterion averages plus a combined overall average."""
criterion_averages = [
(label, _average([getattr(e, field) for e in evaluations]))
for field, label in EVALUATION_CRITERIA
]
return {
'criterion_averages': criterion_averages,
'combined_average': _average([e.overall_score for e in evaluations]),
'count': len(evaluations),
}
def _evaluation_view(evaluation, evaluator, tryout):
"""Shape an Evaluation for the templates (scores in criterion order)."""
return {
'evaluation': evaluation,
'evaluator': evaluator,
'tryout': tryout,
'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA],
'overall': evaluation.overall_score,
'position': evaluation.position_recommendation,
'comment': evaluation.comments,
'date': evaluation.created_at,
}
def _players_by_average(user):
"""Players with visible evaluations, best combined average first."""
visible_ids = _visible_evaluation_ids(user)
rows = (
db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.filter(Evaluation.id.in_(visible_ids))
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in rows)
ordered = [
(players_by_id[row.player_id], row.avg_score)
for row in rows
if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player)
]
ordered.sort(
key=lambda pair: (
pair[1] is None,
-(pair[1] if pair[1] is not None else 0),
pair[0].username.lower(),
)
)
return ordered
@evaluations_bp.route('') @evaluations_bp.route('')
@login_required @login_required
def list_evaluations(): def list_evaluations():
"""List all evaluations accessible to the current user.""" """List player evaluations grouped by player, with a per-player view."""
user = current_user user = current_user
if isinstance(user, Player): if isinstance(user, Player):
flash(_('You do not have permission to view evaluations.'), 'danger') flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
sort_column = request.args.get('sort', 'created_at') sort_column = request.args.get('sort', 'overall_score')
sort_order = request.args.get('order', 'desc') sort_order = request.args.get('order', 'desc')
if sort_order not in ('asc', 'desc'): if sort_order not in ('asc', 'desc'):
sort_order = 'desc' sort_order = 'desc'
player_alias = aliased(User, name='eval_player') visible_ids = _visible_evaluation_ids(user)
evaluator_alias = aliased(User, name='eval_evaluator')
sort_map = { aggregate = (
'tryout': Tryout.title,
'player': player_alias.username,
'evaluator': evaluator_alias.username,
'mecanics_score': Evaluation.mecanics_score,
'cohesion_score': Evaluation.cohesion_score,
'communication_score': Evaluation.communication_score,
'gamesense_score': Evaluation.gamesense_score,
'versatility_score': Evaluation.versatility_score,
'discipline_score': Evaluation.discipline_score,
'analysis_score': Evaluation.analysis_score,
'sport_ethics_score': Evaluation.sport_ethics_score,
'mental_score': Evaluation.mental_score,
'overall_score': Evaluation.overall_score,
'position_recommendation': Evaluation.position_recommendation,
'created_at': Evaluation.created_at,
}
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
if sort_order == 'asc':
sort_expr = sort_expr.asc()
else:
sort_expr = sort_expr.desc()
if isinstance(user, Admin):
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr, Evaluation.id)
)
avg_scores = (
db.session.query( db.session.query(
Evaluation.player_id, Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'), func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'), func.avg(Evaluation.overall_score).label('avg_score'),
func.max(Evaluation.created_at).label('last_eval'),
) )
.filter(Evaluation.id.in_(visible_ids))
.group_by(Evaluation.player_id) .group_by(Evaluation.player_id)
.all() .all()
) )
player_scores = {}
for row in avg_scores: players_by_id = _users_by_id(row.player_id for row in aggregate)
p = User.query.get(row.player_id) grouped = []
if p: for row in aggregate:
player_scores[p.id] = { player = players_by_id.get(row.player_id)
'player': p, if not player or not isinstance(player, Player):
continue
grouped.append(
{
'player': player,
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
'count': row.eval_count, 'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0, 'last_eval': row.last_eval,
'evaluations': [],
} }
else:
# Everyone still here evaluates: players were redirected above, and
# can_evaluate() is true for the four remaining roles. The former
# `else` branch listed evaluations *received* — a player's view,
# unreachable from this point (ARCH-007).
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(Evaluation.evaluator_id == user.id)
.order_by(sort_expr, Evaluation.id)
) )
player_scores = {}
def sort_value(entry):
if sort_column == 'player':
return entry['player'].username.lower()
if sort_column == 'count':
return entry['count']
if sort_column == 'created_at':
return entry['last_eval'] or utc_now_naive()
return entry['avg'] if entry['avg'] is not None else float('-inf')
grouped.sort(key=sort_value, reverse=(sort_order == 'desc'))
page = request.args.get('page', 1, type=int) or 1
page = max(1, page)
per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE
per_page = max(1, min(per_page, MAX_PER_PAGE))
total = len(grouped)
pages = math.ceil(total / per_page) if total else 1
page = min(page, pages)
start = (page - 1) * per_page
page_items = grouped[start:start + per_page]
players_on_page = {entry['player'].id: entry for entry in page_items}
if players_on_page:
evaluations = (
Evaluation.query
.filter(Evaluation.player_id.in_(players_on_page))
.filter(Evaluation.id.in_(visible_ids))
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
.all()
)
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
tryouts_by_id = (
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
if tryout_ids
else {}
)
for evaluation in evaluations:
players_on_page[evaluation.player_id]['evaluations'].append(
_evaluation_view(
evaluation,
evaluators_by_id.get(evaluation.evaluator_id),
tryouts_by_id.get(evaluation.tryout_id),
)
)
pagination = {
'page': page,
'pages': pages,
'total': total,
'has_prev': page > 1,
'has_next': page < pages,
'prev_num': page - 1,
'next_num': page + 1,
}
return render_template( return render_template(
'pages/evaluations.html', 'pages/evaluations.html',
evaluations=evaluations_page.items, players=page_items,
pagination=evaluations_page, pagination=pagination,
player_scores=player_scores,
sort_column=sort_column, sort_column=sort_column,
sort_order=sort_order, sort_order=sort_order,
evaluation_criteria=EVALUATION_CRITERIA,
first_player_id=grouped[0]['player'].id if grouped else None,
)
@evaluations_bp.route('/player/<int:player_id>')
@login_required
def player_evaluations(player_id):
"""One player's evaluations, with prev/next navigation between players."""
user = current_user
if not user.can_evaluate():
flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard'))
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only view evaluations for players.'), 'danger')
return redirect(url_for('evaluations.list_evaluations'))
ordered = _players_by_average(user)
index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None)
prev_player = ordered[index - 1][0] if index is not None and index > 0 else None
next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None
visible_ids = _visible_evaluation_ids(user)
evaluations = (
Evaluation.query
.filter(Evaluation.player_id == player.id)
.filter(Evaluation.id.in_(visible_ids))
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
.all()
)
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
tryouts_by_id = (
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
if tryout_ids
else {}
)
evaluation_views = [
_evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id))
for e in evaluations
]
aggregates = _aggregate(evaluations)
return render_template(
'pages/player_evaluations.html',
player=player,
evaluations=evaluation_views,
prev_player=prev_player,
next_player=next_player,
criterion_averages=aggregates['criterion_averages'],
combined_average=aggregates['combined_average'],
evaluation_count=aggregates['count'],
evaluation_criteria=EVALUATION_CRITERIA,
) )
@@ -126,7 +334,7 @@ def evaluate_player(tryout_id, player_id):
flash(_('You do not have permission to evaluate players.'), 'danger') flash(_('You do not have permission to evaluate players.'), 'danger')
return redirect(url_for('main.dashboard')) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -140,12 +348,12 @@ def evaluate_player(tryout_id, player_id):
) )
if not is_registered: if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger') flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger') flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by( existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id, tryout_id=tryout_id,
@@ -160,8 +368,10 @@ def evaluate_player(tryout_id, player_id):
tryout_id=tryout_id, tryout_id=tryout_id,
player_id=player_id, player_id=player_id,
).all() ).all()
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
evaluators = [ 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( return render_template(
@@ -197,7 +407,7 @@ def evaluate_player(tryout_id, player_id):
evaluation.position_recommendation = data['position_recommendation'] evaluation.position_recommendation = data['position_recommendation']
db.session.commit() db.session.commit()
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_evaluation_form() return render_evaluation_form()
@@ -210,21 +420,141 @@ def players_to_evaluate(tryout_id):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('main.dashboard')) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = [] players_by_id = _users_by_id(reg.player_id for reg in registrations)
for reg in registrations: evaluated_player_ids = {
p = User.query.get(reg.player_id) player_id
if p and isinstance(p, Player): for (player_id,) in db.session.query(Evaluation.player_id)
existing = Evaluation.query.filter_by( .filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
tryout_id=tryout_id, .all()
player_id=p.id, }
evaluator_id=current_user.id, players = [
).first() {
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg}) '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) 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
try:
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
except ValidationError:
db.session.rollback()
flash(_('Scores must be whole numbers from 1 to 10.'), 'danger')
return render_template(
'pages/batch_evaluate.html',
tryout=tryout,
players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS,
)
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
View File
@@ -223,20 +223,18 @@ def dashboard():
elif isinstance(user, Scout): elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count() stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count() stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = ( top_rows = (
db.session.query( db.session.query(
Evaluation.player_id, User,
func.avg(Evaluation.overall_score).label('avg_score'), func.avg(Evaluation.overall_score).label('avg_score'),
) )
.group_by(Evaluation.player_id) .join(Evaluation, Evaluation.player_id == User.id)
.order_by(func.avg(Evaluation.overall_score).desc()) .filter(User.role == 'player')
.group_by(User.id)
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
.limit(5) .limit(5)
.all() .all()
) )
stats['top_players'] = [] stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
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)))
return render_template('pages/dashboard.html', user=user, stats=stats) return render_template('pages/dashboard.html', user=user, stats=stats)
+200 -55
View File
@@ -5,7 +5,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons.
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, flash, jsonify, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
@@ -26,11 +26,14 @@ from app.models import (
PlayerDisponibility, PlayerDisponibility,
Scout, Scout,
Team, Team,
TeamMatch,
TeamMember, TeamMember,
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
) )
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema from app.validators import MatchEditSchema, MatchSchema
@@ -46,6 +49,25 @@ def match_form_payload():
return form_payload(list_fields=('player_ids',), optional_blank=()) 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. #: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30 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() return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
def calendar_event_time_fields(date, start_time, end_time):
"""FullCalendar date/time fields for a match.
A date-only event renders in the all-day row; a ``start``/``end`` with a
time places it on the clock for the same calendar day. Matches, team
matches and one-on-one sessions are stored as a date plus optional time
columns, so the feed translates that into the shape FullCalendar expects.
"""
if not start_time:
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
fields = {
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
'allDay': False,
}
if end_time:
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
return fields
def create_participants(match, data): def create_participants(match, data):
"""Attach participants to a match, per its type. """Attach participants to a match, per its type.
@@ -146,13 +187,17 @@ def api_events():
president — and then issue one MatchParticipant query per match to find president — and then issue one MatchParticipant query per match to find
out whether the viewer was in it. The calendar's cost grew with the out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation. whole history, on every navigation.
Tryout matches, regular-season team matches and approved one-on-one
sessions share this feed. Timed rows carry `start`/`end` so
FullCalendar places them on the clock instead of the all-day row.
""" """
events = [] events = []
tryouts = get_visible_tryouts_for_user() tryouts = get_visible_tryouts_for_user()
window_start, window_end = calendar_window(request.args)
tryouts_by_id = {tryout.id: tryout for tryout in tryouts} tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
if tryouts_by_id: if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id)) query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start: if window_start:
query = query.filter(Match.date >= window_start) query = query.filter(Match.date >= window_start)
@@ -179,7 +224,6 @@ def api_events():
for match in matches: for match in matches:
tryout = tryouts_by_id[match.tryout_id] tryout = tryouts_by_id[match.tryout_id]
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# 'description' used to be participants_str + '<br>' + description. # 'description' used to be participants_str + '<br>' + description.
# Building presentation markup inside a JSON field is what carried # Building presentation markup inside a JSON field is what carried
# the stored XSS: the browser dropped it straight into innerHTML, # the stored XSS: the browser dropped it straight into innerHTML,
@@ -206,13 +250,10 @@ def api_events():
user_participant = mine_by_match.get(match.id) user_participant = mine_by_match.get(match.id)
events.append( event = {
{
'id': f'match_{match.id}', 'id': f'match_{match.id}',
'title': match.title, 'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'type': 'match',
'color': match_color,
'extendedProps': { 'extendedProps': {
'location': match.location or tryout.location or 'TBD', 'location': match.location or tryout.location or 'TBD',
'status': match.status, 'status': match.status,
@@ -229,7 +270,49 @@ def api_events():
else False, 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',
'extendedProps': {
'location': team_match.location or 'TBD',
'status': team_match.status,
'description': team_match.description or '',
'org_team': team_match.org_team.name if team_match.org_team else '',
'opponent': team_match.opponent or '',
'start_time': start_time_str,
'end_time': end_time_str,
},
}
event.update(
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
)
events.append(event)
# Add approved One on One sessions for the current user (player or coach) # Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player): if isinstance(current_user, Player):
@@ -244,13 +327,10 @@ def api_events():
one_on_ones = [] one_on_ones = []
for ooo in one_on_ones: for ooo in one_on_ones:
events.append( event = {
{
'id': f'one_on_one_{ooo.id}', 'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one', 'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': { 'extendedProps': {
'location': 'Discord / Voice Chat', 'location': 'Discord / Voice Chat',
'status': 'approved', 'status': 'approved',
@@ -260,7 +340,8 @@ def api_events():
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}", 'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
}, },
} }
) event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
events.append(event)
return jsonify(events) return jsonify(events)
@@ -270,7 +351,7 @@ def api_events():
@login_required @login_required
def api_events_for_tryout(tryout_id): def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout.""" """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) can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False is_registered = False
@@ -332,11 +413,9 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append( event = {
{
'id': f'match_{match.id}', 'id': f'match_{match.id}',
'title': match.title, 'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'type': 'match',
'color': match_color, 'color': match_color,
'extendedProps': { 'extendedProps': {
@@ -350,7 +429,8 @@ def api_events_for_tryout(tryout_id):
'end_time': end_time_str, 'end_time': end_time_str,
}, },
} }
) event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
events.append(event)
return jsonify(events) return jsonify(events)
@@ -359,21 +439,17 @@ def api_events_for_tryout(tryout_id):
@login_required @login_required
def create_match(tryout_id): def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout.""" """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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger') flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended: if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger') flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() all_players = registered_players(tryout_id)
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)
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
def rerender(): def rerender():
@@ -428,7 +504,7 @@ def create_match(tryout_id):
) )
flash(_('Match scheduled successfully!'), 'success') flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return rerender() return rerender()
@@ -437,21 +513,19 @@ def create_match(tryout_id):
@login_required @login_required
def edit_match(match_id): def edit_match(match_id):
"""Edit an existing match.""" """Edit an existing match."""
match = Match.query.get_or_404(match_id) match = db.get_or_404(Match, match_id)
tryout = match.tryout tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this match.'), 'danger') flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('matches.calendar')) return respond_after_change(url_for('matches.calendar'))
if tryout.is_ended: if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger') flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all() teams = Team.query.filter_by(tryout_id=tryout.id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() all_players = registered_players(tryout.id)
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)
current_player_ids = [p.player_id for p in match.participants.all()] 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()] 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()] team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
@@ -492,6 +566,12 @@ def edit_match(match_id):
flash_validation_errors(err) flash_validation_errors(err)
return rerender() return rerender()
# Snapshot the date/time before we overwrite them, so we can detect
# whether only these fields changed and choose the right Discord message.
old_date = match.date
old_start = match.start_time
old_end = match.end_time
# Assigned only once the whole form has been accepted. Assigning as # Assigned only once the whole form has been accepted. Assigning as
# each field was read meant a form rejected halfway had already # each field was read meant a form rejected halfway had already
# changed the record in the session. # changed the record in the session.
@@ -503,41 +583,106 @@ def edit_match(match_id):
match.location = data['location'] match.location = data['location']
match.status = data['status'] match.status = data['status']
notified_player_ids = [] time_changed = (
notified_participant_ids = [] 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': 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.team1_id = data['team1_id']
match.team2_id = data['team2_id'] match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data) for team_id in (data['team1_id'], data['team2_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: if team_id:
notified_player_ids.extend( for member in TeamMember.query.filter_by(team_id=team_id).all():
m.player_id for m 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: else:
MatchParticipant.query.filter_by(match_id=match.id).delete() for pid in data['player_ids']:
notified_player_ids, notified_participant_ids = create_participants(match, data) 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()
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:
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() db.session.commit()
# Purge Discord pending entries for participants that no longer exist.
if removed_ids:
from app.discord_bot 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))
notify_participants( notify_participants(
title=match.title, title=match.title,
date=match.date, date=match.date,
start_time=match.start_time, start_time=match.start_time,
end_time=match.end_time, end_time=match.end_time,
participants=zip_participants(notified_player_ids, notified_participant_ids), participants=all_current,
fallback_id=match.id, 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)
if pid in new_player_ids
]
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=new_pairs,
fallback_id=match.id,
change_kind='scheduled',
)
# else: nothing changed that needs a notification.
flash(_('Match updated successfully!'), 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return rerender() return rerender()
@@ -569,14 +714,14 @@ def api_manageable_tryouts():
@login_required @login_required
def delete_match(match_id): def delete_match(match_id):
"""Delete a match.""" """Delete a match."""
match = Match.query.get_or_404(match_id) match = db.get_or_404(Match, match_id)
tryout = match.tryout tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('matches.calendar')) return respond_after_change(url_for('matches.calendar'))
if tryout.is_ended: if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger') flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Notes outlive the match they were taken during: a coach's observation # Notes outlive the match they were taken during: a coach's observation
# keeps its value, and deleting it here would destroy unrelated content. # keeps its value, and deleting it here would destroy unrelated content.
@@ -589,7 +734,7 @@ def delete_match(match_id):
db.session.delete(match) db.session.delete(match)
db.session.commit() db.session.commit()
flash(_('Match deleted successfully.'), 'success') flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
def get_players_available_at_time(date_str, time_str): def get_players_available_at_time(date_str, time_str):
@@ -656,10 +801,10 @@ def api_available_players(date, time):
@login_required @login_required
def toggle_presence(match_id, participant_id): def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant.""" """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 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: if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400 return jsonify({'error': 'Participant does not belong to this match'}), 400
+40 -15
View File
@@ -3,9 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from datetime import datetime from flask import Blueprint, flash, jsonify, render_template, request, url_for
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
@@ -15,6 +13,7 @@ from app.extensions import db
from app.forms import flash_validation_errors, form_payload from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
Admin, Admin,
AppSettings,
Coach, Coach,
Manager, Manager,
OrgTeam, OrgTeam,
@@ -25,8 +24,10 @@ from app.models import (
) )
from app.pagination import paginate from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.responders import respond_after_change
from app.routes.matches import default_end_time from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive
from app.validators import TeamMatchSchema from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') 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) 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('') @team_matches_bp.route('')
@login_required @login_required
def list_matches(): def list_matches():
@@ -100,7 +112,7 @@ def list_matches():
teams=teams, teams=teams,
match_data=match_data, match_data=match_data,
pagination=matches_page, pagination=matches_page,
now=datetime.utcnow(), now=utc_now_naive(),
) )
@@ -108,10 +120,16 @@ def list_matches():
@login_required @login_required
def create_match(team_id): def create_match(team_id):
"""Create a new regular-season team match.""" """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): if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger') flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all() team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
@@ -204,7 +222,7 @@ def create_match(team_id):
flash( flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success' _('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
) )
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players) return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -213,12 +231,12 @@ def create_match(team_id):
@login_required @login_required
def edit_match(match_id): def edit_match(match_id):
"""Edit an existing team match.""" """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 team = team_match.org_team
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash(_('You do not have permission to edit this match.'), 'danger') flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
if request.method == 'POST': if request.method == 'POST':
# The three date and time fields used to be checked one at a time, # The three date and time fields used to be checked one at a time,
@@ -248,7 +266,7 @@ def edit_match(match_id):
db.session.commit() db.session.commit()
flash(_('Match updated successfully!'), 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
return render_template( return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[] 'pages/team_match_form.html', match=team_match, team=team, team_players=[]
@@ -259,15 +277,22 @@ def edit_match(match_id):
@login_required @login_required
def delete_match(match_id): def delete_match(match_id):
"""Delete a team match.""" """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 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): if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
db.session.delete(team_match) db.session.delete(team_match)
db.session.commit() db.session.commit()
flash(_('Match deleted successfully.'), 'success') flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches')) return respond_after_change(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams') @team_matches_bp.route('/api/manageable-teams')
@@ -293,10 +318,10 @@ def api_manageable_teams():
@login_required @login_required
def toggle_presence(match_id, participant_id): def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant.""" """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 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: if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400 return jsonify({'error': 'Participant does not belong to this match'}), 400
+85 -68
View File
@@ -3,9 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from datetime import datetime from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
@@ -29,7 +27,10 @@ from app.models import (
User, User,
) )
from app.permissions import visible_org_teams from app.permissions import visible_org_teams
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema from app.responders import respond_after_change
from app.services.documents import backfill_assignments_for_user
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams') teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -77,12 +78,12 @@ def my_teams():
"""View the player's own teams with upcoming matches.""" """View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player): if not isinstance(current_user, Player):
flash(_('This page is for players.'), 'info') flash(_('This page is for players.'), 'info')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams() player_teams = current_user.get_org_teams()
now = datetime.utcnow() now = utc_now_naive()
team_data = [] team_data = []
for org_team in player_teams: for org_team in player_teams:
@@ -184,18 +185,18 @@ def create_team():
"""Create a new organization team.""" """Create a new organization team."""
if not current_user.can_manage_teams(): if not current_user.can_manage_teams():
flash(_('You do not have permission to create teams.'), 'danger') flash(_('You do not have permission to create teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
try: try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids'))) data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
name = data['name'] name = data['name']
if OrgTeam.query.filter_by(name=name).first(): if OrgTeam.query.filter_by(name=name).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger') flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
coach = _staff_member(data['coach_id'], Coach) coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager) manager = _staff_member(data['manager_id'], Manager)
@@ -215,29 +216,32 @@ def create_team():
team.managers.append(manager) team.managers.append(manager)
db.session.commit() db.session.commit()
for staff_user in (coach, manager):
if staff_user:
backfill_assignments_for_user(staff_user)
flash(_('Team "%(name)s" created successfully!', name=name), 'success') flash(_('Team "%(name)s" created successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/edit', methods=['POST']) @teams_bp.route('/<int:team_id>/edit', methods=['POST'])
@login_required @login_required
def edit_team(team_id): def edit_team(team_id):
"""Edit an existing organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger') flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
try: try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids'))) data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
name = data['name'] name = data['name']
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first(): if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger') flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
team.name = name team.name = name
@@ -269,8 +273,10 @@ def edit_team(team_id):
team.managers.append(manager) team.managers.append(manager)
db.session.commit() db.session.commit()
for staff_user in team.get_coaches() + team.get_managers():
backfill_assignments_for_user(staff_user)
flash(_('Team "%(name)s" updated successfully!', name=name), 'success') flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST']) @teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@@ -294,11 +300,11 @@ def delete_team(team_id):
day `Manager.can_manage_this_org_team` is narrowed — which it should be — day `Manager.can_manage_this_org_team` is narrowed — which it should be —
deletion narrows with it instead of staying the one way in. 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)): if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger') flash(_('You do not have permission to delete teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
name = team.name name = team.name
@@ -329,29 +335,29 @@ def delete_team(team_id):
db.session.delete(team) db.session.delete(team)
db.session.commit() db.session.commit()
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success') flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST']) @teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
@login_required @login_required
def add_coach(team_id): def add_coach(team_id):
"""Add a coach to an organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if not data['coach_id']: if not data['coach_id']:
flash(_('Please select a coach.'), 'danger') flash(_('Please select a coach.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
coach = db.session.get(User, data['coach_id']) coach = db.session.get(User, data['coach_id'])
if not coach or not _assignable(coach, Coach): if not coach or not _assignable(coach, Coach):
flash(_('Only coaches can be assigned as coach.'), 'danger') flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first(): if team.coaches.filter_by(id=coach.id).first():
flash( flash(
@@ -362,39 +368,40 @@ def add_coach(team_id):
), ),
'info', 'info',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
team.coaches.append(coach) team.coaches.append(coach)
if not team.coach_id: if not team.coach_id:
team.coach_id = coach.id team.coach_id = coach.id
db.session.commit() db.session.commit()
backfill_assignments_for_user(coach)
flash( flash(
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), _('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
'success', 'success',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST']) @teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
@login_required @login_required
def add_manager(team_id): def add_manager(team_id):
"""Add a manager to an organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if not data['manager_id']: if not data['manager_id']:
flash(_('Please select a manager.'), 'danger') flash(_('Please select a manager.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
manager = db.session.get(User, data['manager_id']) manager = db.session.get(User, data['manager_id'])
if not manager or not _assignable(manager, Manager): if not manager or not _assignable(manager, Manager):
flash(_('Only managers can be assigned as manager.'), 'danger') flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first(): if team.managers.filter_by(id=manager.id).first():
flash( flash(
@@ -405,31 +412,32 @@ def add_manager(team_id):
), ),
'info', 'info',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
team.managers.append(manager) team.managers.append(manager)
if not team.manager_id: if not team.manager_id:
team.manager_id = manager.id team.manager_id = manager.id
db.session.commit() db.session.commit()
backfill_assignments_for_user(manager)
flash( flash(
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), _('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
'success', 'success',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST']) @teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
@login_required @login_required
def remove_coach(team_id): def remove_coach(team_id):
"""Remove a coach from an organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if data['coach_id']: if data['coach_id']:
coach = db.session.get(User, data['coach_id']) coach = db.session.get(User, data['coach_id'])
@@ -443,21 +451,21 @@ def remove_coach(team_id):
db.session.commit() db.session.commit()
flash(_('Coach removed from %(name)s.', name=team.name), 'success') flash(_('Coach removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST']) @teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
@login_required @login_required
def remove_manager(team_id): def remove_manager(team_id):
"""Remove a manager from an organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema()) data = _posted(TeamStaffSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if data['manager_id']: if data['manager_id']:
manager = db.session.get(User, data['manager_id']) manager = db.session.get(User, data['manager_id'])
@@ -471,30 +479,30 @@ def remove_manager(team_id):
db.session.commit() db.session.commit()
flash(_('Manager removed from %(name)s.', name=team.name), 'success') flash(_('Manager removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_player', methods=['POST']) @teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
@login_required @login_required
def add_player(team_id): def add_player(team_id):
"""Add a player to an organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamPlayerSchema()) data = _posted(TeamPlayerSchema())
if data is None: if data is None:
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
if not data['player_id']: if not data['player_id']:
flash(_('Please select a player.'), 'danger') flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
status = data['status'] status = data['status']
player = db.session.get(User, data['player_id']) player = db.session.get(User, data['player_id'])
if not player or not _assignable(player, Player): if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger') flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first() existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing: if existing:
@@ -502,32 +510,33 @@ def add_player(team_id):
_('%(username)s is already on %(name)s.', username=player.username, name=team.name), _('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info', 'info',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status) tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
db.session.add(tp) db.session.add(tp)
db.session.commit() db.session.commit()
backfill_assignments_for_user(player)
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success') flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required @login_required
def remove_player(team_id, player_id): def remove_player(team_id, player_id):
"""Remove a player from an organization team.""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
player = 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() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
flash( flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name), _('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger', 'danger',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
db.session.delete(tp) db.session.delete(tp)
db.session.commit() db.session.commit()
@@ -535,7 +544,7 @@ def remove_player(team_id, player_id):
_('%(username)s removed from %(name)s.', username=player.username, name=team.name), _('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success', 'success',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@@ -543,7 +552,7 @@ def remove_player(team_id, player_id):
@login_required @login_required
def toggle_player_status(team_id, player_id): def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute.""" """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): if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403 return jsonify({'error': 'Permission denied'}), 403
@@ -567,33 +576,37 @@ def toggle_player_status(team_id, player_id):
@login_required @login_required
def add_team_note(team_id): def add_team_note(team_id):
"""Add a team improvement note (coaches only).""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger') flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() try:
if content: data = NoteContentSchema().load(form_payload(list_fields=()))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content) 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.add(note)
db.session.commit() db.session.commit()
flash(_('Team notes added successfully!'), 'success') flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
@login_required @login_required
def add_player_note(team_id, player_id): def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only).""" """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): if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger') flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
@@ -601,12 +614,16 @@ def add_player_note(team_id, player_id):
_('%(username)s is not on %(name)s.', username=player.username, name=team.name), _('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger', 'danger',
) )
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() try:
if content: data = NoteContentSchema().load(form_payload(list_fields=()))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content) 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.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success') flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams')) return respond_after_change(url_for('teams.list_teams'))
+339 -61
View File
@@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons. 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 import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
from sqlalchemy import select
from app.extensions import db from app.extensions import db
from app.forms import flash_validation_errors, form_payload from app.forms import flash_validation_errors, form_payload
@@ -17,6 +16,7 @@ from app.models import (
ESPORT_GAMES, ESPORT_GAMES,
GAME_POSITIONS, GAME_POSITIONS,
Admin, Admin,
AppSettings,
Coach, Coach,
Evaluation, Evaluation,
Manager, Manager,
@@ -32,7 +32,17 @@ from app.models import (
TryoutRegistration, TryoutRegistration,
User, 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') tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -42,9 +52,20 @@ def can_manage():
return isinstance(current_user, (Admin, Manager)) 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(): def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005).""" """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): 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() 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): def _users_by_id(user_ids):
"""Load these users in one query, keyed by id. """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()} 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('') @tryouts_bp.route('')
@login_required @login_required
def list_tryouts(): def list_tryouts():
@@ -87,13 +134,20 @@ def list_tryouts():
Delegates to the polymorphic User subclass's get_visible_tryouts() method. Delegates to the polymorphic User subclass's get_visible_tryouts() method.
""" """
tryouts = current_user.get_visible_tryouts() 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']) @tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
def create_tryout(): def create_tryout():
"""Create a new tryout event. Requires Admin or Manager.""" """Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked():
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(): if not can_manage():
flash(_('You do not have permission to create tryouts.'), 'danger') flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -134,12 +188,12 @@ def create_tryout():
created_by=current_user.id, created_by=current_user.id,
status='upcoming', status='upcoming',
target_org_team_id=data['target_org_team_id'], target_org_team_id=data['target_org_team_id'],
manager_id=data['manager_id'],
) )
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
tryout.coaches = coaches_from_ids(data['coach_ids']) tryout.coaches = coaches_from_ids(data['coach_ids'])
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit() db.session.commit()
flash(_('Tryout created successfully!'), 'success') flash(_('Tryout created successfully!'), 'success')
@@ -152,7 +206,14 @@ def create_tryout():
@login_required @login_required
def edit_tryout(tryout_id): def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout.""" """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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger') 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.location = data['location']
tryout.max_players = data['max_players'] tryout.max_players = data['max_players']
tryout.target_org_team_id = data['target_org_team_id'] tryout.target_org_team_id = data['target_org_team_id']
tryout.manager_id = data['manager_id']
# 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']) 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() db.session.commit()
flash(_('Tryout updated successfully!'), 'success') flash(_('Tryout updated successfully!'), 'success')
@@ -209,14 +276,12 @@ def edit_tryout(tryout_id):
@login_required @login_required
def view_tryout(tryout_id): def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch.""" """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 can_view = False
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
can_view = True can_view = True
elif isinstance(current_user, Manager): elif isinstance(current_user, (Manager, Coach)):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
is_registered = ( is_registered = (
@@ -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]) member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows: for row in member_rows:
members_by_team.setdefault(row.team_id, []).append( members_by_team.setdefault(row.team_id, []).append(
{'player': member_players.get(row.player_id), 'position': row.position} {
'player': member_players.get(row.player_id),
'position': row.position,
'member_id': row.id,
'player_id': row.player_id,
}
) )
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams] team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
@@ -398,7 +468,7 @@ def view_tryout(tryout_id):
matches=matches, matches=matches,
match_data=match_data, match_data=match_data,
game_positions=GAME_POSITIONS, game_positions=GAME_POSITIONS,
now=datetime.utcnow(), now=utc_now_naive(),
) )
@@ -406,56 +476,74 @@ def view_tryout(tryout_id):
@login_required @login_required
def register_for_tryout(tryout_id): def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register.""" """Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player): if not isinstance(current_user, Player):
flash(_('Only players can register for tryouts.'), 'danger') flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']: if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger') flash(_('This tryout is not accepting registrations.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by( existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id tryout_id=tryout_id, player_id=current_user.id
).first() ).first()
if existing: if existing:
flash(_('You are already registered for this tryout.'), 'info') flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players: if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger') flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id) registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration) db.session.add(registration)
db.session.commit() db.session.commit()
flash(_('Successfully registered for tryout!'), 'success') flash(_('Successfully registered for tryout!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@login_required @login_required
def update_status(tryout_id): def update_status(tryout_id):
"""Update the status of a tryout.""" """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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
new_status = request.form.get('status') try:
if new_status in ['upcoming', 'in_progress', 'completed']: data = TryoutStatusSchema().load(form_payload(list_fields=()))
tryout.status = new_status 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() db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success') flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@login_required @login_required
def update_registration_status(tryout_id, player_id): def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status.""" """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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -463,31 +551,42 @@ def update_registration_status(tryout_id, player_id):
registration = TryoutRegistration.query.filter_by( registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id tryout_id=tryout_id, player_id=player_id
).first_or_404() ).first_or_404()
new_status = request.form.get('status') try:
if new_status in ['registered', 'attended', 'no_show']: data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
registration.status = new_status 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() db.session.commit()
flash(_('Registration status updated.'), 'success') flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@login_required @login_required
def register_player(tryout_id): def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches).""" """Manually register a player for a tryout (by managers/coaches)."""
tryout = 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try: try:
data = PlayerSelectionSchema().load(form_payload()) data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not data['player_id']: if not data['player_id']:
flash(_('Please select a player.'), 'danger') flash(_('Please select a player.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Same two checks as the team roster (SEC-16): the right role, and an # Same two checks as the team roster (SEC-16): the right role, and an
# account that has not been deactivated. The select this comes from now # account that has not been deactivated. The select this comes from now
@@ -495,7 +594,7 @@ def register_player(tryout_id):
player = db.session.get(User, data['player_id']) player = db.session.get(User, data['player_id'])
if not player or not isinstance(player, Player) or not player.is_active_account: if not player or not isinstance(player, Player) or not player.is_active_account:
flash(_('Can only register players.'), 'danger') flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first() existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
if existing: if existing:
@@ -503,31 +602,38 @@ def register_player(tryout_id):
_('%(username)s is already registered for this tryout.', username=player.username), _('%(username)s is already registered for this tryout.', username=player.username),
'info', 'info',
) )
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players: if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger') flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id) registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration) db.session.add(registration)
db.session.commit() db.session.commit()
flash(_('%(username)s registered for tryout!', username=player.username), 'success') flash(_('%(username)s registered for tryout!', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required @login_required
def remove_player(tryout_id, player_id): def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches).""" """Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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( registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id tryout_id=tryout_id, player_id=player_id
@@ -551,36 +657,54 @@ def remove_player(tryout_id, player_id):
db.session.commit() db.session.commit()
flash(_('%(username)s removed from tryout.', username=player.username), 'success') flash(_('%(username)s removed from tryout.', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@login_required @login_required
def create_team(tryout_id): def create_team(tryout_id):
"""Create a tryout-specific team.""" """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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name') try:
if team_name: data = TryoutTeamSchema().load(form_payload(list_fields=()))
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id) 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.add(team)
db.session.commit() db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success') flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@login_required @login_required
def add_to_team(tryout_id, team_id): def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team.""" """Add a player to a tryout team."""
team = Team.query.get_or_404(team_id) team = db.get_or_404(Team, team_id)
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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being # The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to # allowed to manage tryout A was enough to modify a team belonging to
@@ -588,10 +712,12 @@ def add_to_team(tryout_id, team_id):
if team.tryout_id != tryout_id: if team.tryout_id != tryout_id:
abort(404) abort(404)
player_id = request.form.get('player_id', type=int) try:
if not player_id: data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
flash(_('Please select a player.'), 'danger') except ValidationError as err:
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 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. # Only players registered for this tryout may be placed on its teams.
is_registered = ( is_registered = (
@@ -600,25 +726,177 @@ def add_to_team(tryout_id, team_id):
) )
if not is_registered: if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger') flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing: if existing:
flash(_('Player is already on this team.'), 'info') flash(_('Player is already on this team.'), 'info')
else: 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.add(member)
db.session.commit() db.session.commit()
flash(_('Player added to team!'), 'success') flash(_('Player added to team!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
@login_required
def edit_team(tryout_id, team_id):
"""Rename a tryout-specific team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team.name = data['team_name']
db.session.commit()
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(tryout_id, team_id):
"""Delete a tryout-specific team and its memberships."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
name = team.name
# Personal note references outlive the team.
PersonalNote.query.filter_by(team_id=team_id).update(
{'team_id': None}, synchronize_session=False
)
# Matches referencing this team: null the FK so the match survives.
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
# Clean participants for those matches (they are no longer in that team).
for match in Match.query.filter(
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
).all():
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
db.session.delete(team)
db.session.commit()
flash(_('Team "%(name)s" deleted.', name=name), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
@login_required
def remove_from_team(tryout_id, team_id, player_id):
"""Remove a player from a tryout team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
synchronize_session=False
)
# Also remove the player from any match participants that were tied to this team.
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(_('Player removed from team.'), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route(
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
)
@login_required
def update_member_position(tryout_id, team_id, member_id):
"""Set or clear a team member's in-game position (role)."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
member = db.get_or_404(TeamMember, member_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id or member.team_id != team_id:
abort(404)
try:
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
member.position = data['position'] or None
db.session.commit()
flash(_('Position updated.'), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required @login_required
def delete_tryout(tryout_id): def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" """Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger') flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
+1
View File
@@ -15,6 +15,7 @@ from app.routes.users import (
accounts, # noqa: F401,E402 accounts, # noqa: F401,E402
availability, # noqa: F401,E402 availability, # noqa: F401,E402
contracts, # noqa: F401,E402 contracts, # noqa: F401,E402
documents, # noqa: F401,E402
notes, # noqa: F401,E402 notes, # noqa: F401,E402
one_on_one, # noqa: F401,E402 one_on_one, # noqa: F401,E402
profile, # noqa: F401,E402 profile, # noqa: F401,E402
+35 -11
View File
@@ -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. can call them with a request context and nothing else.
""" """
from flask import request
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db 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 # 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. # 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.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_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_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 could put an arbitrary file on the server under a name the application
later hands back for download (SEC-021). 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: Args:
file: The uploaded FileStorage, or None. file: The uploaded FileStorage, or None.
allowed_extensions: Extensions to accept, lowercase and without dot. 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: if not (stem and dot) or extension.lower() not in allowed_extensions:
return _('Only PDF files are allowed for contracts.') 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)) head = file.stream.read(len(PDF_SIGNATURE))
file.stream.seek(0) file.stream.seek(0)
if head != PDF_SIGNATURE: if head != PDF_SIGNATURE:
return _('That file is not a PDF, whatever its name says.') 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 return None
def update_user_gamertags(user, selected_games): 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} existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games: for game in selected_games:
gamertag = request.form.get(f'gamertag_{game}', '').strip() payload = submitted.get(game)
platform = (
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
)
existing = existing_gamertags.get(game) existing = existing_gamertags.get(game)
if gamertag: if payload:
if existing: if existing:
existing.gamertag = gamertag existing.gamertag = payload['gamertag']
existing.platform = platform existing.platform = payload['platform']
else: 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) db.session.add(gt)
elif existing: elif existing:
db.session.delete(existing) db.session.delete(existing)
+59 -7
View File
@@ -44,6 +44,7 @@ from app.routes.users._shared import (
) )
from app.routes.users.blueprint import users_bp from app.routes.users.blueprint import users_bp
from app.storage import discard_documents from app.storage import discard_documents
from app.timeslots import total_hours
from app.validators import CreateUserSchema, EditUserSchema 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 # query without a stable ORDER BY can show the same row twice and never
# show another (MNT-14). # show another (MNT-14).
page = paginate(User.query.order_by(User.role, User.username, User.id)) 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']) @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') flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard')) 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': if request.method == 'POST':
actor_name, actor_id = current_user.username, current_user.id 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') flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender() return _rerender()
try:
update_user_gamertags(user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
role_changed = user.role != role role_changed = user.role != role
previous_role = user.role previous_role = user.role
@@ -183,8 +222,6 @@ def edit_user(user_id):
user.discord_user_id = discord_user_id or None user.discord_user_id = discord_user_id or None
user.league_os_profile = league_os_profile 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 # Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema. # been checked against the policy by the schema.
password = validated.get('password') password = validated.get('password')
@@ -249,7 +286,7 @@ def delete_user(user_id):
flash(_('You cannot delete your own account.'), 'danger') flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users')) 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( Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id), db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
@@ -375,5 +412,20 @@ def create_user():
@login_required @login_required
def view_user(user_id): def view_user(user_id):
"""View a public profile for any user.""" """View a public profile for any user."""
user = User.query.get_or_404(user_id) user = db.get_or_404(User, user_id)
return render_template('pages/view_user.html', profile_user=user)
# 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,
)
+4 -15
View File
@@ -5,8 +5,7 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
sessions. sessions.
""" """
from flask import flash, jsonify, redirect, render_template, request, url_for from flask import jsonify, request
from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
@@ -193,7 +192,7 @@ def clear_disponibilities():
@login_required @login_required
def delete_disponibility(disponibility_id): def delete_disponibility(disponibility_id):
"""Delete a disponibility block.""" """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: if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility) db.session.delete(disponibility)
@@ -201,16 +200,14 @@ def delete_disponibility(disponibility_id):
return jsonify({'success': True}) return jsonify({'success': True})
@users_bp.route('/coach-availability', methods=['GET', 'POST']) @users_bp.route('/coach-availability', methods=['POST'])
@json_endpoint @json_endpoint
@login_required @login_required
def manage_coach_availability(): def manage_coach_availability():
"""Manage coach availability for One on One sessions.""" """Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach): if not isinstance(current_user, Coach):
flash(_('Only coaches can manage availability.'), 'danger') return jsonify({'error': 'Unauthorized'}), 403
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots')) accepted, rejected = _load_slots(data.get('slots'))
@@ -248,14 +245,6 @@ def manage_coach_availability():
db.session.commit() db.session.commit()
return jsonify({'success': True, 'saved': len(accepted)}) 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
)
@users_bp.route('/coach-availability/clear', methods=['POST']) @users_bp.route('/coach-availability/clear', methods=['POST'])
@json_endpoint @json_endpoint
+78 -34
View File
@@ -2,7 +2,6 @@
import os import os
import uuid import uuid
from datetime import datetime
from flask import flash, redirect, render_template, request, send_file, url_for from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -11,7 +10,9 @@ from marshmallow import ValidationError
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from app.extensions import db from app.extensions import db
from app.responders import respond_after_change
from app.models import Admin, Coach, Contract, Manager, Player, User from app.models import Admin, Coach, Contract, Manager, Player, User
from app.models.admin_settings import AppSettings
from app.permissions import can_manage_player_contract, coach_player_ids from app.permissions import can_manage_player_contract, coach_player_ids
from app.routes.users._shared import ( from app.routes.users._shared import (
ALLOWED_CONTRACT_EXTENSIONS, ALLOWED_CONTRACT_EXTENSIONS,
@@ -19,7 +20,14 @@ from app.routes.users._shared import (
pdf_upload_error, pdf_upload_error,
) )
from app.routes.users.blueprint import users_bp 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 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() 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') @users_bp.route('/contracts')
@login_required @login_required
def list_contracts(): def list_contracts():
@@ -84,7 +103,11 @@ def upload_contract():
"""Upload a contract for a player.""" """Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)): if not isinstance(current_user, (Admin, Manager, Coach)):
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger') flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return respond_after_change(url_for('users.list_contracts'))
players = manageable_players() players = manageable_players()
@@ -103,15 +126,15 @@ def upload_contract():
if not can_manage_player_contract(current_user, player_id): if not can_manage_player_contract(current_user, player_id):
flash(_('You do not have permission to upload a contract for this player.'), 'danger') flash(_('You do not have permission to upload a contract for this player.'), 'danger')
return redirect(url_for('users.upload_contract')) return respond_after_change(url_for('users.upload_contract'))
file = request.files.get('contract_file') file = request.files.get('contract_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error: if error:
flash(error, 'danger') flash(error, 'danger')
return redirect(url_for('users.upload_contract')) return respond_after_change(url_for('users.upload_contract'))
player = User.query.get_or_404(player_id) player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams() player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None team = player_teams[0] if player_teams else None
@@ -126,9 +149,13 @@ def upload_contract():
if team: if team:
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename) relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
absolute_path = document_path(relative_path) try:
os.makedirs(os.path.dirname(absolute_path), exist_ok=True) stored_path = store_uploaded_document(file, relative_path)
file.save(absolute_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( contract = Contract(
player_id=player_id, player_id=player_id,
@@ -136,7 +163,7 @@ def upload_contract():
uploaded_by_id=current_user.id, uploaded_by_id=current_user.id,
original_filename=original_filename, original_filename=original_filename,
stored_filename=stored_filename, stored_filename=stored_filename,
file_path=relative_path, file_path=stored_path,
notes=notes if notes else None, notes=notes if notes else None,
) )
db.session.add(contract) db.session.add(contract)
@@ -145,7 +172,7 @@ def upload_contract():
_('Contract uploaded successfully for %(username)s!', username=player.username), _('Contract uploaded successfully for %(username)s!', username=player.username),
'success', 'success',
) )
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
return render_template('pages/upload_contract.html', players=players) return render_template('pages/upload_contract.html', players=players)
@@ -154,58 +181,75 @@ def upload_contract():
@login_required @login_required
def upload_signed_contract(contract_id): def upload_signed_contract(contract_id):
"""Upload a signed contract (player only).""" """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): if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger') flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return respond_after_change(url_for('users.list_contracts'))
file = request.files.get('signed_file') file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error: if error:
flash(error, 'danger') flash(error, 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}" signed_filename = f"signed_{contract.stored_filename}"
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename) # Legacy local records keep their signed copy beside the original. Drive
file.save(document_path(signed_path)) # 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_filename = signed_filename
contract.signed_file_path = signed_path contract.signed_file_path = stored_signed_path
contract.status = 'signed' contract.status = 'signed'
contract.signed_at = datetime.utcnow() contract.signed_at = utc_now_naive()
db.session.commit() db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success') flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
@users_bp.route('/contracts/<int:contract_id>/download') @users_bp.route('/contracts/<int:contract_id>/download')
@login_required @login_required
def download_contract(contract_id): def download_contract(contract_id):
"""Download a contract file.""" """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): if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger') flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
return send_file( try:
document_path(contract.file_path), document = open_document(contract.file_path)
as_attachment=True, except GoogleDriveStorageError:
download_name=contract.original_filename, 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') @users_bp.route('/contracts/<int:contract_id>/download_signed')
@login_required @login_required
def download_signed_contract(contract_id): def download_signed_contract(contract_id):
"""Download a signed contract file.""" """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): if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger') flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
if not contract.signed_file_path: if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger') flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts')) return respond_after_change(url_for('users.list_contracts'))
return send_file( try:
document_path(contract.signed_file_path), document = open_document(contract.signed_file_path)
as_attachment=True, except GoogleDriveStorageError:
download_name=contract.signed_filename, 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)
+210
View File
@@ -0,0 +1,210 @@
"""Member-facing document centre.
Players, coaches and managers see the published documents they must sign
or acknowledge, complete them, and download the file drawn from the single
canonical copy in storage. Coaches and managers additionally see progress
for the members of the teams they are attached to.
"""
from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from app.extensions import db
from app.models import (
DOCUMENT_KIND_ROLES,
Admin,
Coach,
Document,
DocumentAssignment,
DocumentVersion,
Manager,
)
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.routes.users._shared import ALLOWED_SIGNED_EXTENSIONS, pdf_upload_error
from app.routes.users.blueprint import users_bp
from app.services.documents import (
backfill_assignments_for_user,
primary_team_for_user,
user_eligible_for_kind,
user_has_any_team,
)
from app.storage import GoogleDriveStorageError, open_document, store_signed_document
from app.time_utils import utc_now_naive
def _team_members(team):
"""Active players, coaches and managers of one team, de-duplicated."""
members = {}
for player in team.players:
if player.is_active_account:
members[player.id] = player
for staff in list(team.get_coaches()) + list(team.get_managers()):
if staff.is_active_account:
members[staff.id] = staff
return sorted(members.values(), key=lambda user: user.username.lower())
@users_bp.route('/documents')
@login_required
def list_my_documents():
"""The current member's obligations, plus team progress for staff."""
backfill_assignments_for_user(current_user)
if isinstance(current_user, Admin):
return redirect(url_for('admin.documents'))
if not user_has_any_team(current_user):
return render_template('pages/my_documents.html', documents=[], teams_progress=None)
published = (
Document.query.filter(Document.is_published.is_(True))
.order_by(Document.kind, Document.title)
.all()
)
documents = []
for doc in published:
if current_user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
assignment = DocumentAssignment.query.filter_by(
document_id=doc.id, user_id=current_user.id
).first()
documents.append({'document': doc, 'assignment': assignment})
teams_progress = None
if isinstance(current_user, (Coach, Manager)):
teams_progress = []
for team in visible_org_teams(current_user):
rows = []
for member in _team_members(team):
statuses = {}
for doc in published:
if member.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
assignment = DocumentAssignment.query.filter_by(
document_id=doc.id, user_id=member.id
).first()
statuses[doc.id] = assignment.status if assignment else None
rows.append({'user': member, 'statuses': statuses})
teams_progress.append({'team': team, 'members': rows, 'documents': published})
return render_template(
'pages/my_documents.html',
documents=documents,
teams_progress=teams_progress,
)
@users_bp.route('/documents/assignments/<int:assignment_id>/sign', methods=['POST'])
@login_required
def sign_document(assignment_id):
"""Upload the signed copy of a pending signature document."""
assignment = db.get_or_404(DocumentAssignment, assignment_id)
if assignment.user_id != current_user.id:
flash(_('You do not have permission to sign this document.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.completed:
flash(_('This document has already been completed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.requires_signature:
flash(_('This document is acknowledged, not signed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.current_version:
flash(_('This document has no version published yet.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
flash(error, 'danger')
return respond_after_change(url_for('users.list_my_documents'))
team = primary_team_for_user(current_user)
if team is None:
flash(_('You must be part of a team before signing documents.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
version = assignment.document.current_version
# The kind prefix keeps separate documents from colliding: a player signs
# both the player contract and the team agreement, each at version 1.
signed_filename = f'{assignment.document.kind}-v{version.version}.pdf'
try:
stored_path = store_signed_document(
file,
team_name=team.name,
role=current_user.role,
username=current_user.username,
filename=signed_filename,
)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('users.list_my_documents'))
assignment.status = 'signed'
assignment.signed_filename = signed_filename
assignment.signed_file_path = stored_path
assignment.completed_version_id = version.id
assignment.completed_at = utc_now_naive()
assignment.filed_team_id = team.id
db.session.commit()
flash(_('Document signed successfully!'), 'success')
return respond_after_change(url_for('users.list_my_documents'))
@users_bp.route('/documents/assignments/<int:assignment_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge_document(assignment_id):
"""Mark a read-and-acknowledge document (code of conduct, ethics)."""
assignment = db.get_or_404(DocumentAssignment, assignment_id)
if assignment.user_id != current_user.id:
flash(_('You do not have permission to acknowledge this document.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.completed:
flash(_('This document has already been completed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.document.requires_signature:
flash(_('This document must be signed, not acknowledged.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.current_version:
flash(_('This document has no version published yet.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
assignment.status = 'acknowledged'
assignment.completed_version_id = assignment.document.current_version.id
assignment.completed_at = utc_now_naive()
db.session.commit()
flash(_('Document acknowledged.'), 'success')
return respond_after_change(url_for('users.list_my_documents'))
@users_bp.route('/documents/version/<int:version_id>/download')
@login_required
def download_document(version_id):
"""Download the canonical file, gated on the admin's published flag."""
version = db.get_or_404(DocumentVersion, version_id)
allowed = isinstance(current_user, Admin) or (
version.document.is_published
and user_eligible_for_kind(current_user, version.document.kind)
)
if not allowed:
flash(_('This document is not available to you.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
try:
file_object = open_document(version.file_path)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('users.list_my_documents'))
return send_file(file_object, as_attachment=True, download_name=version.original_filename)
+120 -58
View File
@@ -7,23 +7,33 @@ it reads exactly what the coach routes write.
from flask import flash, redirect, render_template, request, url_for from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db from app.extensions import db
from app.responders import respond_after_change
from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
Coach, Coach,
Match, Match,
MatchParticipant, MatchParticipant,
OneOnOneRequest, OneOnOneRequest,
OrgTeam,
PersonalNote, PersonalNote,
Player, Player,
Team,
TeamMember,
TeamNote, TeamNote,
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, 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.routes.users.blueprint import users_bp from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@users_bp.route('/my-notes') @users_bp.route('/my-notes')
@@ -116,23 +126,25 @@ def notes_dashboard():
) )
# For context selectors in the form # 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 = ( matches = (
Match.query.filter( Match.query.filter(Match.tryout_id.in_(tryout_ids))
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
)
.order_by(Match.date.desc()) .order_by(Match.date.desc())
.limit(20) .limit(20)
.all() .all()
if tryout_ids
else []
) )
tryouts = ( teams = (
Tryout.query.filter_by( Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
created_by=current_user.id, if tryout_ids
else []
) )
.order_by(Tryout.date.desc())
.limit(20)
.all()
)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
return render_template( return render_template(
'pages/notes.html', 'pages/notes.html',
@@ -165,21 +177,25 @@ def manage_team_notes():
org_teams = coach_org_teams(current_user) org_teams = coach_org_teams(current_user)
if not org_teams: if not org_teams:
flash(_('You are not assigned to a team.'), 'danger') flash(_('You are not assigned to a team.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
org_team = org_teams[0] org_team = org_teams[0]
content = request.form.get('content', '').strip() try:
if content: data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('users.notes_dashboard'))
note = TeamNote( note = TeamNote(
org_team_id=org_team.id, org_team_id=org_team.id,
coach_id=current_user.id, coach_id=current_user.id,
content=content, content=data['content'],
) )
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Team notes saved successfully!'), 'success') flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -195,31 +211,31 @@ def manage_personal_notes():
flash(_('Only coaches can manage personal notes.'), 'danger') flash(_('Only coaches can manage personal notes.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int) try:
content = request.form.get('content', '').strip() 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: player = db.get_or_404(User, player_id)
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id): if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger') flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
note = PersonalNote( note = PersonalNote(
player_id=player_id, player_id=player_id,
coach_id=current_user.id, coach_id=current_user.id,
content=content, content=data['content'],
) )
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success') flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('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') flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int) try:
content = request.form.get('content', '').strip() data = PersonalNoteSchema().load(form_payload(list_fields=()))
match_id = request.form.get('match_id', type=int) except ValidationError as err:
tryout_id = request.form.get('tryout_id', type=int) flash_validation_errors(err)
team_id_str = request.form.get('team_id') return respond_after_change(url_for('users.notes_dashboard'))
player_id = data['player_id']
if not player_id or not content: player = db.get_or_404(User, player_id)
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id): if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger') flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if data['match_id']:
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( note = PersonalNote(
player_id=player_id, player_id=player_id,
coach_id=current_user.id, coach_id=current_user.id,
content=content, content=data['content'],
match_id=match_id if match_id else None, match_id=data['match_id'],
tryout_id=tryout_id if tryout_id else None, tryout_id=data['tryout_id'],
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None, team_id=data['team_id'],
) )
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success') flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -281,7 +321,10 @@ def add_note_from_tryout(tryout_id):
flash(_('Only coaches can add personal notes.'), 'danger') flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard')) 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) preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list # Get registrations as players for the select list
@@ -289,21 +332,29 @@ def add_note_from_tryout(tryout_id):
players = [r.player for r in registrations if r.player] players = [r.player for r in registrations if r.player]
if request.method == 'POST': if request.method == 'POST':
player_id = request.form.get('player_id', type=int) try:
content = request.form.get('content', '').strip() 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: if data['tryout_id'] not in (None, tryout_id):
flash(_('Player and content are required.'), 'danger') flash(_('Invalid tryout context.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id): if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger') flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) 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( note = PersonalNote(
player_id=player_id, player_id=player_id,
coach_id=current_user.id, coach_id=current_user.id,
content=content, content=data['content'],
tryout_id=tryout_id, tryout_id=tryout_id,
) )
db.session.add(note) db.session.add(note)
@@ -334,7 +385,10 @@ def add_note_from_match(match_id):
flash(_('Only coaches can add personal notes.'), 'danger') flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard')) 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 # Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all() participants = MatchParticipant.query.filter_by(match_id=match_id).all()
@@ -343,21 +397,29 @@ def add_note_from_match(match_id):
preselected_player_id = request.args.get('player_id', type=int) preselected_player_id = request.args.get('player_id', type=int)
if request.method == 'POST': if request.method == 'POST':
player_id = request.form.get('player_id', type=int) try:
content = request.form.get('content', '').strip() 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: if data['match_id'] not in (None, match_id):
flash(_('Player and content are required.'), 'danger') flash(_('Invalid match context.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id)) return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id): if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger') flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id)) 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( note = PersonalNote(
player_id=player_id, player_id=player_id,
coach_id=current_user.id, coach_id=current_user.id,
content=content, content=data['content'],
match_id=match_id, match_id=match_id,
) )
db.session.add(note) db.session.add(note)
+23 -18
View File
@@ -1,18 +1,18 @@
"""One-on-one sessions between a player and their coach.""" """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 import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
from app.extensions import db from app.extensions import db
from app.responders import respond_after_change
from app.forms import flash_validation_errors, form_payload from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification from app.services.notifications import send_discord_notification
from app.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']) @users_bp.route('/one-on-one', methods=['GET', 'POST'])
@@ -65,13 +65,13 @@ def one_on_one():
if request.method == 'POST': if request.method == 'POST':
if not coach: if not coach:
flash(_('Cannot request One on One - no coach assigned.'), 'danger') flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
try: try:
data = OneOnOneRequestSchema().load(form_payload()) data = OneOnOneRequestSchema().load(form_payload())
except ValidationError as err: except ValidationError as err:
flash_validation_errors(err) flash_validation_errors(err)
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
date_obj = data['date'] date_obj = data['date']
start_time = data['start_time'] start_time = data['start_time']
@@ -91,7 +91,7 @@ def one_on_one():
if not is_available: if not is_available:
flash(_("The requested time is not within the coach's availability."), 'danger') flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
request_obj = OneOnOneRequest( request_obj = OneOnOneRequest(
player_id=current_user.id, player_id=current_user.id,
@@ -119,7 +119,7 @@ def one_on_one():
) )
flash(_('Your One on One request has been submitted!'), 'success') flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one')) return respond_after_change(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability # Build list of upcoming dates that have coach availability
from datetime import date as date_cls from datetime import date as date_cls
@@ -166,19 +166,19 @@ def accept_one_on_one(request_id):
flash(_('Only coaches can accept One on One requests.'), 'danger') flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard')) 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: if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger') flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if request_obj.status != 'pending': if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info') flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
player = request_obj.player player = request_obj.player
request_obj.status = 'approved' request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow() request_obj.responded_at = utc_now_naive()
db.session.commit() db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions) # Notify player via Discord (same message as if approved through Discord reactions)
@@ -205,7 +205,7 @@ def accept_one_on_one(request_id):
), ),
'success', 'success',
) )
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST']) @users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@@ -216,21 +216,26 @@ def reject_one_on_one(request_id):
flash(_('Only coaches can reject One on One requests.'), 'danger') flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard')) 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: if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger') flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
if request_obj.status != 'pending': if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info') flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
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 player = request_obj.player
request_obj.status = 'rejected' request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow() request_obj.responded_at = utc_now_naive()
if rejection_reason: if rejection_reason:
request_obj.coach_rejection_message = rejection_reason request_obj.coach_rejection_message = rejection_reason
db.session.commit() db.session.commit()
@@ -260,4 +265,4 @@ def reject_one_on_one(request_id):
), ),
'info', 'info',
) )
return redirect(url_for('users.notes_dashboard')) return respond_after_change(url_for('users.notes_dashboard'))
+14 -3
View File
@@ -6,6 +6,7 @@ from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
from app.extensions import db, hash_password from app.extensions import db, hash_password
from app.responders import respond_after_change
from app.logging_config import log_auth_event from app.logging_config import log_auth_event
from app.models import ( from app.models import (
ESPORT_GAMES, ESPORT_GAMES,
@@ -98,6 +99,18 @@ def edit_profile():
user_gamertags=current_user.get_gamertags(), 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.username = username
current_user.full_name = full_name current_user.full_name = full_name
current_user.email = email current_user.email = email
@@ -106,8 +119,6 @@ def edit_profile():
current_user.discord_username = discord_username or None current_user.discord_username = discord_username or None
current_user.league_os_profile = league_os_profile 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 # Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema. # been checked against the policy by the schema.
password = validated.get('password') password = validated.get('password')
@@ -119,7 +130,7 @@ def edit_profile():
db.session.commit() db.session.commit()
flash(_('Profile updated successfully!'), 'success') flash(_('Profile updated successfully!'), 'success')
return redirect(url_for('users.profile')) return respond_after_change(url_for('users.profile'))
return render_template( return render_template(
'pages/edit_profile.html', 'pages/edit_profile.html',
+137
View File
@@ -0,0 +1,137 @@
"""Document lifecycle logic: eligibility, assignment backfill, primary team.
The rules this encodes are the club's:
- Only *active* members who belong to at least one team receive an
obligation. A player not placed on a team signs nothing.
- Obligations are role-scoped. A player on two teams signs the player
contract once; ``UNIQUE(document_id, user_id)`` is the guarantee, and the
team folder below is only where the signed copy is filed.
- Publishing a version (or enabling a document) backfills pending
assignments for everyone currently eligible. Joining a team backfills
that one person across every published document matching their role.
"""
from app.extensions import db
from app.models import (
DOCUMENT_KIND_ROLES,
Coach,
Document,
DocumentAssignment,
Manager,
OrgTeam,
Player,
User,
)
def user_org_team_ids(user):
"""Ids of the organisation teams the user is a member of.
Role-appropriate: placements for a player, the coach/managers
relationships (many-to-many *and* legacy single column) for staff. An
admin or a scout belongs to none, regardless of how many teams exist.
"""
from app.permissions import coach_org_teams, manager_org_teams
if isinstance(user, Player):
return [tp.org_team_id for tp in user.team_placements]
if isinstance(user, Coach):
return [team.id for team in coach_org_teams(user)]
if isinstance(user, Manager):
return [team.id for team in manager_org_teams(user)]
return []
def user_has_any_team(user):
"""Whether the user belongs to at least one organisation team."""
return bool(user_org_team_ids(user))
def primary_team_for_user(user):
"""The canonical team used for filing a signed copy, or None.
A member can sit on several teams but every signed file is filed under
one team only. The choice is deterministic: the team with the lowest
id among the user's memberships.
"""
team_ids = user_org_team_ids(user)
if not team_ids:
return None
return db.session.get(OrgTeam, min(team_ids))
def user_eligible_for_kind(user, kind):
"""Whether *user* must complete documents of *kind*."""
if kind not in DOCUMENT_KIND_ROLES:
return False
if not user.is_active_account:
return False
if user.role not in DOCUMENT_KIND_ROLES[kind]:
return False
return user_has_any_team(user)
def eligible_user_ids(kind):
"""Ids of every user currently eligible for *kind*, ordered by id."""
roles = DOCUMENT_KIND_ROLES[kind]
users = (
User.query.filter(User.role.in_(roles), User.is_active_account.is_(True))
.order_by(User.id)
.all()
)
return [u.id for u in users if user_has_any_team(u)]
def backfill_assignments_for_document(document):
"""Create a pending assignment for every currently eligible user.
Idempotent: anyone who already has an assignment for this document is
left alone. Returns how many new assignments were created.
"""
existing = {a.user_id for a in document.assignments.all()}
created = 0
for user_id in eligible_user_ids(document.kind):
if user_id in existing:
continue
db.session.add(
DocumentAssignment(
document_id=document.id,
user_id=user_id,
status='pending',
)
)
created += 1
if created:
db.session.commit()
return created
def backfill_assignments_for_user(user):
"""Create pending assignments for *user* across published documents.
Called when a member joins (or is reactivated into) a team. Only
published documents are backfilled: an essentially invisible draft
creates no obligations. Returns how many new assignments were created.
"""
if not user.is_active_account or not user_has_any_team(user):
return 0
documents = Document.query.filter(Document.is_published.is_(True)).order_by(Document.id).all()
created = 0
for doc in documents:
if user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
exists = DocumentAssignment.query.filter_by(document_id=doc.id, user_id=user.id).first()
if exists:
continue
db.session.add(
DocumentAssignment(
document_id=doc.id,
user_id=user.id,
status='pending',
)
)
created += 1
if created:
db.session.commit()
return created
+6 -1
View File
@@ -60,7 +60,9 @@ def zip_participants(player_ids, participant_ids):
yield player_id, participant_ids[index] if index < len(participant_ids) else None yield player_id, participant_ids[index] if index < len(participant_ids) else None
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id): def notify_participants(
*, title, date, start_time, end_time, participants, fallback_id, change_kind='scheduled'
):
"""Tell each participant that a match has been scheduled or changed. """Tell each participant that a match has been scheduled or changed.
Args: Args:
@@ -73,6 +75,8 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
attendance lands on the right row. attendance lands on the right row.
fallback_id: Reference to use when a participant row has no id — fallback_id: Reference to use when a participant row has no id —
the match's own, which the bot can still act on. the match's own, which the bot can still act on.
change_kind: 'scheduled' (new match / new player added) or
'modified' (date/time changed for existing players).
Returns: Returns:
int: How many notifications were handed to the bot. int: How many notifications were handed to the bot.
@@ -91,6 +95,7 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
event_date=event_date, event_date=event_date,
event_time=event_time, event_time=event_time,
reference_id=participant_id or fallback_id, reference_id=participant_id or fallback_id,
change_kind=change_kind,
) )
sent += 1 sent += 1
return sent return sent
+274 -15
View File
@@ -1043,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
.fc { .fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--fc-border-color: var(--gray-200);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: var(--gray-50);
--fc-today-bg-color: var(--info-light);
} }
.fc-toolbar { .fc-toolbar {
@@ -1055,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
color: var(--gray-900); color: var(--gray-900);
} }
.fc-button { .fc .fc-button {
background: var(--primary) !important; background: var(--gray-50) !important;
border: none !important; border: 1px solid var(--gray-200) !important;
color: var(--gray-700) !important;
border-radius: var(--radius-sm) !important; border-radius: var(--radius-sm) !important;
padding: 6px 12px !important; padding: 6px 12px !important;
font-size: 0.85rem !important; font-size: 0.85rem !important;
} }
.fc-button:hover { .fc .fc-button:hover {
background: var(--primary-dark) !important; background: var(--gray-100) !important;
color: var(--gray-900) !important;
} }
.fc-button-primary:not(:disabled).fc-button-active, .fc .fc-button-primary:not(:disabled).fc-button-active,
.fc-button-primary:not(:disabled):active { .fc .fc-button-primary:not(:disabled):active {
background: var(--primary-dark) !important; background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
} }
.fc-daygrid-day { .fc-daygrid-day,
.fc-timegrid-slot {
transition: var(--transition); transition: var(--transition);
} }
@@ -1080,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
background: var(--gray-50); background: var(--gray-50);
} }
.fc-event { /* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
border-radius: var(--radius-sm); its inline event colors) */
padding: 2px 4px; #calendar .fc-event {
font-size: 0.8rem; border: none !important;
border-radius: var(--radius-sm) !important;
padding: 3px 6px !important;
font-size: 0.8rem !important;
background-color: var(--fc-event-bg, var(--primary)) !important;
color: #fff !important;
box-shadow: var(--shadow);
} }
.fc-event-title { #calendar .fc-event-body {
font-weight: 600; display: flex;
align-items: center;
gap: 6px;
min-width: 0;
} }
#calendar .fc-event-body i {
flex: 0 0 auto;
font-size: 0.7rem;
opacity: 0.9;
}
#calendar .fc-event-text {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 4px;
min-width: 0;
}
#calendar .fc-event-time {
font-size: 0.72rem;
font-weight: 700;
}
#calendar .fc-event-title {
font-weight: 600 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Event type colors (semantic, light theme) */
#calendar .fc-event--team { --fc-event-bg: #10b981; }
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
#calendar .fc-now-indicator {
border-color: var(--danger);
}
/* Dark theme: the calendar chrome reacts to the light/dark switch */
[data-theme="dark"] .fc {
--fc-border-color: var(--border-color);
--fc-neutral-bg-color: var(--bg-secondary);
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .fc-toolbar-title {
color: var(--text-primary);
}
[data-theme="dark"] .fc .fc-button {
background: var(--bg-tertiary) !important;
border-color: var(--border-color) !important;
color: var(--text-secondary) !important;
}
[data-theme="dark"] .fc .fc-button:hover {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
[data-theme="dark"] .fc-daygrid-day:hover {
background: var(--bg-tertiary);
}
[data-theme="dark"] .fc-daygrid-day-number,
[data-theme="dark"] .fc-col-header-cell-cushion,
[data-theme="dark"] .fc-timegrid-axis-cushion,
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
color: var(--text-secondary);
}
/* Pastel event chips with dark text keep contrast on a dark background. */
[data-theme="dark"] #calendar .fc-event {
color: var(--gray-900) !important;
}
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
.btn-group { .btn-group {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -1256,6 +1360,64 @@ a:hover { color: var(--primary-dark); }
color: white !important; color: white !important;
border-color: #4f46e5 !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 { .merged-disponibility-time-block.high-availability {
background: var(--success); background: var(--success);
@@ -2037,3 +2199,100 @@ a:hover { color: var(--primary-dark); }
.honeypot { .honeypot {
display: none; 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;
}
}
/* Evaluation comments - separate grid so long notes stay readable */
.eval-comments {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin-top: 16px;
}
.eval-comments-title {
grid-column: 1 / -1;
margin: 0 0 4px;
font-size: 0.95rem;
font-weight: 600;
}
.eval-comment {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius-sm);
padding: 12px 14px;
}
.eval-comment-text {
margin: 0 0 10px;
white-space: pre-wrap;
word-break: break-word;
color: var(--gray-800);
}
.eval-comment-meta {
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--gray-500);
}
.eval-comment-author strong {
color: var(--gray-700);
font-weight: 600;
}
[data-theme="dark"] .eval-comment {
background: var(--bg-tertiary);
border-color: var(--border-color);
}
[data-theme="dark"] .eval-comment-text {
color: var(--text-primary);
}
[data-theme="dark"] .eval-comment-meta {
color: var(--text-muted);
}
[data-theme="dark"] .eval-comment-author strong {
color: var(--text-secondary);
}
.eval-comments-empty {
margin: 8px 0 0;
color: var(--gray-500);
font-size: 0.9rem;
}
[data-theme="dark"] .eval-comments-empty {
color: var(--text-muted);
}
+212 -17
View File
@@ -420,22 +420,9 @@ document.addEventListener('DOMContentLoaded', function() {
initDraggableBlocks(); initDraggableBlocks();
/** /**
* Auto-dismiss flash alerts after 5 seconds. * Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
*/ */
const alerts = document.querySelectorAll('.alert-dismissible'); armAlertAutoDismiss();
alerts.forEach(function(alert) {
setTimeout(function() {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function() {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
}); });
/* ========================================================================= /* =========================================================================
Declarative behaviours — replacing inline event handlers Declarative behaviours — replacing inline event handlers
@@ -530,13 +517,31 @@ document.addEventListener('change', function (event) {
* Confirmation before a destructive submit. * Confirmation before a destructive submit.
* *
* <form data-confirm="Delete this match?"> * <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 * Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated. * markup where it can be translated.
*/ */
document.addEventListener('submit', function (event) { document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]'); 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(); event.preventDefault();
} }
}); });
@@ -575,7 +580,7 @@ document.addEventListener('input', function (event) {
document.addEventListener('change', function (event) { document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]'); const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) { if (control && control.form) {
control.form.submit(); submitForm(control.form);
} }
}); });
@@ -595,3 +600,193 @@ document.addEventListener('change', function (event) {
window.location.href = select.getAttribute('data-navigate') window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value)); .replace('{value}', encodeURIComponent(select.value));
}); });
/* =========================================================================
AJAX forms — server-rendered changes without a full page reload
=========================================================================
A <form method="post" data-ajax> is submitted with fetch() instead of a
navigation. The route answers a JSON payload (see app/responders.py) that
says where to fetch the refreshed content and which flash messages to
show; the content region is then swapped in place, so scroll position and
open state survive between edits.
A stale edit — the page changed since this copy was loaded — is answered
409, and the user is asked to reload rather than silently overwrite the
other person's change.
========================================================================= */
let TT_REVISION = null;
function armAlertAutoDismiss() {
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(function (alert) {
if (alert.getAttribute('data-armed')) return;
alert.setAttribute('data-armed', '1');
setTimeout(function () {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function () {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
}
function fetchRevision() {
return fetch('/api/revision', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (data) {
TT_REVISION = data.revision;
return data.revision;
}).catch(function () {
// Revision tracking is best-effort; a failed lookup just disables it.
});
}
function showFlashMessages(messages) {
const container = document.querySelector('.flash-messages');
if (!container || !messages || !messages.length) return;
messages.forEach(function (m) {
const alert = document.createElement('div');
alert.className = 'alert alert-' + (m.category || 'info') + ' alert-dismissible';
const span = document.createElement('span');
span.textContent = m.text;
const close = document.createElement('button');
close.type = 'button';
close.className = 'alert-close';
close.setAttribute('data-action', 'dismiss-alert');
close.setAttribute('aria-label', 'Dismiss');
close.innerHTML = '&times;';
alert.appendChild(span);
alert.appendChild(close);
container.appendChild(alert);
});
armAlertAutoDismiss();
}
function replaceContent(html, url) {
const holder = document.createElement('div');
holder.innerHTML = html;
const next = holder.querySelector('#page-content');
const target = document.getElementById('page-content');
if (next && target) {
target.innerHTML = next.innerHTML;
}
if (url && window.history && window.history.replaceState) {
window.history.replaceState(null, '', url);
}
initDraggableBlocks();
document.dispatchEvent(new Event('fragment:updated'));
}
function refreshPage(url) {
return fetch(url, { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) { replaceContent(html, url); });
}
function submitAjaxForm(form) {
const headers = {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
};
const token = form.querySelector('input[name="csrf_token"]');
if (token) headers['X-CSRFToken'] = token.value;
if (TT_REVISION !== null && TT_REVISION !== undefined) {
headers['X-Revision'] = String(TT_REVISION);
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
body: new FormData(form),
headers: headers,
credentials: 'same-origin'
}).then(function (response) {
return response.json().then(function (data) {
return { status: response.status, data: data };
}).catch(function () {
return { status: response.status, data: null };
});
}).then(function (result) {
if (result.status === 409) {
const message = (result.data && result.data.message) ||
'This page was changed by someone else. Reload to see the latest changes.';
if (window.confirm(message)) {
window.location.reload();
}
return null;
}
if (result.status >= 400) {
const message = (result.data && (result.data.message || result.data.error)) ||
'Something went wrong. Please try again.';
window.alert(message);
return null;
}
return result.data;
}).then(function (data) {
if (!data) return;
if (data.revision !== undefined && data.revision !== null) {
TT_REVISION = data.revision;
}
showFlashMessages(data.messages);
if (data.redirect) {
return refreshPage(data.redirect);
}
return undefined;
}).catch(function (err) {
console.error('AJAX form submission failed:', err);
});
}
function submitForm(form) {
if (form.hasAttribute('data-ajax')) {
submitAjaxForm(form);
} else {
form.submit();
}
}
function checkForRemoteChanges() {
return fetchRevision().then(function (rev) {
if (TT_REVISION !== null && rev !== TT_REVISION) {
TT_REVISION = rev;
return refreshPage(window.location.href);
}
return undefined;
}).catch(function () { /* best-effort */ });
}
// data-submit-on-change and data-ajax both funnel through submitForm so a
// <select> change uses fetch() when the form is marked, and a plain submit
// otherwise. (form.submit() does not fire the submit event, so intercepting
// only 'submit' would miss the selects.)
document.addEventListener('submit', function (event) {
const form = event.target.closest('form[data-ajax]');
if (!form || event.defaultPrevented) return;
event.preventDefault();
submitAjaxForm(form);
});
// Refresh when the user returns to the tab: if someone else changed the data,
// this brings the view up to date without a constant polling loop.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
checkForRemoteChanges();
}
});
window.addEventListener('focus', function () {
checkForRemoteChanges();
});
document.addEventListener('DOMContentLoaded', function () {
fetchRevision();
});
window.TT = window.TT || {};
window.TT.refreshPage = refreshPage;
+155 -7
View File
@@ -28,8 +28,29 @@ absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002). migration and can ship before Alembic does (DB-002).
""" """
import io
import logging
import os 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 (
get_or_create_folder as get_or_create_drive_folder,
)
from app.google_drive import (
root_folder_id as google_drive_root_folder_id,
)
from app.google_drive import (
upload_file as upload_google_drive_file,
)
#: Environment override for the document root. What a release-directory #: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not #: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current. #: inside whichever one is current.
@@ -45,6 +66,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root. #: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés' 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(): def project_root():
"""Absolute path of the project, derived from this file's location. """Absolute path of the project, derived from this file's location.
@@ -91,6 +122,123 @@ def _rooted(env_name, default_name):
return os.path.join(project_root(), 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 _save_locally(file_storage, relative_path):
"""Persist *relative_path* (from ``documents_root()``) and return it."""
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file_storage.save(absolute_path)
return relative_path
def _drive_folder_for(parts):
"""Resolve (and create as needed) a nested chain of Drive folders.
Args:
parts: Folder names under the root folder, root first.
Returns:
str: The Drive id of the deepest folder.
"""
parent = google_drive_root_folder_id()
for part in parts:
parent = get_or_create_drive_folder(part, parent)
return parent
def store_master_document(file_storage, filename, mimetype=None):
"""Store the unsigned template of a document (the canonical copy).
The point is to store the file exactly once: every assignment for that
document points at this one reference.
"""
if document_storage_backend() == 'local':
return _save_locally(file_storage, os.path.join(CONTRACTS_DIR, filename))
file_id = upload_google_drive_file(
stream=file_storage.stream,
filename=filename,
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
)
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
def store_signed_document(file_storage, *, team_name, role, username, filename, mimetype=None):
"""File a signed copy under ``signés/<team>/<role>/<username>/<filename>``.
The root folder is already the signed-contracts folder, so no extra
``signed`` segment is added (the layout the club requested).
Args:
file_storage: The uploaded FileStorage.
team_name: Name of the signing member's (primary) team.
role: The signing member's own role (player/coach/manager).
username: The signing member's username.
filename: Name of the signed file, e.g. ``player-v1.pdf``.
"""
if document_storage_backend() == 'local':
relative = os.path.join(CONTRACTS_DIR, team_name, role, username, filename)
return _save_locally(file_storage, relative)
parent = _drive_folder_for([team_name, role, username])
file_id = upload_google_drive_file(
stream=file_storage.stream,
filename=filename,
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
parents=[parent],
)
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): def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012). """Remove these documents from disk. Returns how many went (DATA-012).
@@ -107,27 +255,27 @@ def discard_documents(stored_paths):
A path that cannot be removed is logged and skipped. Nothing here should A path that cannot be removed is logged and skipped. Nothing here should
be able to abort the deletion of an account. be able to abort the deletion of an account.
""" """
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
removed = 0 removed = 0
for stored_path in stored_paths: for stored_path in stored_paths:
if not stored_path: if not stored_path:
continue continue
target = document_path(stored_path)
try: 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 removed += 1
except FileNotFoundError: except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous # Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line. # attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target) logger.info('Document already absent: %s', stored_path)
except OSError as exc: except (GoogleDriveStorageError, OSError) as exc:
logger.error( logger.error(
'Could not remove %s (%s). It is now an orphan: no database row ' '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 ' 'refers to it, so nothing in the application will ever offer to '
'delete it again.', 'delete it again.',
target, stored_path,
exc, exc,
) )
return removed return removed
+102 -20
View File
@@ -42,7 +42,11 @@ from urllib.parse import unquote, urlparse
# to be — see DOCUMENTS_DIR below. # to be — see DOCUMENTS_DIR below.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 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 # Configuration
BACKUP_DIR = backups_root() 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 # wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
# keep uploads outside the releases, and docs/deployment.md now tells the # 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 # 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 # application had never written to. A missing or unreadable document store
# either; it prints "No documents directory found", skips, and exits 0. # is now a failed full-backup run rather than a database-only green result.
# #
# So the more correctly an operator followed the deployment documentation, # So the more correctly an operator followed the deployment documentation,
# the more certainly their contract backups were empty (OBS-006). # the more certainly their contract backups were empty (OBS-006).
@@ -262,38 +266,105 @@ def backup_documents():
module happened to be imported with. module happened to be imported with.
Returns: Returns:
str: Path to the created archive, or None if there is nothing to str: Path to the created archive.
archive. Signed contracts live only on disk, so losing this
directory loses the documents themselves. 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() documents_dir = documents_root()
if not os.path.exists(documents_dir): if not os.path.exists(documents_dir):
# Says where it looked. The previous message named no path, so an raise BackupError(f'Documents directory does not exist: {documents_dir}')
# operator who had moved the documents read it as "there are no if not os.path.isdir(documents_dir):
# documents" rather than "I am looking in the wrong place". raise BackupError(f'Documents path is not a directory: {documents_dir}')
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}') archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
try: try:
shutil.make_archive(archive_basename, 'zip', documents_dir) shutil.make_archive(archive_basename, 'zip', documents_dir)
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
# This runs after the database dump has already succeeded. Letting raise BackupError(f'Document backup failed: {exc}') from exc
# 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
zip_path = f'{archive_basename}.zip' 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) size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)') print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path 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(): def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS.""" """Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR): if not os.path.exists(BACKUP_DIR):
@@ -361,15 +432,26 @@ def main(argv=None):
return 1 return 1
verified = verify_backup(backup_path) verified = verify_backup(backup_path)
documents_ok = True
try:
backup_documents() 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() cleanup_old_backups()
print() print()
if verified: if verified and documents_ok and drive_ok:
print('=== Backup completed successfully ===') print('=== Backup completed successfully ===')
return 0 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 return 1
+121
View File
@@ -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())
+33 -17
View File
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
- Debug mode status - Debug mode status
- HTTPS configuration - HTTPS configuration
- Dependency vulnerabilities - Dependency vulnerabilities
- Database connectivity - Required database configuration
Usage: Usage:
python security_scan.py [--url http://localhost:5000] python security_scan.py [--url http://localhost:5000]
@@ -19,6 +19,10 @@ import subprocess
import sys import sys
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
def check_environment(): def check_environment():
@@ -31,8 +35,8 @@ def check_environment():
print('1. ENVIRONMENT VARIABLES CHECK') print('1. ENVIRONMENT VARIABLES CHECK')
print('=' * 60) print('=' * 60)
critical_vars = ['SECRET_KEY'] critical_vars = ['SECRET_KEY', 'DATABASE_URL']
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] recommended_vars = ['CORS_ALLOWED_ORIGINS']
all_ok = True all_ok = True
for var in critical_vars: for var in critical_vars:
@@ -58,7 +62,8 @@ def check_environment():
# Check FLASK_DEBUG # Check FLASK_DEBUG
debug = os.getenv('FLASK_DEBUG', 'false').lower() debug = os.getenv('FLASK_DEBUG', 'false').lower()
if debug == 'true': 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: else:
print('[OK] FLASK_DEBUG is disabled') print('[OK] FLASK_DEBUG is disabled')
@@ -91,10 +96,11 @@ def check_https_headers(url):
all_ok = True all_ok = True
try: 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 = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method='HEAD') req = urllib.request.Request(url, method='HEAD')
@@ -148,9 +154,9 @@ def check_https_headers(url):
all_ok = False all_ok = False
except urllib.error.URLError as e: except urllib.error.URLError as e:
print(f'[SKIP] Cannot connect to {url}: {e.reason}') print(f'[FAIL] Cannot connect to {url}: {e.reason}')
print('[SKIP] Run with --url <application_url> to check headers') print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
return True # Not a failure, just can't check return False
return all_ok return all_ok
@@ -167,7 +173,15 @@ def check_dependencies():
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, '-m', 'pip_audit', '--format', 'json'], [
sys.executable,
'-m',
'pip_audit',
'--requirement',
str(REQUIREMENTS_FILE),
'--format',
'json',
],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60, timeout=60,
@@ -195,14 +209,16 @@ def check_dependencies():
if result.stdout: if result.stdout:
print(f'[INFO] {result.stdout.strip()}') print(f'[INFO] {result.stdout.strip()}')
if result.stderr: if result.stderr:
print(f'[WARN] {result.stderr.strip()}') print(f'[FAIL] {result.stderr.strip()}')
return True else:
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
return False
except FileNotFoundError: except FileNotFoundError:
print('[SKIP] pip-audit not installed. Run: pip install pip-audit') print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
return True return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
print('[WARN] pip-audit timed out') print('[FAIL] pip-audit timed out')
return True return False
def check_file_permissions(): def check_file_permissions():
+13 -1
View File
@@ -90,6 +90,12 @@
<span>{{ _('Manage Users') }}</span> <span>{{ _('Manage Users') }}</span>
</a> </a>
</li> </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 %} {% endif %}
{% if current_user.role == 'player' %} {% if current_user.role == 'player' %}
<li> <li>
@@ -107,6 +113,12 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li>
<a href="{{ url_for('users.list_my_documents') }}" class="{% if request.endpoint and 'document' in request.endpoint %}active{% endif %}">
<i class="fas fa-folder-open"></i>
<span>{{ _('Documents') }}</span>
</a>
</li>
<li> <li>
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
<i class="fas fa-file-contract"></i> <i class="fas fa-file-contract"></i>
@@ -167,7 +179,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
<div class="content"> <div class="content" id="page-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>
+1 -1
View File
@@ -27,7 +27,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action=" <form method="POST" data-ajax action="
{% if context_type == 'tryout' %} {% if context_type == 'tryout' %}
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }} {{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
{% elif context_type == 'match' %} {% elif context_type == 'match' %}
+294
View File
@@ -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,134 @@
{% extends "layouts/base.html" %}
{% block title %}{{ document.title }} - Admin{% endblock %}
{% block page_title %}{{ document.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / <a href="{{ url_for('admin.documents') }}">{{ _('Documents') }}</a> / {{ document.title }}</span>{% endblock %}
{% block content %}
{% set kind_labels = {
'player': _('Player contract'),
'coach': _('Coach contract'),
'manager': _('Manager contract'),
'team': _('Team agreement'),
'code_of_conduct': _('Code of conduct'),
'ethics_code': _('Ethics code'),
} %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-upload"></i> {{ _('Publish a new version') }}</h3>
</div>
<div class="card-body">
<p class="text-muted">
{{ _('Type') }}: <strong>{{ kind_labels.get(document.kind, document.kind) }}</strong>
{% if document.requires_signature %} · {{ _('signed by the member') }}
{% else %} · {{ _('acknowledged by the member') }}{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.publish_document_version', doc_id=document.id) }}" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="version_file">{{ _('New version file (PDF)') }}</label>
<input type="file" id="version_file" name="version_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div>
<div class="form-group">
<label for="notes">{{ _('Version notes (optional)') }}</label>
<textarea id="notes" name="notes" rows="2" placeholder="{{ _('What changed in this version?') }}"></textarea>
</div>
<div class="form-actions">
<a href="{{ url_for('admin.documents') }}" class="btn btn-secondary">{{ _('Back') }}</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload"></i> {{ _('Publish version') }}
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-history"></i> {{ _('Versions') }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Version') }}</th>
<th>{{ _('File') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Published') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for version in versions %}
<tr>
<td>
{{ _('v%(number)s', number=version.version) }}
{% if document.current_version_id == version.id %}
<span class="badge badge-success">{{ _('Current') }}</span>{% endif %}
</td>
<td>{{ version.original_filename }}</td>
<td>{{ version.notes or '—' }}</td>
<td>{{ version.published_at.strftime('%Y-%m-%d %H:%M') if version.published_at else '—' }}</td>
<td>
<a href="{{ url_for('admin.download_document_version', version_id=version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
<i class="fas fa-download"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-muted">{{ _('No versions published yet.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-list-check"></i> {{ _('Completion') }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Member') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Completed at') }}</th>
</tr>
</thead>
<tbody>
{% for assignment, user in assignments %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.role | capitalize }}</td>
<td>
{% if assignment.status == 'signed' %}
<span class="badge badge-success">{{ _('Signed') }}</span>
{% elif assignment.status == 'acknowledged' %}
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
{% else %}
<span class="badge badge-warning">{{ _('Pending') }}</span>
{% endif %}
</td>
<td>{{ assignment.completed_at.strftime('%Y-%m-%d %H:%M') if assignment.completed_at else '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-muted">
{{ _('No one is assigned yet. Publish a version first.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('Documents') }} - Admin{% endblock %}
{% block page_title %}{{ _('Document management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / {{ _('Documents') }}</span>{% endblock %}
{% block content %}
{% set kind_labels = {
'player': _('Player contract'),
'coach': _('Coach contract'),
'manager': _('Manager contract'),
'team': _('Team agreement'),
'code_of_conduct': _('Code of conduct'),
'ethics_code': _('Ethics code'),
} %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-plus-circle"></i> {{ _('Create a document') }}</h3>
</div>
<div class="card-body">
<form method="POST" data-ajax action="{{ url_for('admin.create_document') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="title">{{ _('Title') }}</label>
<input type="text" id="title" name="title" class="form-control" required
placeholder="{{ _('e.g. Player contract 2026-2027') }}">
</div>
<div class="form-group">
<label for="kind">{{ _('Type') }}</label>
<select id="kind" name="kind" class="form-select" required>
<option value="">{{ _('-- Select a type --') }}</option>
{% for kind in kinds %}
<option value="{{ kind }}">{{ kind_labels.get(kind, kind) }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
{{ _('The type decides who must complete the document and how.') }}
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i> {{ _('Create document') }}
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-file-contract"></i> {{ _('Existing documents') }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Title') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Version') }}</th>
<th>{{ _('Availability') }}</th>
<th>{{ _('Progress') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for document in documents %}
<tr>
<td><a href="{{ url_for('admin.document_detail', doc_id=document.id) }}">{{ document.title }}</a></td>
<td>{{ kind_labels.get(document.kind, document.kind) }}</td>
<td>
{% if document.current_version %}
{{ _('v%(number)s', number=document.current_version.version) }}
{% else %}{{ _('None') }}{% endif %}
</td>
<td>
{% if document.is_published %}
<span class="badge badge-success">{{ _('Downloadable') }}</span>
{% else %}
<span class="badge badge-secondary">{{ _('Hidden') }}</span>
{% endif %}
</td>
<td>
{% set completed, pending = document.completion_counts() %}
<span class="badge badge-info">{{ _('%(completed)s done', completed=completed) }}</span>
<span class="badge badge-warning">{{ _('%(pending)s pending', pending=pending) }}</span>
</td>
<td>
<div class="btn-group">
<a href="{{ url_for('admin.document_detail', doc_id=document.id) }}" class="btn btn-sm btn-primary" title="{{ _('Manage') }}">
<i class="fas fa-cog"></i> {{ _('Manage') }}
</a>
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_published', doc_id=document.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if document.is_published %}
<button type="submit" class="btn btn-sm btn-secondary" title="{{ _('Hide') }}">
<i class="fas fa-eye-slash"></i> {{ _('Hide') }}
</button>
{% else %}
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Make downloadable') }}">
<i class="fas fa-eye"></i> {{ _('Publish') }}
</button>
{% endif %}
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-muted">{{ _('No documents yet. Create one above.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+85
View File
@@ -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 %}
+55 -2
View File
@@ -137,12 +137,18 @@ document.addEventListener('DOMContentLoaded', function() {
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
center: 'title', center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth' right: ''
}, },
events: '/matches/api/events', events: '/matches/api/events',
eventClick: function(info) { eventClick: function(info) {
showEventModal(info.event); showEventModal(info.event);
}, },
eventClassNames: function(arg) {
return [eventClassName(arg.event)];
},
eventContent: function(arg) {
return renderEventContent(arg);
},
dateClick: function(info) { dateClick: function(info) {
if (canScheduleMatches) { if (canScheduleMatches) {
var dateStr = info.dateStr; var dateStr = info.dateStr;
@@ -158,7 +164,11 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}, },
slotMinTime: '12:00:00', slotMinTime: '12:00:00',
slotMaxTime: '24:00:00' slotMaxTime: '24:00:00',
slotEventOverlap: true,
dayMaxEvents: false,
nowIndicator: true,
height: 'auto'
}); });
calendar.render(); calendar.render();
@@ -177,6 +187,41 @@ function changeView(viewName) {
} }
} }
function eventClassName(event) {
var props = event.extendedProps || {};
if (props.type === 'team_match') { return 'fc-event--team-match'; }
if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; }
return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim';
}
function eventIcon(type, matchType) {
if (type === 'team_match') { return 'fa-trophy'; }
if (type === 'one_on_one') { return 'fa-user-friends'; }
if (matchType === 'team_vs_team') { return 'fa-shield-alt'; }
if (matchType === 'player_vs_player') { return 'fa-users'; }
return 'fa-gamepad';
}
function renderEventContent(arg) {
// Built with DOM nodes + textContent: the title comes from a JSON endpoint
// and has never been HTML-escaped, so it must not reach innerHTML.
var props = arg.event.extendedProps || {};
var wrap = makeEl('div', 'fc-event-body');
var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type));
icon.setAttribute('aria-hidden', 'true');
wrap.appendChild(icon);
var text = makeEl('span', 'fc-event-text');
if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) {
text.appendChild(makeEl('span', 'fc-event-time', arg.timeText));
}
text.appendChild(makeEl('span', 'fc-event-title', arg.event.title));
wrap.appendChild(text);
return { domNodes: [wrap] };
}
// --- Create Event Modal --- // --- Create Event Modal ---
function showCreateEventModal(dateStr) { function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr; document.getElementById('createEventDate').textContent = dateStr;
@@ -346,6 +391,14 @@ function showEventModal(event) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true)); grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
} }
// Team match details
if (type === 'team_match') {
grid.appendChild(detailItem('Team', document.createTextNode(props.org_team || 'Team')));
if (props.opponent) {
grid.appendChild(detailItem('Opponent', document.createTextNode(props.opponent)));
}
}
if (props.description) { if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true)); grid.appendChild(detailItem('Description', multilineNode(props.description), true));
} }
-241
View File
@@ -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">&times;</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 %}
+2 -2
View File
@@ -94,8 +94,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label for="signed_file">{{ _('Select Signed Contract File') }}</label> <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> <input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small> <small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button> <button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
+1 -1
View File
@@ -25,7 +25,7 @@
</div> </div>
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form"> <form method="POST" data-ajax action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
+121 -53
View File
@@ -5,7 +5,7 @@
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
{% block content %} {% block content %}
{% set sort_column = sort_column | default('created_at') %} {% set sort_column = sort_column | default('overall_score') %}
{% set sort_order = sort_order | default('desc') %} {% set sort_order = sort_order | default('desc') %}
{% macro sort_link(column, label) %} {% macro sort_link(column, label) %}
@@ -22,76 +22,111 @@
</th> </th>
{% endmacro %} {% endmacro %}
{% if current_user.role == 'admin' and player_scores %} <div class="view-toggle mb-3">
<div class="stats-grid mb-4"> <a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-primary">
{% for pid, data in player_scores.items() %} <i class="fas fa-table"></i> {{ _('Table') }}
<div class="stat-card stat-card-sm"> </a>
<div class="stat-icon bg-info"> {% if first_player_id %}
<i class="fas fa-user"></i> <a href="{{ url_for('evaluations.player_evaluations', player_id=first_player_id) }}" class="btn btn-sm btn-outline">
</div> <i class="fas fa-user"></i> {{ _('By player') }}
<div class="stat-info"> </a>
<h3>{{ data.player.username[:12] }}</h3>
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
</div>
</div>
{% endfor %}
</div>
{% endif %} {% endif %}
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-clipboard-list"></i> <h3><i class="fas fa-clipboard-list"></i> {{ _('Player Evaluations') }}</h3>
{% if current_user.role == 'player' %}
My Evaluations
{% else %}
All Evaluations
{% endif %}
</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
{{ sort_link('tryout', 'Tryout') }} <th></th>
{{ sort_link('player', 'Player') }} {{ sort_link('player', 'Player') }}
{{ sort_link('evaluator', 'Evaluator') }} {{ sort_link('overall_score', 'Average') }}
{{ sort_link('mecanics_score', 'Mecanics') }} {{ sort_link('count', 'Evaluations') }}
{{ sort_link('cohesion_score', 'Cohesion') }} <th>{{ _('Open') }}</th>
{{ sort_link('communication_score', 'Communication') }}
{{ sort_link('gamesense_score', 'Gamesense') }}
{{ sort_link('versatility_score', 'Versatility') }}
{{ sort_link('discipline_score', 'Discipline') }}
{{ sort_link('analysis_score', 'Analysis') }}
{{ sort_link('sport_ethics_score', 'Sport Ethics') }}
{{ sort_link('mental_score', 'Mental') }}
{{ sort_link('overall_score', 'Overall') }}
{{ sort_link('position_recommendation', 'Position') }}
{{ sort_link('created_at', 'Date') }}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for eval in evaluations %} {% for entry in players %}
<tr class="eval-row" data-action="toggle-player-evals" data-target="evals-{{ entry.player.id }}">
<td><i class="fas fa-chevron-right eval-chevron"></i></td>
<td>
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="player-link">
{{ entry.player.username }}
</a>
</td>
<td><span class="score">{{ entry.avg | round(1) if entry.avg is not none else '-' }}</span></td>
<td>{{ entry.count }}</td>
<td>
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
<i class="fas fa-external-link-alt"></i>
</a>
</td>
</tr>
<tr class="eval-detail hidden" id="evals-{{ entry.player.id }}">
<td colspan="5">
<div class="eval-detail-inner">
<table class="table table-sm">
<thead>
<tr> <tr>
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td> <th>{{ _('Evaluator') }}</th>
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td> <th>{{ _('Tryout') }}</th>
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td> {% for field, label in evaluation_criteria %}
<td>{{ eval.mecanics_score or '-' }}</td> <th>{{ label }}</th>
<td>{{ eval.cohesion_score or '-' }}</td> {% endfor %}
<td>{{ eval.communication_score or '-' }}</td> <th>{{ _('Overall') }}</th>
<td>{{ eval.gamesense_score or '-' }}</td> <th>{{ _('Position') }}</th>
<td>{{ eval.versatility_score or '-' }}</td> <th>{{ _('Date') }}</th>
<td>{{ eval.discipline_score or '-' }}</td> </tr>
<td>{{ eval.analysis_score or '-' }}</td> </thead>
<td>{{ eval.sport_ethics_score or '-' }}</td> <tbody>
<td>{{ eval.mental_score or '-' }}</td> {% for ev in entry.evaluations %}
<td><span class="score">{{ eval.overall_score or '-' }}</span></td> <tr>
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td> <td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td> <td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
{% for score in ev.scores %}
<td>{{ score if score is not none else '-' }}</td>
{% endfor %}
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
<td>{{ ev.position or '-' }}</td>
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="15" class="text-center"> <td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if entry.evaluations %}
{% set comment_evals = entry.evaluations | selectattr('comment') | list %}
<div class="eval-comments">
<h4 class="eval-comments-title"><i class="fas fa-comments"></i> {{ _('Comments') }}</h4>
{% if comment_evals %}
{% for ev in comment_evals %}
<article class="eval-comment">
<p class="eval-comment-text">{{ ev.comment }}</p>
<footer class="eval-comment-meta">
<span class="eval-comment-author">{{ _('Evaluator') }}&nbsp;: <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} &middot; {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
</footer>
</article>
{% endfor %}
{% else %}
<p class="eval-comments-empty">{{ _('No comments') }}</p>
{% endif %}
</div>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center">
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-clipboard"></i> <i class="fas fa-clipboard"></i>
<h3>{{ _('No evaluations yet') }}</h3> <h3>{{ _('No evaluations yet') }}</h3>
@@ -106,3 +141,36 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<style>
.view-toggle { display: flex; gap: 8px; }
.eval-row { cursor: pointer; }
.eval-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
.eval-detail-inner { padding: 8px 4px; }
.eval-detail-inner .table { margin-bottom: 0; }
.player-nav { display: flex; gap: 8px; align-items: center; }
.is-disabled { opacity: 0.5; pointer-events: none; }
</style>
<script nonce="{{ csp_nonce }}">
function togglePlayerEvals(element, event) {
if (event.target.closest('a')) {
return;
}
var detail = document.getElementById(element.getAttribute('data-target'));
if (!detail) {
return;
}
var chevron = element.querySelector('.eval-chevron');
var isHidden = detail.classList.contains('hidden');
if (isHidden) {
detail.classList.remove('hidden');
if (chevron) chevron.classList.add('fa-rotate-90');
} else {
detail.classList.add('hidden');
if (chevron) chevron.classList.remove('fa-rotate-90');
}
}
registerActions({ 'toggle-player-evals': togglePlayerEvals });
</script>
{% endblock %}
+21 -9
View File
@@ -22,7 +22,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm"> <form method="POST" data-ajax action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if is_practice %} {% if is_practice %}
<input type="hidden" name="title" value="Practice"> <input type="hidden" name="title" value="Practice">
@@ -450,11 +450,18 @@
var playerDataById = { var playerDataById = {
player_data: { player_data: {
{%- for p in all_players %} {%- for p in all_players %}
{{ p.id }}: "{{ p.username | escape }}", {{ p.id }}: {{ p.username | tojson }},
{%- endfor %} {%- endfor %}
} }
}; };
var HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (character) {
return HTML_ESCAPES[character];
});
}
// All registered player IDs // All registered player IDs
var allRegisteredPlayers = [ var allRegisteredPlayers = [
{%- for p in all_players %} {%- for p in all_players %}
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid]; var playerName = playerDataById.player_data[pid];
if (playerName) { if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">'; 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 += '<span class="remove-btn">↺</span>';
html += '</div>'; html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html); teamDiv.insertAdjacentHTML('beforeend', html);
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid]; var playerName = playerDataById.player_data[pid];
if (playerName) { if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">'; 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 += '<span class="remove-btn">↺</span>';
html += '</div>'; html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html); teamDiv.insertAdjacentHTML('beforeend', html);
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
} }
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' + 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 + slotData.display +
'<span class="merged-disponibility-count">' + count + '</span>' + '<span class="merged-disponibility-count">' + count + '</span>' +
'</div>'; '</div>';
@@ -920,7 +927,7 @@ function updatePlayerPool() {
var availabilityClass = isAvailable ? 'available' : 'unavailable'; var availabilityClass = isAvailable ? 'available' : 'unavailable';
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">'; 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 += '<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-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>'; 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; if (!playerName) return;
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">'; 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 += '<span class="remove-btn">↺</span>';
html += '</div>'; html += '</div>';
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid]; var playerName = playerDataById.player_data[pid];
if (playerName) { if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">'; 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 += '<span class="remove-btn">↺</span>';
html += '</div>'; html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html); teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid]; var playerName = playerDataById.player_data[pid];
if (playerName) { if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">'; 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 += '<span class="remove-btn">↺</span>';
html += '</div>'; html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html); teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) {
registerActions({ registerActions({
'toggle-match-type': toggleMatchType, 'toggle-match-type': toggleMatchType,
'clear-time-selection': clearTimeSelection, '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, 'update-randomize-preview': updateRandomizePreview,
'randomize-teams': randomizeTeams, 'randomize-teams': randomizeTeams,
'return-to-pool': returnToPool, 'return-to-pool': returnToPool,
+173
View File
@@ -0,0 +1,173 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('My Documents') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('My Documents') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / {{ _('My Documents') }}</span>{% endblock %}
{% block content %}
{% set kind_labels = {
'player': _('Player contract'),
'coach': _('Coach contract'),
'manager': _('Manager contract'),
'team': _('Team agreement'),
'code_of_conduct': _('Code of conduct'),
'ethics_code': _('Ethics code'),
} %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-contract"></i> {{ _('Documents to sign or acknowledge') }}</h3>
</div>
<div class="card-body">
{% if documents %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Document') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Version') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for entry in documents %}
<tr>
<td>{{ entry.document.title }}</td>
<td>{{ kind_labels.get(entry.document.kind, entry.document.kind) }}</td>
<td>
{% if entry.document.current_version %}
{{ _('Version %(number)s', number=entry.document.current_version.version) }}
{% else %}—{% endif %}
</td>
<td>
{% set assignment = entry.assignment %}
{% if assignment is not none and assignment.status == 'signed' %}
<span class="badge badge-success">{{ _('Signed') }}</span>
{% elif assignment is not none and assignment.status == 'acknowledged' %}
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
{% else %}
<span class="badge badge-warning">{{ _('Pending') }}</span>
{% endif %}
</td>
<td>
<div class="btn-group">
{% if entry.document.current_version %}
<a href="{{ url_for('users.download_document', version_id=entry.document.current_version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
<i class="fas fa-download"></i> {{ _('Download') }}
</a>
{% endif %}
{% if assignment is not none and assignment.status == 'pending' %}
{% if entry.document.requires_signature %}
<button class="btn btn-sm btn-warning" data-action="show-sign-document" data-assignment-id="{{ assignment.id }}" title="{{ _('Return signed') }}">
<i class="fas fa-upload"></i> {{ _('Sign') }}
</button>
{% else %}
<form method="POST" data-ajax action="{{ url_for('users.acknowledge_document', assignment_id=assignment.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" data-confirm="{{ _('Confirm that you have read and understood this document') }}">
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
</button>
</form>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif not teams_progress %}
<div class="empty-state">
<i class="fas fa-file-contract"></i>
<h4>{{ _('No documents for now') }}</h4>
<p>{{ _('Nothing has been published for you yet. Check back later.') }}</p>
</div>
{% endif %}
</div>
</div>
{% if teams_progress %}
{% for block in teams_progress %}
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ _('Team progress') }} — {{ block.team.name }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Member') }}</th>
<th>{{ _('Role') }}</th>
{% for doc in block.documents %}<th>{{ doc.title }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for member in block.members %}
<tr>
<td>{{ member.user.username }}</td>
<td>{{ member.user.role | capitalize }}</td>
{% for doc in block.documents %}
{% set status = member.statuses.get(doc.id) %}
<td>
{% if status == 'signed' %}<span class="badge badge-success">{{ _('Signed') }}</span>
{% elif status == 'acknowledged' %}<span class="badge badge-success">{{ _('Acknowledged') }}</span>
{% elif status == 'pending' %}<span class="badge badge-warning">{{ _('Pending') }}</span>
{% else %}<span class="text-muted">{{ _('Not required') }}</span>{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
{% endif %}
<!-- Upload signed document modal -->
<div id="signDocumentModal" class="modal hidden">
<div class="modal-backdrop" data-action="hide-sign-document"></div>
<div class="modal-content">
<div class="modal-header">
<h3>{{ _('Upload Signed Document') }}</h3>
<button class="modal-close" data-action="hide-sign-document">&times;</button>
</div>
<div class="modal-body">
<form id="signDocumentForm" method="POST" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="signed_file">{{ _('Select Signed Document File') }}</label>
<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-sign-document">{{ _('Cancel') }}</button>
<button type="submit" class="btn btn-primary">{{ _('Upload Signed Document') }}</button>
</div>
</form>
</div>
</div>
</div>
<script nonce="{{ csp_nonce }}">
function showSignDocumentForm(assignmentId) {
document.getElementById('signDocumentForm').action = '/users/documents/assignments/' + assignmentId + '/sign';
document.getElementById('signDocumentModal').classList.remove('hidden');
}
function hideSignDocumentForm() {
document.getElementById('signDocumentModal').classList.add('hidden');
}
registerActions({
'show-sign-document': function (element) {
showSignDocumentForm(element.getAttribute('data-assignment-id'));
},
'hide-sign-document': hideSignDocumentForm,
});
</script>
{% endblock %}
+1 -1
View File
@@ -192,7 +192,7 @@ function togglePresence(btn) {
btn.classList.add('btn-outline'); btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm'; btn.innerHTML = 'Confirm';
} }
location.reload(); window.TT.refreshPage(window.location.pathname);
}) })
.catch(function(error) { .catch(function(error) {
console.error('Error:', error); console.error('Error:', error);
+4 -4
View File
@@ -13,7 +13,7 @@
<span class="badge badge-esport">{{ org_team.name }}</span> <span class="badge badge-esport">{{ org_team.name }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm"> <form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
@@ -41,7 +41,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm"> <form method="POST" data-ajax action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
@@ -145,7 +145,7 @@
</td> </td>
<td> <td>
{% if req.status == 'pending' %} {% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;"> <form method="POST" data-ajax action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}"> <button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}">
<i class="fas fa-check"></i> {{ _('Accept') }} <i class="fas fa-check"></i> {{ _('Accept') }}
@@ -177,7 +177,7 @@
<h4><i class="fas fa-times-circle"></i> {{ _('Reject One on One Request') }}</h4> <h4><i class="fas fa-times-circle"></i> {{ _('Reject One on One Request') }}</h4>
<button type="button" class="modal-close" data-action="hide-reject-modal">&times;</button> <button type="button" class="modal-close" data-action="hide-reject-modal">&times;</button>
</div> </div>
<form id="rejectForm" method="POST" action=""> <form id="rejectForm" method="POST" data-ajax action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
+1 -1
View File
@@ -124,7 +124,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if coach %} {% if coach %}
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm"> <form method="POST" data-ajax action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.manage_personal_notes') }}" class="form"> <form method="POST" data-ajax action="{{ url_for('users.manage_personal_notes') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
+134
View File
@@ -0,0 +1,134 @@
{% extends "layouts/base.html" %}
{% block title %}{{ player.username }} - {{ _('Evaluations') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Player Evaluations') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('evaluations.list_evaluations') }}">{{ _('Evaluations') }}</a> / {{ player.username }}</span>{% endblock %}
{% block content %}
<div class="view-toggle mb-3">
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-outline">
<i class="fas fa-table"></i> {{ _('Table') }}
</a>
<a href="{{ url_for('evaluations.player_evaluations', player_id=player.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-user"></i> {{ _('By player') }}
</a>
</div>
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-user"></i> {{ player.username }}</h3>
<div class="player-nav">
{% if prev_player %}
<a href="{{ url_for('evaluations.player_evaluations', player_id=prev_player.id) }}" class="btn btn-sm btn-secondary" title="{{ prev_player.username }}">
<i class="fas fa-chevron-left"></i> {{ prev_player.username }}
</a>
{% else %}
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-left"></i></span>
{% endif %}
{% if next_player %}
<a href="{{ url_for('evaluations.player_evaluations', player_id=next_player.id) }}" class="btn btn-sm btn-secondary" title="{{ next_player.username }}">
{{ next_player.username }} <i class="fas fa-chevron-right"></i>
</a>
{% else %}
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-right"></i></span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="stats-grid mb-4">
<div class="stat-card">
<div class="stat-icon bg-info"><i class="fas fa-star"></i></div>
<div class="stat-info">
<h3>{{ combined_average | round(1) if combined_average is not none else '-' }}</h3>
<p>{{ _('Combined average') }} &middot; {{ evaluation_count }} {{ _('evaluations') }}</p>
</div>
</div>
{% for label, avg in criterion_averages %}
<div class="stat-card stat-card-sm">
<div class="stat-icon bg-info"><i class="fas fa-chart-bar"></i></div>
<div class="stat-info">
<h3>{{ avg | round(1) if avg is not none else '-' }}</h3>
<p>{{ label }}</p>
</div>
</div>
{% endfor %}
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Evaluator') }}</th>
<th>{{ _('Tryout') }}</th>
{% for field, label in evaluation_criteria %}
<th>{{ label }}</th>
{% endfor %}
<th>{{ _('Overall') }}</th>
<th>{{ _('Position') }}</th>
<th>{{ _('Date') }}</th>
</tr>
</thead>
<tbody>
{% for ev in evaluations %}
<tr>
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
{% for score in ev.scores %}
<td>{{ score if score is not none else '-' }}</td>
{% endfor %}
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
<td>{{ ev.position or '-' }}</td>
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">
<div class="empty-state">
<i class="fas fa-clipboard"></i>
<h3>{{ _('No evaluations for this player yet') }}</h3>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if evaluations %}
{% set comment_evals = evaluations | selectattr('comment') | list %}
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3>
</div>
<div class="card-body">
{% if comment_evals %}
<div class="eval-comments">
{% for ev in comment_evals %}
<article class="eval-comment">
<p class="eval-comment-text">{{ ev.comment }}</p>
<footer class="eval-comment-meta">
<span class="eval-comment-author">{{ _('Evaluator') }}&nbsp;: <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} &middot; {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
</footer>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-comments"></i>
<h3>{{ _('No comments') }}</h3>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
.view-toggle { display: flex; gap: 8px; }
.player-nav { display: flex; gap: 8px; align-items: center; }
.is-disabled { opacity: 0.5; pointer-events: none; }
</style>
{% endblock %}
+39 -9
View File
@@ -6,23 +6,29 @@
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <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>
<div class="card-body"> <div class="card-body">
<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"> <div class="table-container">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>{{ _('Player') }}</th> <th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
<th>{{ _('Contact') }}</th> <th>Player</th>
<th>{{ _('Attendance') }}</th> <th>Contact</th>
<th>{{ _('Status') }}</th> <th>Attendance</th>
<th>{{ _('Actions') }}</th> <th>Status</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for entry in players %} {% for entry in players %}
<tr> <tr>
<td>
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
</td>
<td> <td>
<div class="user-mini"> <div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div> <div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
@@ -41,19 +47,43 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary"> <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 %}Evaluate{% endif %} <i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
</a> </a>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td> <td colspan="6" class="text-center">No players registered for this tryout.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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>
</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 %} {% endblock %}
+7 -1
View File
@@ -393,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
loadCoachAvailability(); loadCoachAvailability();
renderCoachGrid(); 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> </script>
{% endif %} {% endif %}
@@ -608,7 +615,6 @@ document.addEventListener('DOMContentLoaded', function() {
// onclick attributes, which no CSP nonce is able to authorise. // onclick attributes, which no CSP nonce is able to authorise.
registerActions({ registerActions({
'clear-disponibilities': clearDisponibilities, 'clear-disponibilities': clearDisponibilities,
'clear-availability': clearAllAvailability,
}); });
</script> </script>
{% endif %} {% endif %}
+1 -1
View File
@@ -9,7 +9,7 @@
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3> <h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form"> <form method="POST" data-ajax action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
+3 -3
View File
@@ -98,7 +98,7 @@
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}"> <a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}"> <form method="POST" data-ajax action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}"> <button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
@@ -170,8 +170,8 @@ function togglePresence(btn) {
btn.classList.add('btn-outline'); btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm'; btn.innerHTML = 'Confirm';
} }
// Reload to update presence bar // Update in place to preserve state
location.reload(); window.TT.refreshPage(window.location.pathname);
}) })
.catch(function(error) { .catch(function(error) {
console.error('Error:', error); console.error('Error:', error);
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form"> <form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
+10 -7
View File
@@ -18,7 +18,7 @@
<h3>{{ _('Create New Team') }}</h3> <h3>{{ _('Create New Team') }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('teams.create_team') }}" class="form"> <form method="POST" data-ajax action="{{ url_for('teams.create_team') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
<div class="form-group col-4"> <div class="form-group col-4">
@@ -63,7 +63,7 @@
<button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}"> <button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}">
<i class="fas fa-edit"></i> {{ _('Edit') }} <i class="fas fa-edit"></i> {{ _('Edit') }}
</button> </button>
<form method="POST" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger"> <button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i> {{ _('Delete') }} <i class="fas fa-trash"></i> {{ _('Delete') }}
@@ -89,7 +89,7 @@
<span class="staff-tag"> <span class="staff-tag">
{{ c.username }} {{ c.username }}
{% if can_manage %} {% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="coach_id" value="{{ c.id }}"/> <input type="hidden" name="coach_id" value="{{ c.id }}"/>
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">&times;</button> <button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">&times;</button>
@@ -111,7 +111,7 @@
<span class="staff-tag manager-tag"> <span class="staff-tag manager-tag">
{{ m.username }} {{ m.username }}
{% if can_manage %} {% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="manager_id" value="{{ m.id }}"/> <input type="hidden" name="manager_id" value="{{ m.id }}"/>
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">&times;</button> <button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">&times;</button>
@@ -180,7 +180,7 @@
<td>{{ entry.player.phone or '-' }}</td> <td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %} {% if can_manage_team %}
<td> <td>
<form method="POST" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger"> <button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-user-minus"></i> {{ _('Remove') }} <i class="fas fa-user-minus"></i> {{ _('Remove') }}
@@ -210,7 +210,7 @@
<hr class="my-3"> <hr class="my-3">
<div class="add-player-section"> <div class="add-player-section">
<h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5> <h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5>
<form method="POST" action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline"> <form method="POST" data-ajax action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required> <select name="player_id" class="form-select" required>
<option value="">{{ _('-- Select a player --') }}</option> <option value="">{{ _('-- Select a player --') }}</option>
@@ -258,7 +258,7 @@
<button class="modal-close" data-action="hide-edit-form">&times;</button> <button class="modal-close" data-action="hide-edit-form">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="editTeamForm" method="POST" action="" class="form"> <form id="editTeamForm" method="POST" data-ajax action="" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="sync_staff" value="1"/> <input type="hidden" name="sync_staff" value="1"/>
@@ -300,6 +300,9 @@
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}"> <script nonce="{{ csp_nonce }}">
function showCreateForm() { function showCreateForm() {
document.getElementById('createTeamForm').classList.remove('hidden'); document.getElementById('createTeamForm').classList.remove('hidden');
+14 -7
View File
@@ -67,15 +67,22 @@
</select> </select>
</div> </div>
<div class="form-group col-6"> <div class="form-group col-6">
<label for="manager_id">{{ _('Assigned Manager') }}</label> <label>{{ _('Assigned Managers') }}</label>
<select id="manager_id" name="manager_id" class="form-select"> <div class="checkbox-grid">
<option value="">{{ _('-- No manager assigned --') }}</option>
{% for manager in managers %} {% for manager in managers %}
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}> {% set is_checked = false %}
{{ manager.username }} {% if tryout %}
</option> {% for m in tryout.get_managers() %}
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
{% endfor %} {% endfor %}
</select> {% 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 %}
</div>
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
+2 -2
View File
@@ -24,8 +24,8 @@
<div class="form-group"> <div class="form-group">
<label for="contract_file">{{ _('Contract File') }}</label> <label for="contract_file">{{ _('Contract File') }}</label>
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required> <input type="file" id="contract_file" name="contract_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small> <small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
+8
View File
@@ -21,6 +21,7 @@
<th>{{ _('Username') }}</th> <th>{{ _('Username') }}</th>
<th>{{ _('Email') }}</th> <th>{{ _('Email') }}</th>
<th>{{ _('Role') }}</th> <th>{{ _('Role') }}</th>
<th>{{ _('Hours') }}</th>
<th>{{ _('Status') }}</th> <th>{{ _('Status') }}</th>
<th>{{ _('Joined') }}</th> <th>{{ _('Joined') }}</th>
<th>{{ _('Actions') }}</th> <th>{{ _('Actions') }}</th>
@@ -38,6 +39,13 @@
<td>{{ u.username }}</td> <td>{{ u.username }}</td>
<td>{{ u.email }}</td> <td>{{ u.email }}</td>
<td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></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> <td>
{% if u.is_active_account %} {% if u.is_active_account %}
<span class="badge badge-success">Active</span> <span class="badge badge-success">Active</span>
+173 -22
View File
@@ -15,7 +15,7 @@
</a> </a>
{% endif %} {% endif %}
{% if can_edit %} {% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form"> <form method="POST" data-ajax action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" data-submit-on-change class="form-select"> <select name="status" data-submit-on-change class="form-select">
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option> <option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option>
@@ -70,16 +70,22 @@
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span> <span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Manager</span> <span class="detail-label">Manager(s)</span>
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span> <span class="detail-value">
{% set mgrs = tryout.get_managers() %}
{% if mgrs %}
{{ mgrs | map(attribute='username') | join(', ') }}
{% else %}
Not assigned
{% endif %}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Coaches</span> <span class="detail-label">Coaches</span>
<span class="detail-value"> <span class="detail-value">
{% if tryout.coaches %} {% set cos = tryout.get_coaches() %}
{{ tryout.coaches | map(attribute='username') | join(', ') }} {% if cos %}
{% elif tryout.coach %} {{ cos | map(attribute='username') | join(', ') }}
{{ tryout.coach.username }}
{% else %} {% else %}
Not assigned Not assigned
{% endif %} {% endif %}
@@ -102,7 +108,7 @@
{% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %} {% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body text-center"> <div class="card-body text-center">
<form method="POST" action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}"> <form method="POST" data-ajax action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }} <i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }}
@@ -118,7 +124,7 @@
<h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3> <h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline"> <form method="POST" data-ajax action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required> <select name="player_id" class="form-select" required>
<option value="">{{ _('-- Select a player --') }}</option> <option value="">{{ _('-- Select a player --') }}</option>
@@ -167,7 +173,7 @@
</td> </td>
<td> <td>
{% if can_edit %} {% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form"> <form method="POST" data-ajax action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" data-submit-on-change class="form-select form-select-sm"> <select name="status" data-submit-on-change class="form-select form-select-sm">
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option> <option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option>
@@ -199,7 +205,7 @@
</a> </a>
{% endif %} {% endif %}
{% if not tryout.is_ended %} {% if not tryout.is_ended %}
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}"> <form method="POST" data-ajax action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}"> <button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}">
<i class="fas fa-user-minus"></i> {{ _('Remove') }} <i class="fas fa-user-minus"></i> {{ _('Remove') }}
@@ -232,7 +238,7 @@
<div class="card-body"> <div class="card-body">
{% if can_edit and not tryout.is_ended %} {% if can_edit and not tryout.is_ended %}
<div id="createTeamForm" class="hidden mb-3"> <div id="createTeamForm" class="hidden mb-3">
<form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline"> <form method="POST" data-ajax action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2"> <input type="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2">
<button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button> <button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button>
@@ -243,7 +249,14 @@
{% for team in team_data %} {% for team in team_data %}
<div class="team-card mb-3"> <div class="team-card mb-3">
<div class="team-card-header">
<h4 class="team-name">{{ team.team.name }}</h4> <h4 class="team-name">{{ team.team.name }}</h4>
{% if can_edit and not tryout.is_ended %}
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</button>
{% endif %}
</div>
<ul class="team-members"> <ul class="team-members">
{% for member in team.members %} {% for member in team.members %}
<li> <li>
@@ -257,11 +270,63 @@
<li class="text-muted">{{ _('No players assigned yet.') }}</li> <li class="text-muted">{{ _('No players assigned yet.') }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if can_edit and not tryout.is_ended and registered_players %} </div>
{% if can_edit and not tryout.is_ended %}
{% set positions = game_positions.get(tryout.game, []) %} {% 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"> <!-- Edit Team Modal -->
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">&times;</button>
</div>
<div class="modal-body">
<!-- Rename -->
<form method="POST" data-ajax action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required> <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> <option value="">{{ _('Select player...') }}</option>
{% for p in registered_players %} {% for p in registered_players %}
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %} {% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
@@ -277,12 +342,24 @@
{% endfor %} {% endfor %}
</select> </select>
{% else %} {% else %}
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2"> <input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
{% endif %} {% endif %}
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button> <button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
</form> </form>
{% endif %} {% 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>
</div>
{% endif %}
{% else %} {% else %}
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p> <p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
{% endfor %} {% endfor %}
@@ -452,6 +529,12 @@
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline"> <a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-edit"></i> {{ _('Edit') }} <i class="fas fa-edit"></i> {{ _('Edit') }}
</a> </a>
<form method="POST" data-ajax action="{{ url_for('matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i> {{ _('Delete') }}
</button>
</form>
{% endif %} {% endif %}
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}"> <a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
<i class="fas fa-sticky-note"></i> {{ _('Note') }} <i class="fas fa-sticky-note"></i> {{ _('Note') }}
@@ -534,6 +617,9 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %}
{% block scripts %}
<style> <style>
.presence-toggle-btn { .presence-toggle-btn {
padding: 2px 7px; padding: 2px 7px;
@@ -559,8 +645,56 @@
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 1px 3px rgba(0,0,0,0.12); box-shadow: 0 1px 3px rgba(0,0,0,0.12);
} }
/* Team card header — edit button aligned right */
.team-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.team-card-header .team-name {
margin-bottom: 0;
}
/* Roster rows inside modal */
.roster-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.roster-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--gray-50);
border-radius: var(--radius-sm);
}
.roster-player-name {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
}
.roster-role-select {
width: auto;
font-size: 0.75rem;
padding: 1px 4px;
}
.roster-remove-btn {
padding: 1px 5px;
font-size: 0.7rem;
}
[data-theme="dark"] .roster-row {
background: var(--bg-tertiary);
}
</style> </style>
<script nonce="{{ csp_nonce }}"> <script nonce="{{ csp_nonce }}">
function showModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); } function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); } function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
@@ -583,18 +717,27 @@ function toggleTryoutPresence(matchId, participantId, btn) {
btn.classList.add('presence-pending-btn'); btn.classList.add('presence-pending-btn');
btn.innerHTML = btn.innerHTML.replace('✅', '⏳'); btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
} }
location.reload(); window.TT.refreshPage(window.location.pathname);
}) })
.catch(function(err) { .catch(function(err) {
console.error('Error toggling tryout presence:', err); console.error('Error toggling tryout presence:', err);
}); });
} }
// Mini calendar for matches // Mini calendar for matches. Re-initialised on fragment:updated so the
document.addEventListener('DOMContentLoaded', function() { // calendar survives AJAX edits that swap the page content in place.
var miniCalendar = null;
function initMiniCalendar() {
var miniCalendarEl = document.getElementById('mini-calendar'); var miniCalendarEl = document.getElementById('mini-calendar');
if (miniCalendarEl) { if (!miniCalendarEl) {
var miniCalendar = new FullCalendar.Calendar(miniCalendarEl, { return;
}
if (miniCalendar) {
miniCalendar.destroy();
miniCalendar = null;
}
miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
headerToolbar: { headerToolbar: {
left: 'prev,next', left: 'prev,next',
@@ -611,13 +754,21 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
miniCalendar.render(); miniCalendar.render();
} }
});
document.addEventListener('DOMContentLoaded', initMiniCalendar);
document.addEventListener('fragment:updated', initMiniCalendar);
// Behaviours declared in the markup, dispatched by the delegated listener // Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce. // in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({ registerActions({
'show-create-team': showCreateTeam, 'show-create-team': showCreateTeam,
'hide-create-team': hideCreateTeam, 'hide-create-team': hideCreateTeam,
'show-modal': function (element) {
showModal(element.getAttribute('data-modal-id'));
},
'hide-modal': function (element) {
hideModal(element.getAttribute('data-modal-id'));
},
'toggle-tryout-presence': function (element) { 'toggle-tryout-presence': function (element) {
toggleTryoutPresence( toggleTryoutPresence(
element.getAttribute('data-match-id'), element.getAttribute('data-match-id'),
+25
View File
@@ -81,6 +81,31 @@
</table> </table>
</div> </div>
{% endif %} {% 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>
</div> </div>
{% endblock %} {% endblock %}
+19
View File
@@ -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)
+36
View File
@@ -58,3 +58,39 @@ def day_name(day_of_week):
if 0 <= day_of_week < len(DAY_NAMES): if 0 <= day_of_week < len(DAY_NAMES):
return DAY_NAMES[day_of_week] return DAY_NAMES[day_of_week]
return f'Day {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
+173 -1
View File
@@ -23,7 +23,7 @@ from marshmallow import (
validates_schema, validates_schema,
) )
from app.models import ESPORT_GAMES, USER_TYPES from app.models import DOCUMENT_KINDS, ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
# ============================================================================= # =============================================================================
# Custom Validators # Custom Validators
@@ -276,6 +276,36 @@ class RegisterSchema(StripMixin):
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password') 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): class CreateUserSchema(StripMixin):
"""Validate president-created user form input. """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): class OneOnOneRequestSchema(StripMixin):
"""A player asking their coach for a session (MNT-12). """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) target_org_team_id = fields.Integer(allow_none=True, load_default=None)
manager_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) coach_ids = fields.List(fields.Integer(), load_default=list)
manager_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema @validates_schema
def validate_span(self, data, **kwargs): def validate_span(self, data, **kwargs):
@@ -909,3 +1061,23 @@ class EvaluationSchema(StripMixin):
allow_none=True, allow_none=True,
load_default=None, load_default=None,
) )
class DocumentCreateSchema(StripMixin):
"""Validate the admin form that creates a new document.
Fields:
title: Human-readable name of the document (e.g. ``Contrat joueur``).
kind: One of DOCUMENT_KINDS. The kind decides who completes the
document and whether completion is a signature or an
acknowledgment.
"""
title = fields.String(
required=True,
validate=validate.Length(min=1, max=255, error=_l('Title is required.')),
)
kind = fields.String(
required=True,
validate=validate.OneOf(DOCUMENT_KINDS, error=_l('That document type is not recognised.')),
)
-66
View File
@@ -1,66 +0,0 @@
"""Database seeding script for Team Tryouts application.
Wipes all data and creates an admin user. Run manually with:
python -m app.supporting_scripts.seed
"""
from app.extensions import db, hash_password
from app.models import Admin
def seed_database():
"""Delete all existing data and create a single admin account."""
# Delete all data in FK-safe order
print("Deleting existing data...")
tables = [
'one_on_one_requests',
'player_disponibilities',
'coach_availabilities',
'match_participants',
'team_match_participants',
'matches',
'team_matches',
'team_players',
'team_members',
'teams',
'evaluations',
'tryout_registrations',
'tryouts',
'org_team_coaches',
'org_team_managers',
'team_notes',
'personal_notes',
'user_gamertags',
'org_teams',
'users',
]
for table in tables:
db.session.execute(db.text(f'DELETE FROM {table}'))
db.session.commit()
print("[OK] All data deleted.")
# Create the admin user
admin = Admin(
username='admin',
password_hash=hash_password('password'),
role='admin',
full_name='Admin',
email='[email protected]',
)
db.session.add(admin)
db.session.commit()
print("[OK] Created admin user")
print("\n=== Admin Credentials ===")
print("Username: admin")
print("Password: password")
print("\n[SUCCESS] Database reset and seeded!")
if __name__ == '__main__':
from app.app import create_app
app = create_app()
with app.app_context():
seed_database()
+1
View File
@@ -15,6 +15,7 @@ incident.
|---|---|---| |---|---|---|
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record | | `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_<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 Losing either one alone loses data. A database restore without the document
archive leaves contract rows pointing at files that no longer exist. archive leaves contract rows pointing at files that no longer exist.
+1 -1
View File
@@ -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-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-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-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-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 | | `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
+27 -5
View File
@@ -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 refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
than one that starts and is wide open (OPS-003). 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) ### Binding and proxy trust — read this before going live (OPS-002)
Two variables decide whether the rate limiter, the account lockout and the 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 `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 backup script kept its own copy of the document path — so it archived
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2 `./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
was therefore enough, on its own, to make every contract backup empty; the was therefore enough, on its own, to make every contract backup empty. All
script prints `No documents directory…` and still exits 0, so a scheduled three roots now come from `app/storage.py`, and the backup run prints the
task watching the exit code would have seen green indefinitely. All three document source it used. A missing or unarchivable document store makes the
roots now come from `app/storage.py`, and the backup run prints the document run exit non-zero even when the database dump itself is valid, so a scheduler
source it used. cannot report a database-only recovery point as a complete backup.
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand** **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 and check the `Document source:` line and the size of the resulting

Some files were not shown because too many files have changed in this diff Show More