15 Commits
Author SHA1 Message Date
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
92 changed files with 6355 additions and 1212 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:
push:
branches: [main, master]
branches: [main, master, 'audit/**']
pull_request:
branches: [main, master]
workflow_dispatch: # Allow manual triggers
@@ -94,6 +94,7 @@ jobs:
- name: Run security scan
env:
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http
+20 -9
View File
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# =============================================================================
# Optional — storage
# Contract storage — Google Drive
# =============================================================================
# Where uploaded contracts live. Empty means `documents/` beside the
# application. Set it to a path OUTSIDE the deployment directory if you move
# to a release-directory layout, or a deployment will take the documents with
# it (OPS-011, app/storage.py).
#
# IMPORTANT: the backup script reads this same variable. Before wave J it
# did not, and archived `./documents` regardless — so setting this here and
# nowhere else produced empty contract backups that still exited 0.
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
# refresh token, and folder ID are secrets/configuration: do not commit them.
# Use `local` only for isolated development and legacy-file maintenance.
DOCUMENT_STORAGE_BACKEND=google_drive
# Target folder in the owner's personal Drive. The application keeps every
# contract directly in this folder and stores only each Drive file ID in the
# database.
GOOGLE_DRIVE_FOLDER_ID=
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
# refresh token here. See docs/deployment.md before enabling this in production.
GOOGLE_DRIVE_CLIENT_ID=
GOOGLE_DRIVE_CLIENT_SECRET=
GOOGLE_DRIVE_REFRESH_TOKEN=
# Local storage is retained only for historical rows and local test runs.
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both
+2
View File
@@ -411,6 +411,7 @@ def create_app(config=None):
from app.routes.teams import teams_bp
from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp
from app.routes.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -420,6 +421,7 @@ def create_app(config=None):
app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
+13 -8
View File
@@ -65,6 +65,8 @@ from discord.ext import commands
from dotenv import load_dotenv
from sqlalchemy.exc import SQLAlchemyError
from app.time_utils import utc_now_naive
load_dotenv()
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
@@ -683,9 +685,10 @@ class TeamTryoutsBot(commands.Bot):
reference_id: ID of the MatchParticipant or TryoutRegistration record.
"""
# Look up the DB user to get their Discord user ID
from app.extensions import db
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:
logger.warning(f"DB user {user_id} not found for schedule notification")
return None
@@ -754,7 +757,7 @@ class TeamTryoutsBot(commands.Bot):
from app.models import OneOnOneRequest
try:
request = OneOnOneRequest.query.get(request_id)
request = db.session.get(OneOnOneRequest, request_id)
if not request:
# The row is gone; no reaction on this message can ever mean
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
@@ -779,7 +782,7 @@ class TeamTryoutsBot(commands.Bot):
coach_obj = request.coach
request.status = 'approved'
request.responded_at = datetime.utcnow()
request.responded_at = utc_now_naive()
except SQLAlchemyError:
db.session.rollback()
logger.exception('Could not read One on One request %s to approve it', request_id)
@@ -825,7 +828,7 @@ class TeamTryoutsBot(commands.Bot):
from app.models import OneOnOneRequest
try:
request = OneOnOneRequest.query.get(request_id)
request = db.session.get(OneOnOneRequest, request_id)
if not request:
logger.info(
'One on One request %s no longer exists; its pending message was dropped.',
@@ -874,7 +877,7 @@ class TeamTryoutsBot(commands.Bot):
try:
request.status = 'rejected'
request.responded_at = datetime.utcnow()
request.responded_at = utc_now_naive()
if refusal_note:
request.coach_rejection_message = refusal_note
except SQLAlchemyError:
@@ -918,12 +921,13 @@ class TeamTryoutsBot(commands.Bot):
Returns:
tuple: (row, player_id) — either may be None.
"""
from app.extensions import db
from app.models import MatchParticipant, TryoutRegistration
if event_type == 'match':
row = MatchParticipant.query.get(reference_id)
row = db.session.get(MatchParticipant, reference_id)
elif event_type == 'tryout':
row = TryoutRegistration.query.get(reference_id)
row = db.session.get(TryoutRegistration, reference_id)
else:
row = None
return row, getattr(row, 'player_id', None)
@@ -937,11 +941,12 @@ class TeamTryoutsBot(commands.Bot):
attendance handlers did not (OPS-009) — same message shape, same
threat, one of them checked. The asymmetry was the bug.
"""
from app.extensions import db
from app.models import User
if not player_id:
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))
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
+30
View File
@@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass
if not payload.get(name):
payload.pop(name, None)
return payload
def form_gamertags(selected_games):
"""Validate the dynamic gamertag fields for the selected games.
These fields cannot be declared statically on the account schemas: their
names contain the game label. They are still untrusted form data, so
every caller uses this shared boundary before adding or changing rows.
"""
from marshmallow import ValidationError
from app.models import GAME_PLATFORMS
from app.validators import GamertagSchema
validated = {}
for game in selected_games:
raw_gamertag = request.form.get(f'gamertag_{game}', '')
raw_platform = (
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
)
if not raw_gamertag.strip():
continue
try:
validated[game] = GamertagSchema().load(
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
)
except ValidationError as err:
messages = [message for values in err.messages.values() for message in values]
raise ValidationError({f'gamertag_{game}': messages}) from err
return validated
+105
View File
@@ -0,0 +1,105 @@
"""Google Drive storage for contract PDFs.
The application uses an OAuth refresh token for the owner's personal Google
account. Tokens and client secrets come only from environment variables; the
database stores opaque file IDs, never a credential or a shareable Drive URL.
"""
import io
import os
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
class GoogleDriveStorageError(RuntimeError):
"""A configuration or API failure while storing a contract in Drive."""
def _setting(name):
value = os.getenv(name)
if not value:
raise GoogleDriveStorageError(f'{name} must be configured for Google Drive document storage.')
return value
def _drive_service():
"""Build an authorized Drive client from the owner's refresh token."""
try:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
except ImportError as exc:
raise GoogleDriveStorageError(
'Google Drive dependencies are not installed. Install requirements.txt again.'
) from exc
credentials = Credentials(
token=None,
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
token_uri='https://oauth2.googleapis.com/token',
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
scopes=[GOOGLE_DRIVE_SCOPE],
)
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
def upload_file(*, stream, filename, mimetype):
"""Upload a PDF to the configured owner folder and return its Drive ID."""
try:
from googleapiclient.http import MediaIoBaseUpload
stream.seek(0)
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
response = (
_drive_service()
.files()
.create(
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
media_body=media,
fields='id',
)
.execute()
)
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
file_id = response.get('id')
if not file_id:
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
return file_id
def download_file(file_id):
"""Download a Drive file into memory for Flask's authenticated response."""
try:
from googleapiclient.http import MediaIoBaseDownload
destination = io.BytesIO()
downloader = MediaIoBaseDownload(
destination,
_drive_service().files().get_media(fileId=file_id),
)
complete = False
while not complete:
_status, complete = downloader.next_chunk()
return destination.getvalue()
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
def delete_file(file_id):
"""Permanently delete a Drive document when its contract record is deleted."""
try:
_drive_service().files().delete(fileId=file_id).execute()
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+4
View File
@@ -20,6 +20,7 @@ from app.models._constants import (
GAME_PLATFORMS,
PLATFORM_CODES,
TRN_URLS,
EVALUATION_CRITERIA,
)
# =========================================================================
@@ -93,3 +94,6 @@ from app.models.contract import Contract
from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
+10
View File
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
tryout_managers = db.Table(
'tryout_managers',
db.Column(
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
),
db.Column(
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
+15
View File
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
# Ordered list of (field_name, human_label) pairs for the player evaluation
# score criteria. Kept in a single place so the evaluation forms, batch
# evaluation page, and any future reporting all stay in sync.
EVALUATION_CRITERIA = [
('mecanics_score', 'Mecanics'),
('cohesion_score', 'Cohesion'),
('communication_score', 'Communication'),
('gamesense_score', 'Gamesense'),
('versatility_score', 'Versatility'),
('discipline_score', 'Discipline'),
('analysis_score', 'Analysis'),
('sport_ethics_score', 'Sport Ethics'),
('mental_score', 'Mental'),
]
ESPORT_GAMES = [
'Valorant',
'League of Legends',
+55
View File
@@ -0,0 +1,55 @@
"""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")
# 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)."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class BaseAvailability(db.Model):
@@ -13,5 +12,5 @@ class BaseAvailability(db.Model):
day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
+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."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class Contract(db.Model):
@@ -23,7 +22,7 @@ class Contract(db.Model):
status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
@@ -57,7 +56,7 @@ class Contract(db.Model):
if isinstance(user, Admin):
return True
if isinstance(user, Manager):
player = User.query.get(self.player_id)
player = db.session.get(User, self.player_id)
if player and player.get_org_teams():
return True
if isinstance(user, Coach):
+3 -4
View File
@@ -1,8 +1,7 @@
"""Player evaluation record."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class Evaluation(db.Model):
@@ -25,8 +24,8 @@ class Evaluation(db.Model):
overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match models (Match + TeamMatch)."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class BaseMatch(db.Model):
@@ -18,4 +17,4 @@ class BaseMatch(db.Model):
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Request from player to coach for a One on One session."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class OneOnOneRequest(db.Model):
@@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model):
end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True)
+2 -3
View File
@@ -1,9 +1,8 @@
"""Persistent organisation team (e.g. Varsity, JV)."""
from datetime import datetime
from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers
from app.time_utils import utc_now_naive
class OrgTeam(db.Model):
@@ -13,7 +12,7 @@ class OrgTeam(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Many-to-many junction: player to org-team."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TeamPlayer(db.Model):
@@ -14,7 +13,7 @@ class TeamPlayer(db.Model):
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utc_now_naive)
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match participant models."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class BaseParticipant(db.Model):
@@ -11,4 +10,4 @@ class BaseParticipant(db.Model):
__abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utc_now_naive)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Personal notes from coach to individual player."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class PersonalNote(db.Model):
@@ -13,8 +12,8 @@ class PersonalNote(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class Team(db.Model):
@@ -13,7 +12,7 @@ class Team(db.Model):
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Link between a player and a tryout-specific team."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TeamMember(db.Model):
@@ -13,6 +12,6 @@ class TeamMember(db.Model):
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utc_now_naive)
player = db.relationship('User', overlaps="player_ref,team_assignments")
+3 -4
View File
@@ -1,8 +1,7 @@
"""Team improvement notes from coach."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TeamNote(db.Model):
@@ -13,8 +12,8 @@ class TeamNote(db.Model):
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
+20 -6
View File
@@ -1,9 +1,8 @@
"""Tryout event for player evaluations and team formation."""
from datetime import datetime
from app.extensions import db
from app.models._associations import tryout_coaches
from app.models._associations import tryout_coaches, tryout_managers
from app.time_utils import utc_now_naive
class Tryout(db.Model):
@@ -21,16 +20,17 @@ class Tryout(db.Model):
max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
@@ -48,3 +48,17 @@ class Tryout(db.Model):
if self.end_date is not None:
return self.end_date < today
return self.date < today
def get_managers(self):
"""Managers attached to this tryout, both legacy and many-to-many."""
manager_list = list(self.managers)
if not manager_list and self.manager:
return [self.manager]
return manager_list
def get_coaches(self):
"""Coaches attached to this tryout, both legacy and many-to-many."""
coach_list = list(self.coaches)
if not coach_list and self.coach:
return [self.coach]
return coach_list
+2 -3
View File
@@ -1,8 +1,7 @@
"""Registration linking a player to a tryout."""
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class TryoutRegistration(db.Model):
@@ -12,6 +11,6 @@ class TryoutRegistration(db.Model):
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
registered_at = db.Column(db.DateTime, default=utc_now_naive)
status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True)
+12 -2
View File
@@ -21,7 +21,11 @@ class Manager(User):
return True
def can_manage_this_tryout(self, tryout):
return tryout.created_by == self.id or tryout.manager_id == self.id
return (
tryout.created_by == self.id
or tryout.manager_id == self.id
or any(m.id == self.id for m in tryout.managers)
)
def can_manage_this_org_team(self, org_team):
return True
@@ -32,7 +36,13 @@ class Manager(User):
from app.models.tryout.tryout import Tryout
return (
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
Tryout.query.filter(
or_(
Tryout.created_by == self.id,
Tryout.manager_id == self.id,
Tryout.managers.any(id=self.id),
)
)
.order_by(Tryout.date)
.all()
)
+2 -3
View File
@@ -1,10 +1,9 @@
"""Base User model — shared fields and polymorphic configuration."""
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db
from app.time_utils import utc_now_naive
class User(UserMixin, db.Model):
@@ -25,7 +24,7 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=utc_now_naive)
failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
+377
View File
@@ -0,0 +1,377 @@
"""Admin panel routes for system-level management.
Provides backup/restore, tryout open/close toggling, season lifecycle
management, team wiping, and audit log viewing. All routes are restricted
to administrators.
"""
import os
from datetime import datetime
from flask import (
Blueprint, render_template, redirect, url_for, flash, request,
send_file,
)
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
)
from app.supporting_scripts.backup import (
BACKUP_DIR, BackupError, backup_database, backup_documents,
create_backup_dir, parse_database_url, verify_backup,
)
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),
'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 redirect(url_for('admin.dashboard'))
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
flash(f'Backup created successfully: {record.filename}', 'success')
return redirect(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 redirect(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 redirect(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 redirect(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 redirect(url_for('admin.dashboard'))
# Create a safety backup of the current state before restoring.
try:
safety = _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 redirect(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 redirect(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 redirect(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 redirect(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 redirect(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 redirect(url_for('admin.dashboard'))
try:
datetime.strptime(start_date, '%Y-%m-%d')
except ValueError:
flash('Invalid season start date format.', 'danger')
return redirect(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 redirect(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 redirect(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 redirect(url_for('admin.dashboard'))
try:
datetime.strptime(end_date, '%Y-%m-%d')
except ValueError:
flash('Invalid season end date format.', 'danger')
return redirect(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 redirect(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 redirect(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 redirect(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 redirect(url_for('admin.dashboard'))
+19 -12
View File
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
import os
import secrets
import time
from datetime import datetime, timedelta
from datetime import timedelta
from urllib.parse import urlencode, urlparse
import requests
@@ -18,9 +18,11 @@ from flask_login import current_user, login_required, login_user, logout_user
from marshmallow import ValidationError
from app.extensions import check_password, db, hash_password, limiter
from app.forms import form_gamertags
from app.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User
from app.time_utils import utc_now_naive
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token.
@@ -293,7 +295,7 @@ def login():
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
log_auth_event(
'account.throttled',
username=username,
@@ -393,6 +395,13 @@ def register():
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
try:
submitted_gamertags = form_gamertags(selected_games)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return _rerender_registration(form_data)
# The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005).
@@ -445,16 +454,14 @@ def register():
# Create UserGamertag records for each selected game
from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
if gamertag_value:
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
)
db.session.add(gamertag)
for game, gamertag_data in submitted_gamertags.items():
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
)
db.session.add(gamertag)
db.session.commit()
# Clear Discord OAuth data from session after successful registration
+131 -15
View File
@@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
)
from sqlalchemy import func
from sqlalchemy.orm import aliased
@@ -27,6 +35,14 @@ from app.validators import EvaluationSchema
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()}
@evaluations_bp.route('')
@login_required
def list_evaluations():
@@ -85,9 +101,10 @@ def list_evaluations():
.group_by(Evaluation.player_id)
.all()
)
players_by_id = _users_by_id(row.player_id for row in avg_scores)
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
p = players_by_id.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p,
@@ -126,7 +143,7 @@ def evaluate_player(tryout_id, player_id):
flash(_('You do not have permission to evaluate players.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -142,7 +159,7 @@ def evaluate_player(tryout_id, player_id):
flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -160,8 +177,10 @@ def evaluate_player(tryout_id, player_id):
tryout_id=tryout_id,
player_id=player_id,
).all()
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
evaluators = [
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
{'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e}
for e in all_evaluations
]
return render_template(
@@ -210,21 +229,118 @@ def players_to_evaluate(tryout_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=p.id,
evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
players_by_id = _users_by_id(reg.player_id for reg in registrations)
evaluated_player_ids = {
player_id
for (player_id,) in db.session.query(Evaluation.player_id)
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
.all()
}
players = [
{
'player': player,
'evaluated': player.id in evaluated_player_ids,
'registration': registration,
}
for registration in registrations
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
]
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
@login_required
def batch_evaluate(tryout_id):
"""Evaluate multiple players at once in a tryout.
GET renders a single form listing every selected player with their
evaluation criteria. POST saves (creates or updates) all of them.
"""
if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Resolve selected player ids (query string on GET, hidden fields on POST).
player_ids = []
for raw in request.values.getlist('player_ids'):
try:
pid = int(raw)
except (ValueError, TypeError):
continue
if pid not in player_ids:
player_ids.append(pid)
if not player_ids:
flash('Please select at least one player to evaluate.', 'warning')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
players = []
for pid in player_ids:
player = User.query.get(pid)
if not player or not isinstance(player, Player):
continue
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=pid,
).first() is not None
if not is_registered:
continue
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
).first()
existing_scores = {
field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA
}
players.append({
'player': player,
'existing': existing,
'existing_scores': existing_scores,
})
if not players:
flash('No valid players selected for evaluation.', 'danger')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
if request.method == 'POST':
saved = 0
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success')
return redirect(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):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = (
top_rows = (
db.session.query(
Evaluation.player_id,
User,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.order_by(func.avg(Evaluation.overall_score).desc())
.join(Evaluation, Evaluation.player_id == User.id)
.filter(User.role == 'player')
.group_by(User.id)
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
.limit(5)
.all()
)
stats['top_players'] = []
for row in stats['avg_scores']:
p = User.query.get(row.player_id)
if p:
stats['top_players'].append((p, round(row.avg_score, 1)))
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
return render_template('pages/dashboard.html', user=user, stats=stats)
+27 -14
View File
@@ -46,6 +46,25 @@ def match_form_payload():
return form_payload(list_fields=('player_ids',), optional_blank=())
def registered_players(tryout_id):
"""Players registered for one tryout, loaded in a single query.
The create form previously called ``User.query.get`` twice per
registration (once in the filter and once in the result expression),
and the edit form called it once per row. Besides scaling linearly, both
paths could return duplicates while DB-006 is still pending. The join is
bounded and ``distinct`` preserves the form's intended one-option-per-
player contract until the database constraint lands.
"""
return (
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
.filter(TryoutRegistration.tryout_id == tryout_id)
.order_by(User.username)
.distinct()
.all()
)
#: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30
@@ -270,7 +289,7 @@ def api_events():
@login_required
def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False
@@ -359,7 +378,7 @@ def api_events_for_tryout(tryout_id):
@login_required
def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -369,11 +388,7 @@ def create_match(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
all_players = [
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)
]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout_id)
prefill_date = request.args.get('date', '')
def rerender():
@@ -437,7 +452,7 @@ def create_match(tryout_id):
@login_required
def edit_match(match_id):
"""Edit an existing match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
@@ -449,9 +464,7 @@ def edit_match(match_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
all_players = registered_players(tryout.id)
current_player_ids = [p.player_id for p in match.participants.all()]
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
@@ -569,7 +582,7 @@ def api_manageable_tryouts():
@login_required
def delete_match(match_id):
"""Delete a match."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger')
@@ -656,10 +669,10 @@ def api_available_players(date, time):
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id)
match = db.get_or_404(Match, match_id)
tryout = match.tryout
participant = MatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(MatchParticipant, participant_id)
if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
+28 -8
View File
@@ -3,8 +3,6 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
@@ -22,11 +20,13 @@ from app.models import (
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings,
)
from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive
from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
@@ -41,6 +41,17 @@ def can_manage_team_match(team):
return can_manage_org_team(current_user, team)
def season_locked():
"""Return True when the regular season is inactive for non-admins.
Admins bypass the lock. Coaches and managers may only schedule regular
season matches while the season is active.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('season_active', default=False)
@team_matches_bp.route('')
@login_required
def list_matches():
@@ -100,7 +111,7 @@ def list_matches():
teams=teams,
match_data=match_data,
pagination=matches_page,
now=datetime.utcnow(),
now=utc_now_naive(),
)
@@ -108,7 +119,11 @@ def list_matches():
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if season_locked():
flash('The regular season is not active. Begin a season before scheduling matches.', 'warning')
return redirect(url_for('team_matches.list_matches'))
if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
@@ -213,7 +228,7 @@ def create_match(team_id):
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if not can_manage_team_match(team):
@@ -259,11 +274,16 @@ def edit_match(match_id):
@login_required
def delete_match(match_id):
"""Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if season_locked():
flash('The regular season is not active. Begin a season before deleting matches.', 'warning')
return redirect(url_for('team_matches.list_matches'))
if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match)
db.session.commit()
flash(_('Match deleted successfully.'), 'success')
@@ -293,10 +313,10 @@ def api_manageable_teams():
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(TeamMatchParticipant, participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
+37 -30
View File
@@ -3,9 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
@@ -29,7 +27,8 @@ from app.models import (
User,
)
from app.permissions import visible_org_teams
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -82,7 +81,7 @@ def my_teams():
from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams()
now = datetime.utcnow()
now = utc_now_naive()
team_data = []
for org_team in player_teams:
@@ -223,7 +222,7 @@ def create_team():
@login_required
def edit_team(team_id):
"""Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -294,7 +293,7 @@ def delete_team(team_id):
day `Manager.can_manage_this_org_team` is narrowed which it should be
deletion narrows with it instead of staying the one way in.
"""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger')
@@ -336,7 +335,7 @@ def delete_team(team_id):
@login_required
def add_coach(team_id):
"""Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -379,7 +378,7 @@ def add_coach(team_id):
@login_required
def add_manager(team_id):
"""Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -422,7 +421,7 @@ def add_manager(team_id):
@login_required
def remove_coach(team_id):
"""Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -450,7 +449,7 @@ def remove_coach(team_id):
@login_required
def remove_manager(team_id):
"""Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -478,7 +477,7 @@ def remove_manager(team_id):
@login_required
def add_player(team_id):
"""Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -515,12 +514,12 @@ def add_player(team_id):
@login_required
def remove_player(team_id, player_id):
"""Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(
@@ -543,7 +542,7 @@ def remove_player(team_id, player_id):
@login_required
def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403
@@ -567,17 +566,21 @@ def toggle_player_status(team_id, player_id):
@login_required
def add_team_note(team_id):
"""Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams'))
@@ -585,12 +588,12 @@ def add_team_note(team_id):
@login_required
def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -603,10 +606,14 @@ def add_player_note(team_id, player_id):
)
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams'))
+143 -43
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.
"""
from datetime import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from sqlalchemy import select
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
@@ -31,8 +30,17 @@ from app.models import (
Tryout,
TryoutRegistration,
User,
AppSettings,
)
from app.time_utils import utc_now_naive
from app.validators import (
PlayerSelectionSchema,
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
from app.validators import PlayerSelectionSchema, TryoutSchema
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -42,9 +50,20 @@ def can_manage():
return isinstance(current_user, (Admin, Manager))
def tryouts_locked():
"""Return True when tryouts are globally closed to coaches/managers.
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('tryouts_open', default=True)
def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids',), optional_blank=())
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
def coaches_from_ids(coach_ids):
@@ -60,6 +79,13 @@ def coaches_from_ids(coach_ids):
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def managers_from_ids(manager_ids):
"""The manager accounts behind these ids, filtered by role."""
if not manager_ids:
return []
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
def _users_by_id(user_ids):
"""Load these users in one query, keyed by id.
@@ -79,6 +105,25 @@ def _users_by_id(user_ids):
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
def registration_lock_statement(tryout_id):
"""The PostgreSQL row lock used by both registration entry points."""
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
def locked_tryout_or_404(tryout_id):
"""Load and row-lock a tryout while a registration slot is decided.
PostgreSQL serializes concurrent registration attempts on this row. The
duplicate check, capacity count and insert that follow therefore form
one decision instead of three independently racing statements. SQLite
ignores ``FOR UPDATE`` in tests, but production does not.
"""
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
if tryout is None:
abort(404)
return tryout
@tryouts_bp.route('')
@login_required
def list_tryouts():
@@ -87,13 +132,17 @@ def list_tryouts():
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
"""
tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive())
@tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if not can_manage():
flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -134,12 +183,12 @@ def create_tryout():
created_by=current_user.id,
status='upcoming',
target_org_team_id=data['target_org_team_id'],
manager_id=data['manager_id'],
)
db.session.add(tryout)
db.session.flush()
tryout.coaches = coaches_from_ids(data['coach_ids'])
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit()
flash(_('Tryout created successfully!'), 'success')
@@ -152,7 +201,11 @@ def create_tryout():
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger')
@@ -195,8 +248,14 @@ def edit_tryout(tryout_id):
tryout.location = data['location']
tryout.max_players = data['max_players']
tryout.target_org_team_id = data['target_org_team_id']
tryout.manager_id = data['manager_id']
tryout.coaches = coaches_from_ids(data['coach_ids'])
# Only update staff lists when the form explicitly sends them.
# An absent checkbox group (all unchecked or JS failed) means
# "don't change", not "remove everyone".
if 'coach_ids' in request.form:
tryout.coaches = coaches_from_ids(data['coach_ids'])
if 'manager_ids' in request.form:
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit()
flash(_('Tryout updated successfully!'), 'success')
@@ -209,13 +268,13 @@ def edit_tryout(tryout_id):
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
can_view = False
if isinstance(current_user, Admin):
can_view = True
elif isinstance(current_user, Manager):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
@@ -398,7 +457,7 @@ def view_tryout(tryout_id):
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow(),
now=utc_now_naive(),
)
@@ -406,10 +465,10 @@ def view_tryout(tryout_id):
@login_required
def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player):
flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger')
@@ -439,15 +498,23 @@ def register_for_tryout(tryout_id):
@login_required
def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']:
tryout.status = new_status
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
try:
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout.status = data['status']
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -455,7 +522,11 @@ def update_status(tryout_id):
@login_required
def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -463,11 +534,15 @@ def update_registration_status(tryout_id, player_id):
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first_or_404()
new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
db.session.commit()
flash(_('Registration status updated.'), 'success')
try:
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration.status = data['status']
db.session.commit()
flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -475,7 +550,11 @@ def update_registration_status(tryout_id, player_id):
@login_required
def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = locked_tryout_or_404(tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -522,12 +601,16 @@ def register_player(tryout_id):
@login_required
def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
@@ -558,17 +641,25 @@ def remove_player(tryout_id, player_id):
@login_required
def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name')
if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -576,8 +667,12 @@ def create_team(tryout_id):
@login_required
def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team."""
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -588,10 +683,12 @@ def add_to_team(tryout_id, team_id):
if team.tryout_id != tryout_id:
abort(404)
player_id = request.form.get('player_id', type=int)
if not player_id:
flash(_('Please select a player.'), 'danger')
try:
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_id']
# Only players registered for this tryout may be placed on its teams.
is_registered = (
@@ -602,12 +699,11 @@ def add_to_team(tryout_id, team_id):
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
flash(_('Player is already on this team.'), 'info')
else:
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
db.session.add(member)
db.session.commit()
flash(_('Player added to team!'), 'success')
@@ -618,7 +714,11 @@ def add_to_team(tryout_id, team_id):
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
+1312
View File
File diff suppressed because it is too large Load Diff
+15 -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.
"""
from flask import request
from flask_babel import gettext as _
from app.extensions import db
@@ -13,7 +12,7 @@ from app.extensions import db
# routes needed them as well (ARCH-005). Importing them from here still
# works, so the thirty call sites in this package did not have to move.
from app.forms import flash_validation_errors, form_payload # noqa: F401
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
@@ -63,20 +62,25 @@ def pdf_upload_error(file, allowed_extensions):
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
"""Update gamertags for a user from validated dynamic form fields."""
from app.forms import form_gamertags
submitted = form_gamertags(selected_games)
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
gamertag = request.form.get(f'gamertag_{game}', '').strip()
platform = (
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
)
payload = submitted.get(game)
existing = existing_gamertags.get(game)
if gamertag:
if payload:
if existing:
existing.gamertag = gamertag
existing.platform = platform
existing.gamertag = payload['gamertag']
existing.platform = payload['platform']
else:
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
gt = UserGamertag(
user_id=user.id,
game=game,
gamertag=payload['gamertag'],
platform=payload['platform'],
)
db.session.add(gt)
elif existing:
db.session.delete(existing)
+9 -5
View File
@@ -70,7 +70,7 @@ def edit_user(user_id):
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
if request.method == 'POST':
actor_name, actor_id = current_user.username, current_user.id
@@ -121,6 +121,12 @@ def edit_user(user_id):
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender()
try:
update_user_gamertags(user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
role_changed = user.role != role
previous_role = user.role
@@ -183,8 +189,6 @@ def edit_user(user_id):
user.discord_user_id = discord_user_id or None
user.league_os_profile = league_os_profile or None
update_user_gamertags(user, selected_games)
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
@@ -249,7 +253,7 @@ def delete_user(user_id):
flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users'))
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
@@ -375,5 +379,5 @@ def create_user():
@login_required
def view_user(user_id):
"""View a public profile for any user."""
user = User.query.get_or_404(user_id)
user = db.get_or_404(User, user_id)
return render_template('pages/view_user.html', profile_user=user)
+1 -1
View File
@@ -193,7 +193,7 @@ def clear_disponibilities():
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
disponibility = db.get_or_404(PlayerDisponibility, disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
+45 -24
View File
@@ -2,7 +2,6 @@
import os
import uuid
from datetime import datetime
from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
@@ -19,7 +18,14 @@ from app.routes.users._shared import (
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
from app.storage import CONTRACTS_DIR, document_path
from app.storage import (
CONTRACTS_DIR,
GoogleDriveStorageError,
is_google_drive_path,
open_document,
store_uploaded_document,
)
from app.time_utils import utc_now_naive
from app.validators import UploadContractSchema
@@ -111,7 +117,7 @@ def upload_contract():
flash(error, 'danger')
return redirect(url_for('users.upload_contract'))
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None
@@ -126,9 +132,11 @@ def upload_contract():
if team:
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file.save(absolute_path)
try:
stored_path = store_uploaded_document(file, relative_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.upload_contract'))
contract = Contract(
player_id=player_id,
@@ -136,7 +144,7 @@ def upload_contract():
uploaded_by_id=current_user.id,
original_filename=original_filename,
stored_filename=stored_filename,
file_path=relative_path,
file_path=stored_path,
notes=notes if notes else None,
)
db.session.add(contract)
@@ -154,7 +162,7 @@ def upload_contract():
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
@@ -166,13 +174,24 @@ def upload_signed_contract(contract_id):
return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
file.save(document_path(signed_path))
# Legacy local records keep their signed copy beside the original. Drive
# identifiers are opaque rather than filenames, so new Drive records use
# a fresh logical path and receive a second Drive ID.
signed_path = (
os.path.join(CONTRACTS_DIR, signed_filename)
if is_google_drive_path(contract.file_path)
else contract.file_path.replace(contract.stored_filename, signed_filename)
)
try:
stored_signed_path = store_uploaded_document(file, signed_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
contract.signed_filename = signed_filename
contract.signed_file_path = signed_path
contract.signed_file_path = stored_signed_path
contract.status = 'signed'
contract.signed_at = datetime.utcnow()
contract.signed_at = utc_now_naive()
db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
@@ -182,30 +201,32 @@ def upload_signed_contract(contract_id):
@login_required
def download_contract(contract_id):
"""Download a contract file."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.file_path),
as_attachment=True,
download_name=contract.original_filename,
)
try:
document = open_document(contract.file_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.original_filename)
@users_bp.route('/contracts/<int:contract_id>/download_signed')
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = Contract.query.get_or_404(contract_id)
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.signed_file_path),
as_attachment=True,
download_name=contract.signed_filename,
)
try:
document = open_document(contract.signed_file_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
+116 -55
View File
@@ -7,23 +7,32 @@ it reads exactly what the coach routes write.
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
Team,
TeamMember,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
from app.permissions import (
coach_can_access_player,
coach_org_teams,
coach_player_ids,
coach_tryouts,
)
from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@users_bp.route('/my-notes')
@@ -116,23 +125,25 @@ def notes_dashboard():
)
# For context selectors in the form
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
# previous selector mixed the two namespaces and could either attach the
# note to an unrelated team with the same integer id or fail its FK.
# Every context list now comes from the tryouts this coach may manage.
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
tryout_ids = [tryout.id for tryout in tryouts]
matches = (
Match.query.filter(
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
)
Match.query.filter(Match.tryout_id.in_(tryout_ids))
.order_by(Match.date.desc())
.limit(20)
.all()
if tryout_ids
else []
)
tryouts = (
Tryout.query.filter_by(
created_by=current_user.id,
)
.order_by(Tryout.date.desc())
.limit(20)
.all()
teams = (
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
if tryout_ids
else []
)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
return render_template(
'pages/notes.html',
@@ -168,16 +179,20 @@ def manage_team_notes():
return redirect(url_for('users.notes_dashboard'))
org_team = org_teams[0]
content = request.form.get('content', '').strip()
if content:
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=content,
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -195,14 +210,14 @@ def manage_personal_notes():
flash(_('Only coaches can manage personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
@@ -214,7 +229,7 @@ def manage_personal_notes():
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
)
db.session.add(note)
db.session.commit()
@@ -235,17 +250,14 @@ def add_personal_note():
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
match_id = request.form.get('match_id', type=int)
tryout_id = request.form.get('tryout_id', type=int)
team_id_str = request.form.get('team_id')
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = User.query.get_or_404(player_id)
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
@@ -254,13 +266,40 @@ def add_personal_note():
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(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 redirect(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 redirect(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 redirect(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 redirect(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 redirect(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 redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
match_id=match_id if match_id else None,
tryout_id=tryout_id if tryout_id else None,
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
content=data['content'],
match_id=data['match_id'],
tryout_id=data['tryout_id'],
team_id=data['team_id'],
)
db.session.add(note)
db.session.commit()
@@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list
@@ -289,21 +331,29 @@ def add_note_from_tryout(tryout_id):
players = [r.player for r in registrations if r.player]
if request.method == 'POST':
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
player_id = data['player_id']
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
if data['tryout_id'] not in (None, tryout_id):
flash(_('Invalid tryout context.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
tryout_id=tryout_id,
)
db.session.add(note)
@@ -334,7 +384,10 @@ def add_note_from_match(match_id):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
match_obj = Match.query.get_or_404(match_id)
match_obj = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
# Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
@@ -343,21 +396,29 @@ def add_note_from_match(match_id):
preselected_player_id = request.args.get('player_id', type=int)
if request.method == 'POST':
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_match', match_id=match_id))
player_id = data['player_id']
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
if data['match_id'] not in (None, match_id):
flash(_('Invalid match context.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
flash(_('That player did not participate in this match.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
content=data['content'],
match_id=match_id,
)
db.session.add(note)
+12 -8
View File
@@ -1,7 +1,5 @@
"""One-on-one sessions between a player and their coach."""
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
@@ -12,7 +10,8 @@ from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification
from app.validators import OneOnOneRequestSchema
from app.time_utils import utc_now_naive
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@@ -166,7 +165,7 @@ def accept_one_on_one(request_id):
flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
@@ -178,7 +177,7 @@ def accept_one_on_one(request_id):
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow()
request_obj.responded_at = utc_now_naive()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
@@ -216,7 +215,7 @@ def reject_one_on_one(request_id):
flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
request_obj = db.get_or_404(OneOnOneRequest, request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
@@ -226,11 +225,16 @@ def reject_one_on_one(request_id):
flash(_('This request has already been processed.'), 'info')
return redirect(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 redirect(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason']
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow()
request_obj.responded_at = utc_now_naive()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
+12 -2
View File
@@ -98,6 +98,18 @@ def edit_profile():
user_gamertags=current_user.get_gamertags(),
)
try:
update_user_gamertags(current_user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
current_user.username = username
current_user.full_name = full_name
current_user.email = email
@@ -106,8 +118,6 @@ def edit_profile():
current_user.discord_username = discord_username or None
current_user.league_os_profile = league_os_profile or None
update_user_gamertags(current_user, selected_games)
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
+27
View File
@@ -2037,3 +2037,30 @@ a:hover { color: var(--primary-dark); }
.honeypot {
display: none;
}
/* Batch Evaluation - 2 cards wide layout */
.batch-eval-form {
max-width: none;
}
.batch-eval-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.batch-eval-grid .card {
margin-bottom: 0;
}
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
.batch-eval-grid .form-row {
flex-wrap: wrap;
}
@media (max-width: 1100px) {
.batch-eval-grid {
grid-template-columns: 1fr;
}
}
+19 -1
View File
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
* Confirmation before a destructive submit.
*
* <form data-confirm="Delete this match?">
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
* data-confirm-input-message="Type DELETE to continue."
* data-confirm="Delete this record?">
*
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated.
*/
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
if (!form) {
return;
}
const inputName = form.getAttribute('data-confirm-input');
if (inputName) {
const input = form.elements.namedItem(inputName);
const expectedValue = form.getAttribute('data-confirm-value') || '';
if (!input || input.value.trim() !== expectedValue) {
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
event.preventDefault();
return;
}
}
if (!window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
+76 -7
View File
@@ -28,8 +28,17 @@ absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002).
"""
import io
import logging
import os
from app.google_drive import (
GoogleDriveStorageError,
delete_file as delete_google_drive_file,
download_file as download_google_drive_file,
upload_file as upload_google_drive_file,
)
#: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current.
@@ -45,6 +54,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés'
#: Database marker for documents stored remotely. Existing rows continue to
#: hold relative or absolute filesystem paths, so switching storage does not
#: invalidate contracts already uploaded before the Google Drive move.
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
#: Storage backends deliberately stay explicit. Google Drive is the
#: production default; local disk exists only for legacy rows and isolated
#: test/development environments.
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
def project_root():
"""Absolute path of the project, derived from this file's location.
@@ -91,6 +110,56 @@ def _rooted(env_name, default_name):
return os.path.join(project_root(), default_name)
def document_storage_backend():
"""Return the configured backend for new document uploads."""
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'google_drive').strip().lower()
if backend not in {'google_drive', 'local'}:
raise ValueError(
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
)
return backend
def is_google_drive_path(stored_path):
"""Whether a database path references a Google Drive file."""
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
def _google_drive_id(stored_path):
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
if not file_id:
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
return file_id
def store_uploaded_document(file_storage, relative_path):
"""Store an uploaded document and return its durable database reference.
Local storage retains the relative-path format used by existing rows. A
Google Drive upload returns an opaque Drive file identifier prefixed with
``gdrive://`` so it cannot be mistaken for a filesystem path.
"""
if document_storage_backend() == 'local':
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file_storage.save(absolute_path)
return relative_path
file_id = upload_google_drive_file(
stream=file_storage.stream,
filename=os.path.basename(relative_path),
mimetype=file_storage.mimetype or 'application/pdf',
)
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
def open_document(stored_path):
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
if is_google_drive_path(stored_path):
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
return document_path(stored_path)
def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012).
@@ -107,27 +176,27 @@ def discard_documents(stored_paths):
A path that cannot be removed is logged and skipped. Nothing here should
be able to abort the deletion of an account.
"""
import logging
logger = logging.getLogger(__name__)
removed = 0
for stored_path in stored_paths:
if not stored_path:
continue
target = document_path(stored_path)
try:
os.remove(target)
if is_google_drive_path(stored_path):
delete_google_drive_file(_google_drive_id(stored_path))
else:
os.remove(document_path(stored_path))
removed += 1
except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target)
except OSError as exc:
logger.info('Document already absent: %s', stored_path)
except (GoogleDriveStorageError, OSError) as exc:
logger.error(
'Could not remove %s (%s). It is now an orphan: no database row '
'refers to it, so nothing in the application will ever offer to '
'delete it again.',
target,
stored_path,
exc,
)
return removed
+23 -20
View File
@@ -55,8 +55,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
# keep uploads outside the releases, and docs/deployment.md now tells the
# operator to set it — at which point this script archived a directory the
# application had never written to. It does not fail on a missing directory
# either; it prints "No documents directory found", skips, and exits 0.
# application had never written to. A missing or unreadable document store
# is now a failed full-backup run rather than a database-only green result.
#
# So the more correctly an operator followed the deployment documentation,
# the more certainly their contract backups were empty (OBS-006).
@@ -262,33 +262,31 @@ def backup_documents():
module happened to be imported with.
Returns:
str: Path to the created archive, or None if there is nothing to
archive. Signed contracts live only on disk, so losing this
directory loses the documents themselves.
str: Path to the created archive.
Raises:
BackupError: If the configured store is absent or cannot be archived.
Signed contracts live only on disk, so a database-only run must
never be reported as a complete backup.
"""
documents_dir = documents_root()
if not os.path.exists(documents_dir):
# Says where it looked. The previous message named no path, so an
# operator who had moved the documents read it as "there are no
# documents" rather than "I am looking in the wrong place".
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
return None
raise BackupError(f'Documents directory does not exist: {documents_dir}')
if not os.path.isdir(documents_dir):
raise BackupError(f'Documents path is not a directory: {documents_dir}')
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
try:
shutil.make_archive(archive_basename, 'zip', documents_dir)
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
# This runs after the database dump has already succeeded. Letting
# anything through here would abort the script with a traceback and
# take the one part that worked down with it. Reported to stdout, in
# the format the rest of this script uses; it has no logger.
print(f'[ERROR] Document backup failed: {exc}')
return None
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
raise BackupError(f'Document backup failed: {exc}') from exc
zip_path = f'{archive_basename}.zip'
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Document archiver reported success but produced an empty file.')
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
@@ -361,15 +359,20 @@ def main(argv=None):
return 1
verified = verify_backup(backup_path)
backup_documents()
documents_ok = True
try:
backup_documents()
except BackupError as exc:
documents_ok = False
print(f'[ERROR] {exc}')
cleanup_old_backups()
print()
if verified:
if verified and documents_ok:
print('=== Backup completed successfully ===')
return 0
print('=== Backup FAILED verification — do not rely on this archive ===')
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
return 1
+33 -17
View File
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
- Debug mode status
- HTTPS configuration
- Dependency vulnerabilities
- Database connectivity
- Required database configuration
Usage:
python security_scan.py [--url http://localhost:5000]
@@ -19,6 +19,10 @@ import subprocess
import sys
import urllib.request
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
def check_environment():
@@ -31,8 +35,8 @@ def check_environment():
print('1. ENVIRONMENT VARIABLES CHECK')
print('=' * 60)
critical_vars = ['SECRET_KEY']
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
recommended_vars = ['CORS_ALLOWED_ORIGINS']
all_ok = True
for var in critical_vars:
@@ -58,7 +62,8 @@ def check_environment():
# Check FLASK_DEBUG
debug = os.getenv('FLASK_DEBUG', 'false').lower()
if debug == 'true':
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
all_ok = False
else:
print('[OK] FLASK_DEBUG is disabled')
@@ -91,10 +96,11 @@ def check_https_headers(url):
all_ok = True
try:
# Create a context that doesn't verify SSL (for local testing)
# Keep the default certificate and hostname verification. A scanner
# that accepts an invalid certificate can validate headers while the
# transport itself is impersonated. Local runs without TLS should use
# http:// explicitly or opt out with --skip-http.
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method='HEAD')
@@ -148,9 +154,9 @@ def check_https_headers(url):
all_ok = False
except urllib.error.URLError as e:
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
print('[SKIP] Run with --url <application_url> to check headers')
return True # Not a failure, just can't check
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
return False
return all_ok
@@ -167,7 +173,15 @@ def check_dependencies():
try:
result = subprocess.run(
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
[
sys.executable,
'-m',
'pip_audit',
'--requirement',
str(REQUIREMENTS_FILE),
'--format',
'json',
],
capture_output=True,
text=True,
timeout=60,
@@ -195,14 +209,16 @@ def check_dependencies():
if result.stdout:
print(f'[INFO] {result.stdout.strip()}')
if result.stderr:
print(f'[WARN] {result.stderr.strip()}')
return True
print(f'[FAIL] {result.stderr.strip()}')
else:
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
return False
except FileNotFoundError:
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
return True
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
return False
except subprocess.TimeoutExpired:
print('[WARN] pip-audit timed out')
return True
print('[FAIL] pip-audit timed out')
return False
def check_file_permissions():
+6
View File
@@ -90,6 +90,12 @@
<span>{{ _('Manage Users') }}</span>
</a>
</li>
<li>
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
<i class="fas fa-cogs"></i>
<span>Admin Panel</span>
</a>
</li>
{% endif %}
{% if current_user.role == 'player' %}
<li>
+267
View File
@@ -0,0 +1,267 @@
{% 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" 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-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" 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" 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 %}
+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" 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 %}
+8 -1
View File
@@ -216,7 +216,14 @@ 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>';
const text = document.createElement('span');
text.textContent = message;
const close = document.createElement('button');
close.type = 'button';
close.className = 'alert-close';
close.dataset.action = 'remove-element';
close.textContent = '×';
alert.append(text, close);
flashContainer.appendChild(alert);
}
+20 -8
View File
@@ -450,11 +450,18 @@
var playerDataById = {
player_data: {
{%- for p in all_players %}
{{ p.id }}: "{{ p.username | escape }}",
{{ p.id }}: {{ p.username | tojson }},
{%- endfor %}
}
};
var HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (character) {
return HTML_ESCAPES[character];
});
}
// All registered player IDs
var allRegisteredPlayers = [
{%- for p in all_players %}
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
}
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
'data-action="toggle-time-slot">' +
slotData.display +
'<span class="merged-disponibility-count">' + count + '</span>' +
'</div>';
@@ -920,7 +927,7 @@ function updatePlayerPool() {
var availabilityClass = isAvailable ? 'available' : 'unavailable';
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
html += '<span class="player-name">' + playerName + '</span>';
html += '<span class="player-name">' + escapeHtml(playerName) + '</span>';
html += '<div class="player-actions">';
html += '<button type="button" class="btn btn-sm btn-primary" data-action="assign-team" data-team-side="1">{{ _('T1') }}</button>';
html += '<button type="button" class="btn btn-sm btn-secondary" data-action="assign-team" data-team-side="2">{{ _('T2') }}</button>';
@@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) {
if (!playerName) return;
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) {
registerActions({
'toggle-match-type': toggleMatchType,
'clear-time-selection': clearTimeSelection,
'toggle-time-slot': function (element) {
toggleTimeSlot(parseInt(element.getAttribute('data-day'), 10),
element.getAttribute('data-time'),
element);
},
'update-randomize-preview': updateRandomizePreview,
'randomize-teams': randomizeTeams,
'return-to-pool': returnToPool,
+74 -44
View File
@@ -6,54 +6,84 @@
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
<h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Player') }}</th>
<th>{{ _('Contact') }}</th>
<th>{{ _('Attendance') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for entry in players %}
<tr>
<td>
<div class="user-mini">
<p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
<form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
<th>Player</th>
<th>Contact</th>
<th>Attendance</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in players %}
<tr>
<td>
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
</td>
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<span>{{ entry.player.username }}</span>
</div>
</td>
<td>{{ entry.player.email }}</td>
<td>
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
</td>
<td>
{% if entry.evaluated %}
<span class="badge badge-success">Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</td>
<td>{{ entry.player.email }}</td>
<td>
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
</td>
<td>
{% if entry.evaluated %}
<span class="badge badge-success">Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">No players registered for this tryout.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-clipboard-check"></i> Evaluate Selected
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}">
function toggleAll() {
var boxes = document.querySelectorAll('.player-check');
var master = document.getElementById('select-all');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = master.checked;
}
}
registerActions({
'toggle-all': toggleAll,
});
</script>
{% endblock %}
+44
View File
@@ -145,6 +145,49 @@
</div>
</div>
<!-- Player Disponibilities Card -->
{% if user.role == 'player' %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Disponibilities</h3>
<p class="text-muted small">Your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
</div>
<div class="card-body">
<div id="disponibilities-grid">
<p class="text-muted">Loading...</p>
</div>
<div class="form-actions mt-3">
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
<i class="fas fa-save"></i> Save Disponibilities
</button>
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
<!-- Coach Availability Card -->
{% if user.role == 'coach' %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Availability</h3>
<p class="text-muted small">Select time slots when you're available for One on One sessions (8am to 10pm).</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-3">
<button type="button" class="btn btn-secondary" data-action="clear-availability">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
<!-- Contracts Card -->
{% if user.role == 'player' %}
<div class="card">
@@ -607,6 +650,7 @@ document.addEventListener('DOMContentLoaded', function() {
// dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise.
registerActions({
'save-disponibilities': saveDisponibilities,
'clear-disponibilities': clearDisponibilities,
'clear-availability': clearAllAvailability,
});
+3
View File
@@ -300,6 +300,9 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}">
function showCreateForm() {
document.getElementById('createTeamForm').classList.remove('hidden');
+14 -7
View File
@@ -67,15 +67,22 @@
</select>
</div>
<div class="form-group col-6">
<label for="manager_id">{{ _('Assigned Manager') }}</label>
<select id="manager_id" name="manager_id" class="form-select">
<option value="">{{ _('-- No manager assigned --') }}</option>
<label>{{ _('Assigned Managers') }}</label>
<div class="checkbox-grid">
{% for manager in managers %}
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
{{ manager.username }}
</option>
{% set is_checked = false %}
{% if tryout %}
{% for m in tryout.get_managers() %}
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
{% endfor %}
{% endif %}
<label class="checkbox-label">
<input type="checkbox" name="manager_ids" value="{{ manager.id }}" {% if is_checked %}checked{% endif %}>
<span>{{ manager.username }}</span>
</label>
{% endfor %}
</select>
</div>
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
</div>
</div>
<div class="form-row">
+15 -6
View File
@@ -70,16 +70,22 @@
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Manager</span>
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
<span class="detail-label">Manager(s)</span>
<span class="detail-value">
{% set mgrs = tryout.get_managers() %}
{% if mgrs %}
{{ mgrs | map(attribute='username') | join(', ') }}
{% else %}
Not assigned
{% endif %}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Coaches</span>
<span class="detail-value">
{% if tryout.coaches %}
{{ tryout.coaches | map(attribute='username') | join(', ') }}
{% elif tryout.coach %}
{{ tryout.coach.username }}
{% set cos = tryout.get_coaches() %}
{% if cos %}
{{ cos | map(attribute='username') | join(', ') }}
{% else %}
Not assigned
{% endif %}
@@ -534,6 +540,9 @@
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<style>
.presence-toggle-btn {
padding: 2px 7px;
+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)
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
+141 -1
View File
@@ -23,7 +23,7 @@ from marshmallow import (
validates_schema,
)
from app.models import ESPORT_GAMES, USER_TYPES
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
# =============================================================================
# Custom Validators
@@ -276,6 +276,36 @@ class RegisterSchema(StripMixin):
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password')
class GamertagSchema(StripMixin):
"""One dynamic per-game identity submitted beside an account form."""
game = fields.String(
required=True,
validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')),
)
gamertag = fields.String(
required=True,
validate=validate.Length(
min=1,
max=120,
error=_l('Gamertag must be between 1 and 120 characters.'),
),
)
platform = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=30, error=_l('Platform must be 30 characters or less.')),
)
@validates_schema
def validate_platform_for_game(self, data, **kwargs):
"""A forged platform must belong to the selected game's list."""
platform = data.get('platform')
allowed = GAME_PLATFORMS.get(data.get('game'), ())
if platform and platform not in allowed:
raise ValidationError(_l('Unknown platform for this game.'), field_name='platform')
class CreateUserSchema(StripMixin):
"""Validate president-created user form input.
@@ -517,6 +547,115 @@ 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 NoteContentSchema(StripMixin):
"""Bounded text stored as a team or personal coaching note."""
content = fields.String(
required=True,
validate=validate.Length(
min=1,
max=5000,
error=_l('Note content must be between 1 and 5000 characters.'),
),
)
class PersonalNoteSchema(NoteContentSchema):
"""A personal note with at most one optional, typed context."""
player_id = fields.Integer(
required=True,
validate=validate.Range(min=1),
error_messages={
'invalid': _l('Invalid player selection.'),
'required': _l('Player must be selected.'),
},
)
match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
@validates_schema
def validate_one_context(self, data, **kwargs):
"""A note cannot claim several unrelated contexts at once."""
contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')]
if sum(value is not None for value in contexts) > 1:
raise ValidationError(
_l('Select at most one note context.'),
field_name='context',
)
class OneOnOneRejectionSchema(StripMixin):
"""Optional explanation sent to a player when a request is rejected."""
rejection_reason = fields.String(
load_default='',
validate=validate.Length(
max=2000,
error=_l('Rejection reason must be 2000 characters or less.'),
),
)
class OneOnOneRequestSchema(StripMixin):
"""A player asking their coach for a session (MNT-12).
@@ -810,6 +949,7 @@ class TryoutSchema(StripMixin):
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
manager_id = fields.Integer(allow_none=True, load_default=None)
coach_ids = fields.List(fields.Integer(), load_default=list)
manager_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema
def validate_span(self, data, **kwargs):
+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-005` | Cascades de suppression au niveau base | Les cascades ORM sont en place ; PostgreSQL ne les connaît pas |
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Le plafond d'inscriptions est aujourd'hui un `count()` suivi d'un `add()` : deux requêtes simultanées passent toutes les deux |
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Les deux routes verrouillent désormais la ligne `Tryout` avant le contrôle de doublon, le `count()` et l'`add()` : PostgreSQL sérialise donc leurs décisions de capacité. La contrainte reste nécessaire pour les scripts, imports et futurs chemins d'écriture qui ne passent pas par ces routes |
| `DB-007` | Index, `CheckConstraint` sur les statuts, `server_default` | — |
| `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement |
| `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
+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
than one that starts and is wide open (OPS-003).
### Google Drive contract storage
New contracts are stored in the owner's personal Google Drive when
`DOCUMENT_STORAGE_BACKEND=google_drive`. Enable the Google Drive API in a
Google Cloud project, create an OAuth client for the owner, and authorize it
once with the `drive.file` scope. Configure the resulting values outside the
repository:
```text
DOCUMENT_STORAGE_BACKEND=google_drive
GOOGLE_DRIVE_FOLDER_ID=<owner folder id>
GOOGLE_DRIVE_CLIENT_ID=<OAuth client id>
GOOGLE_DRIVE_CLIENT_SECRET=<OAuth client secret>
GOOGLE_DRIVE_REFRESH_TOKEN=<owner refresh token>
```
The refresh token can create, download, and delete contracts created by this
application. Keep all four values out of source control. The database stores
only `gdrive://<file-id>` references, so existing local-file rows continue to
work after the change. Use `DOCUMENT_STORAGE_BACKEND=local` only for tests,
local development, or while migrating legacy files.
### Binding and proxy trust — read this before going live (OPS-002)
Two variables decide whether the rate limiter, the account lockout and the
@@ -282,11 +304,11 @@ have sent new contracts to a new tree and made the existing ones unreadable
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
backup script kept its own copy of the document path — so it archived
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
was therefore enough, on its own, to make every contract backup empty; the
script prints `No documents directory…` and still exits 0, so a scheduled
task watching the exit code would have seen green indefinitely. All three
roots now come from `app/storage.py`, and the backup run prints the document
source it used.
was therefore enough, on its own, to make every contract backup empty. All
three roots now come from `app/storage.py`, and the backup run prints the
document source it used. A missing or unarchivable document store makes the
run exit non-zero even when the database dump itself is valid, so a scheduler
cannot report a database-only recovery point as a complete backup.
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
and check the `Document source:` line and the size of the resulting
+7 -4
View File
@@ -21,14 +21,17 @@ filterwarnings = [
"default",
# discord.py imports audioop, removed from the stdlib in 3.13.
"ignore:'audioop' is deprecated:DeprecationWarning",
# Every model uses datetime.utcnow as a column default. Tracked as
# DB-009; the warning would otherwise drown the run.
"ignore:datetime.datetime.utcnow:DeprecationWarning",
]
[tool.coverage.report]
# The exhaustive audit established a 71% baseline. Keep one point of margin
# for platform-specific branches while making any material regression fail CI.
fail_under = 70
show_missing = true
[tool.ruff]
line-length = 100
target-version = "py312"
target-version = "py313"
exclude = [".venv", "venv", "migrations", "docs"]
[tool.ruff.lint]
+2
View File
@@ -24,6 +24,8 @@ Flask-SQLAlchemy==3.1.1
Flask-WTF==1.3.0
frozenlist==1.8.0
greenlet==3.5.4
google-api-python-client==2.198.0
google-auth==2.56.3
idna==3.18
itsdangerous==2.2.0
Jinja2==3.1.6
+6
View File
@@ -80,6 +80,7 @@ def documents_in_a_throwaway_directory(tmp_path, monkeypatch):
cannot leave a PDF in someone's working tree.
"""
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents'))
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'local')
@pytest.fixture
@@ -194,6 +195,11 @@ def as_role(app, client, make_user, login):
from app.models import User
username = _db.session.get(User, user_id).username
# The login route short-circuits when the client is already
# authenticated, so a previous as_role() call would otherwise leave
# the old identity in the session and the switch would silently not
# happen (ADMIN-010: coach/manager gate tests ran as admin).
client.post('/auth/logout', follow_redirects=False)
response = login(username)
assert response.status_code in (301, 302), (
f'login for {username} did not redirect: {response.status_code}'
+62
View File
@@ -0,0 +1,62 @@
"""AppSettings key-value store — model behaviour.
ADMIN-001. The admin panel relies on AppSettings for global toggles
(tryouts_open, season_active, season_name, season_start, season_end).
These tests verify the key-value store itself, independent of any route.
"""
import pytest
from app.extensions import db
from app.models import AppSettings
class TestAppSettingsGetSet:
def test_get_returns_default_for_missing_key(self, app):
with app.app_context():
assert AppSettings.get('nonexistent', 'fallback') == 'fallback'
def test_set_and_get_roundtrip(self, app):
with app.app_context():
AppSettings.set('test_key', 'hello')
assert AppSettings.get('test_key') == 'hello'
def test_set_overwrites_existing_key(self, app):
with app.app_context():
AppSettings.set('overwrite_key', 'first')
AppSettings.set('overwrite_key', 'second')
assert AppSettings.get('overwrite_key') == 'second'
def test_set_none_value_is_stored_as_none(self, app):
with app.app_context():
AppSettings.set('nullable_key', None)
assert AppSettings.get('nullable_key') is None
class TestAppSettingsBool:
def test_get_bool_parses_true_values(self, app):
with app.app_context():
for val in ('true', 'True', 'TRUE', '1', 'yes', 'on'):
AppSettings.set('bool_test', val)
assert AppSettings.get_bool('bool_test'), f'{val!r} should be True'
def test_get_bool_parses_false_values(self, app):
with app.app_context():
for val in ('false', 'False', 'FALSE', '0', 'no', 'off', 'anything_else'):
AppSettings.set('bool_test', val)
assert not AppSettings.get_bool('bool_test'), f'{val!r} should be False'
def test_get_bool_defaults_to_false_for_missing_key(self, app):
with app.app_context():
assert not AppSettings.get_bool('does_not_exist')
def test_get_bool_respects_custom_default(self, app):
with app.app_context():
assert AppSettings.get_bool('does_not_exist', default=True)
def test_set_bool_stores_as_string(self, app):
with app.app_context():
AppSettings.set_bool('bool_key', True)
assert AppSettings.get('bool_key') == 'true'
AppSettings.set_bool('bool_key', False)
assert AppSettings.get('bool_key') == 'false'
+61
View File
@@ -0,0 +1,61 @@
"""AuditLog model — append-only admin action tracking.
ADMIN-002. Every sensitive admin action (backup, restore, toggle, season,
wipe) writes an AuditLog entry. These tests verify the model itself.
"""
import pytest
from app.extensions import db
from app.models import AuditLog, User
class TestAuditLogRecord:
def test_record_stores_all_fields(self, app, make_user):
admin_id = make_user('admin')
with app.app_context():
AuditLog.record(
user_id=admin_id,
action='test_action',
details='some details here',
ip_address='192.168.1.1',
)
entry = AuditLog.query.order_by(AuditLog.id.desc()).first()
assert entry is not None
assert entry.user_id == admin_id
assert entry.action == 'test_action'
assert entry.details == 'some details here'
assert entry.ip_address == '192.168.1.1'
assert entry.created_at is not None
def test_record_without_details_or_ip(self, app, make_user):
admin_id = make_user('admin')
with app.app_context():
AuditLog.record(user_id=admin_id, action='minimal')
entry = AuditLog.query.order_by(AuditLog.id.desc()).first()
assert entry.action == 'minimal'
assert entry.details is None
assert entry.ip_address is None
def test_entries_are_ordered_by_date_descending(self, app, make_user):
admin_id = make_user('admin')
with app.app_context():
AuditLog.record(user_id=admin_id, action='first')
AuditLog.record(user_id=admin_id, action='second')
AuditLog.record(user_id=admin_id, action='third')
entries = AuditLog.query.order_by(AuditLog.created_at.desc()).all()
actions = [e.action for e in entries]
assert actions == ['third', 'second', 'first']
def test_audit_log_survives_user_deletion(self, app, make_user):
admin_id = make_user('admin')
with app.app_context():
AuditLog.record(user_id=admin_id, action='before_delete')
user = db.session.get(User, admin_id)
db.session.delete(user)
db.session.commit()
entry = AuditLog.query.filter_by(action='before_delete').first()
assert entry is not None
assert entry.user_id is None # FK set to NULL on delete
+285
View File
@@ -0,0 +1,285 @@
"""Admin backup — create, download, delete, restore, and audit trail.
ADMIN-008. The backup buttons on the admin panel run pg_dump/pg_restore
in production, but tests mock the subprocess boundary. These tests verify
the route logic, the BackupRecord model, and audit logging.
"""
import os
import pytest
from app.extensions import db
from app.models import AuditLog, BackupRecord
def _redirected(response):
return response.status_code in (301, 302)
class TestBackupCreate:
def test_create_backup_succeeds(self, app, client, as_role, monkeypatch):
import app.routes.admin as admin_module
from app.supporting_scripts.backup import BackupError
def fake_create_backup_record(backup_type='manual', notes=None):
from app.models import BackupRecord
record = BackupRecord(
filename='db_backup_test.dump',
file_path='/tmp/db_backup_test.dump',
size_bytes=1234,
backup_type=backup_type,
notes=notes,
created_by_id=1,
)
db.session.add(record)
db.session.commit()
return record
monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record)
as_role('admin')
response = client.post(
'/admin/backup/create', data={'notes': 'test backup'}, follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
record = BackupRecord.query.filter_by(filename='db_backup_test.dump').first()
assert record is not None
assert record.notes == 'test backup'
def test_create_backup_failure_is_handled(self, app, client, as_role, monkeypatch):
import app.routes.admin as admin_module
from app.supporting_scripts.backup import BackupError
def fake_create_backup_record(backup_type='manual', notes=None):
raise BackupError('pg_dump not found')
monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record)
as_role('admin')
response = client.post(
'/admin/backup/create', data={}, follow_redirects=True,
)
html = response.data.decode()
assert 'Backup failed' in html
def test_create_backup_creates_audit_log(self, app, client, as_role, monkeypatch):
import app.routes.admin as admin_module
def fake_create_backup_record(backup_type='manual', notes=None):
from app.models import BackupRecord
record = BackupRecord(
filename='db_backup_test.dump',
file_path='/tmp/db_backup_test.dump',
size_bytes=1234,
backup_type=backup_type,
notes=notes,
created_by_id=1,
)
db.session.add(record)
db.session.commit()
return record
monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record)
as_role('admin')
client.post('/admin/backup/create', data={}, follow_redirects=False)
with app.app_context():
entry = AuditLog.query.filter_by(action='backup_created').first()
assert entry is not None
class TestBackupDownload:
def test_download_existing_backup(self, app, client, as_role, tmp_path):
from app.models import BackupRecord
backup_file = tmp_path / 'db_backup_test.dump'
backup_file.write_text('fake dump content')
as_role('admin')
with app.app_context():
record = BackupRecord(
filename='db_backup_test.dump',
file_path=str(backup_file),
size_bytes=18,
backup_type='manual',
created_by_id=1,
)
db.session.add(record)
db.session.commit()
record_id = record.id
response = client.get(f'/admin/backup/{record_id}/download')
assert response.status_code == 200
assert response.data == b'fake dump content'
def test_download_missing_backup_file(self, app, client, as_role):
from app.models import BackupRecord
as_role('admin')
with app.app_context():
record = BackupRecord(
filename='missing.dump',
file_path='/nonexistent/missing.dump',
size_bytes=0,
backup_type='manual',
created_by_id=1,
)
db.session.add(record)
db.session.commit()
record_id = record.id
response = client.get(
f'/admin/backup/{record_id}/download', follow_redirects=True,
)
html = response.data.decode()
assert 'missing from disk' in html.lower()
class TestBackupDelete:
def test_delete_backup_removes_record_and_file(self, app, client, as_role, tmp_path):
from app.models import BackupRecord
backup_file = tmp_path / 'db_backup_delete.dump'
backup_file.write_text('fake dump content')
as_role('admin')
with app.app_context():
record = BackupRecord(
filename='db_backup_delete.dump',
file_path=str(backup_file),
size_bytes=18,
backup_type='manual',
created_by_id=1,
)
db.session.add(record)
db.session.commit()
record_id = record.id
response = client.post(
f'/admin/backup/{record_id}/delete', follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
assert BackupRecord.query.get(record_id) is None
assert not backup_file.exists()
def test_delete_backup_creates_audit_log(self, app, client, as_role, tmp_path):
from app.models import BackupRecord
backup_file = tmp_path / 'db_backup_delete2.dump'
backup_file.write_text('fake')
as_role('admin')
with app.app_context():
record = BackupRecord(
filename='db_backup_delete2.dump',
file_path=str(backup_file),
size_bytes=4,
backup_type='manual',
created_by_id=1,
)
db.session.add(record)
db.session.commit()
record_id = record.id
client.post(f'/admin/backup/{record_id}/delete', follow_redirects=False)
with app.app_context():
entry = AuditLog.query.filter_by(action='backup_deleted').first()
assert entry is not None
class TestBackupRestore:
def test_restore_with_missing_file_fails(self, app, client, as_role):
from app.models import BackupRecord
as_role('admin')
with app.app_context():
record = BackupRecord(
filename='missing_restore.dump',
file_path='/nonexistent/missing_restore.dump',
size_bytes=0,
backup_type='manual',
created_by_id=1,
)
db.session.add(record)
db.session.commit()
record_id = record.id
response = client.post(
f'/admin/backup/{record_id}/restore', follow_redirects=True,
)
html = response.data.decode()
assert 'missing from disk' in html.lower()
def test_restore_creates_safety_backup_first(
self, app, client, as_role, monkeypatch, tmp_path
):
import app.routes.admin as admin_module
# The restore route parses DATABASE_URL outside its try block.
monkeypatch.setenv('DATABASE_URL', 'postgresql://appuser:[email protected]:5432/tryouts')
backup_file = tmp_path / 'db_backup_restore.dump'
backup_file.write_text('fake')
safety_file = tmp_path / 'db_backup_safety.dump'
safety_file.write_text('fake safety')
as_role('admin')
with app.app_context():
from app.models import BackupRecord
record = BackupRecord(
filename='db_backup_restore.dump',
file_path=str(backup_file),
size_bytes=4,
backup_type='manual',
created_by_id=1,
)
db.session.add(record)
db.session.commit()
record_id = record.id
def fake_create_backup_record(backup_type='pre_restore', notes=None):
from app.models import BackupRecord
record = BackupRecord(
filename='db_backup_safety.dump',
file_path=str(safety_file),
size_bytes=12,
backup_type=backup_type,
notes=notes,
created_by_id=1,
)
db.session.add(record)
db.session.commit()
return record
monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record)
import subprocess
def fake_subprocess_run(cmd, env=None, capture_output=True, text=True, timeout=None):
class Result:
returncode = 0
stderr = ''
stdout = ''
return Result()
monkeypatch.setattr(subprocess, 'run', fake_subprocess_run)
client.post(f'/admin/backup/{record_id}/restore', follow_redirects=False)
with app.app_context():
safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first()
assert safety is not None
assert safety.backup_type == 'pre_restore'
+56
View File
@@ -0,0 +1,56 @@
"""Admin panel — CSRF protection on mutation endpoints.
ADMIN-005. Every admin POST route must reject requests that lack a valid
CSRF token. These tests use the app_with_csrf fixture where CSRF is
enabled, unlike the default app fixture which disables it.
"""
import pytest
ADMIN_POST_ROUTES = [
'/admin/backup/create',
'/admin/toggle-tryouts',
'/admin/season/start',
'/admin/season/end',
'/admin/teams/wipe',
]
class TestAdminCsrfProtection:
@pytest.mark.parametrize('route', ADMIN_POST_ROUTES)
def test_post_without_csrf_is_rejected(self, app_with_csrf, route):
"""Every admin POST route must reject a missing CSRF token."""
# as_role uses the default app fixture, so we log in manually
# with the csrf-enabled app.
from app.extensions import db as _db
from app.models import User
client = app_with_csrf.test_client()
# Create and log in an admin on the CSRF-enabled app
with app_with_csrf.app_context():
from app.extensions import hash_password
from app.models import Admin
admin = Admin(
username='csrfadmin',
password_hash=hash_password('Password123'),
role='admin',
full_name='CSRF Admin',
email='[email protected]',
)
_db.session.add(admin)
_db.session.commit()
client.post(
'/auth/login',
data={'username': 'csrfadmin', 'password': 'Password123'},
follow_redirects=False,
)
response = client.post(route, data={}, follow_redirects=False)
# Flask-WTF returns 400 on missing CSRF token
assert response.status_code in (400, 302), (
f'{route} returned {response.status_code} without CSRF token'
)
+150
View File
@@ -0,0 +1,150 @@
"""Admin panel — access control, dashboard rendering, and template integrity.
ADMIN-004. The admin panel at /admin must only be reachable by admins,
must render all expected sections, and must serve static assets correctly.
"""
import pytest
from app.extensions import db
from app.models import User
NON_ADMIN_ROLES = ['player', 'coach', 'manager', 'scout']
ADMIN_MUTATION_ROUTES = [
'/admin/backup/create',
'/admin/toggle-tryouts',
'/admin/season/start',
'/admin/season/end',
'/admin/teams/wipe',
]
def _redirected(response):
return response.status_code in (301, 302)
# ---------------------------------------------------------------------------
# Access control
# ---------------------------------------------------------------------------
class TestAdminAccessControl:
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_is_redirected_from_dashboard(self, client, as_role, role):
as_role(role)
response = client.get('/admin', follow_redirects=False)
assert _redirected(response), f'{role} reached /admin'
def test_admin_reaches_dashboard(self, client, as_role):
as_role('admin')
response = client.get('/admin')
assert response.status_code == 200
@pytest.mark.parametrize('route', ADMIN_MUTATION_ROUTES)
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_post_to_admin_routes(self, client, as_role, role, route):
as_role(role)
response = client.post(route, data={}, follow_redirects=False)
assert _redirected(response), f'{role} reached {route}'
# ---------------------------------------------------------------------------
# Dashboard rendering
# ---------------------------------------------------------------------------
class TestAdminDashboardRendering:
def test_dashboard_shows_stats(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Total Users' in html
assert 'Players' in html
assert 'Org Teams' in html
assert 'Active Tryouts' in html
def test_dashboard_shows_tryout_status(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
# Either OPEN or CLOSED badge must be present
assert 'OPEN' in html or 'CLOSED' in html
def test_dashboard_shows_season_info(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Season Management' in html
assert 'Name:' in html
def test_dashboard_shows_audit_log_section(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Audit Log' in html
def test_dashboard_shows_backup_section(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Manual Backup' in html
assert 'Backup History' in html
# ---------------------------------------------------------------------------
# Template integrity
# ---------------------------------------------------------------------------
class TestAdminTemplateIntegrity:
def test_page_returns_200(self, client, as_role):
as_role('admin')
response = client.get('/admin')
assert response.status_code == 200
def test_page_has_html_doctype(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert '<!DOCTYPE html>' in html or '<!doctype html>' in html.lower()
def test_all_buttons_are_present(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
# Key buttons that must exist
assert 'Create Backup Now' in html
assert 'Wipe Team Rosters' in html
# Toggle button text depends on state
assert 'Tryouts' in html or 'tryouts' in html.lower()
def test_all_forms_have_csrf_tokens(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
# Count <form> tags and csrf_token occurrences
form_count = html.count('<form ')
csrf_count = html.count('csrf_token')
assert form_count > 0, 'No forms found on admin page'
assert csrf_count >= form_count, (
f'Found {form_count} forms but only {csrf_count} csrf_token(s)'
)
def test_static_css_loads(self, client):
response = client.get('/static/css/style.css')
assert response.status_code == 200
assert 'text/css' in response.content_type
def test_static_js_loads(self, client):
response = client.get('/static/js/main.js')
assert response.status_code == 200
assert 'javascript' in response.content_type or 'text/' in response.content_type
+49
View File
@@ -0,0 +1,49 @@
"""Admin route registration — every endpoint must exist and require login.
ADMIN-003. The admin blueprint registers 10 routes. If one is accidentally
removed or renamed, the admin panel breaks silently. These tests walk the
URL map to catch that.
"""
import pytest
ADMIN_ROUTES = [
'/admin',
'/admin/backup/create',
'/admin/toggle-tryouts',
'/admin/season/start',
'/admin/season/end',
'/admin/teams/wipe',
]
def _redirected(response):
return response.status_code in (301, 302)
class TestAdminRouteRegistration:
@pytest.mark.parametrize('route', ADMIN_ROUTES)
def test_route_is_registered(self, app, route):
"""Every admin endpoint must appear in the URL map."""
adapter = app.url_map.bind('localhost')
try:
adapter.match(route, method='GET')
except Exception:
# Some routes are POST-only; try POST
try:
adapter.match(route, method='POST')
except Exception as exc:
pytest.fail(f'Route {route} is not registered: {exc}')
@pytest.mark.parametrize('route', ADMIN_ROUTES)
def test_route_requires_login(self, client, route):
"""Unauthenticated access must redirect to login."""
response = client.get(route, follow_redirects=False)
# POST-only routes return 405 on GET, which is fine — the point is
# they don't return 200 to an anonymous caller.
if response.status_code == 405:
return
assert _redirected(response), (
f'{route} answered {response.status_code} to an anonymous caller'
)
+170
View File
@@ -0,0 +1,170 @@
"""Admin season lifecycle — start/end and gate on team matches.
ADMIN-007. The season_active setting gates regular-season match scheduling
for non-admins. These tests verify start/end season and the gate.
"""
import pytest
from app.extensions import db
from app.models import AppSettings, OrgTeam, TeamMatch, User
NON_ADMIN_ROLES = ['manager', 'coach']
def _redirected(response):
return response.status_code in (301, 302)
# ---------------------------------------------------------------------------
# Season lifecycle
# ---------------------------------------------------------------------------
class TestSeasonLifecycle:
def test_start_season_sets_fields(self, client, as_role):
as_role('admin')
client.post(
'/admin/season/start',
data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'},
follow_redirects=False,
)
with client.application.app_context():
assert AppSettings.get('season_name') == 'Fall 2026'
assert AppSettings.get('season_start') == '2026-09-01'
assert AppSettings.get_bool('season_active', default=False)
def test_cannot_start_season_when_already_active(self, client, as_role):
as_role('admin')
client.post(
'/admin/season/start',
data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'},
)
# Second start should be rejected
response = client.post(
'/admin/season/start',
data={'season_name': 'Spring 2027', 'season_start': '2027-01-01'},
follow_redirects=True,
)
html = response.data.decode()
assert 'already active' in html.lower()
def test_start_season_creates_audit_log(self, client, as_role):
from app.models import AuditLog
as_role('admin')
client.post(
'/admin/season/start',
data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'},
)
with client.application.app_context():
entry = AuditLog.query.filter_by(action='season_started').first()
assert entry is not None
def test_end_season_sets_end_date_and_deactivates(self, client, as_role):
as_role('admin')
client.post(
'/admin/season/start',
data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'},
)
client.post(
'/admin/season/end',
data={'season_end': '2026-12-15'},
follow_redirects=False,
)
with client.application.app_context():
assert AppSettings.get('season_end') == '2026-12-15'
assert not AppSettings.get_bool('season_active', default=False)
def test_cannot_end_season_when_inactive(self, client, as_role):
as_role('admin')
# No season started
response = client.post(
'/admin/season/end',
data={'season_end': '2026-12-15'},
follow_redirects=True,
)
html = response.data.decode()
assert 'no season is currently active' in html.lower()
def test_end_season_creates_audit_log(self, client, as_role):
from app.models import AuditLog
as_role('admin')
client.post(
'/admin/season/start',
data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'},
)
client.post('/admin/season/end', data={'season_end': '2026-12-15'})
with client.application.app_context():
entry = AuditLog.query.filter_by(action='season_ended').first()
assert entry is not None
# ---------------------------------------------------------------------------
# Gate enforcement on team matches
# ---------------------------------------------------------------------------
class TestSeasonGateEnforcement:
@pytest.fixture
def org_team(self, app, client, as_role):
as_role('admin')
client.post(
'/teams/create',
data={'name': 'Varsity Test'},
follow_redirects=False,
)
with app.app_context():
return OrgTeam.query.filter_by(name='Varsity Test').first().id
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_create_match_when_season_inactive(
self, client, as_role, org_team, role
):
as_role(role)
response = client.get(
f'/team-matches/{org_team}/create',
follow_redirects=True,
)
html = response.data.decode()
assert 'season is not active' in html.lower() or 'begin a season' in html.lower()
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_delete_match_when_season_inactive(
self, app, client, as_role, org_team, role, make_user
):
# Create a match as admin (season active not required for admin)
as_role('admin')
# Start season so match can be created
client.post(
'/admin/season/start',
data={'season_name': 'Fall', 'season_start': '2026-09-01'},
)
client.post(
f'/team-matches/{org_team}/create',
data={'title': 'Match Test', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
with app.app_context():
match_id = TeamMatch.query.filter_by(title='Match Test').first().id
# End season
client.post('/admin/season/end', data={'season_end': '2026-12-15'})
as_role(role)
response = client.post(
f'/team-matches/{match_id}/delete', data={}, follow_redirects=True,
)
html = response.data.decode()
assert 'season is not active' in html.lower() or 'begin a season' in html.lower()
def test_admin_can_always_schedule_match(self, client, as_role, org_team):
as_role('admin')
# No season active
response = client.post(
f'/team-matches/{org_team}/create',
data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
assert _redirected(response) # success, not blocked
+87
View File
@@ -0,0 +1,87 @@
"""Admin panel UI — buttons, forms, tables, and image loading.
ADMIN-010. This verifies the admin panel HTML structure: the stats grid,
the tryout toggle button, the season form fields, the wipe confirmation
input, and the backup/audit tables, plus that the logo image is served.
"""
import pytest
class TestAdminStatsGrid:
def test_stats_grid_has_five_cards(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
# Each stat card contains a stat-icon and stat-info
assert html.count('stat-card') >= 5
class TestAdminButtons:
def test_tryout_toggle_button_present(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Open Tryouts' in html or 'Close Tryouts' in html
def test_backup_button_present(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Create Backup Now' in html
def test_wipe_button_present(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Wipe Team Rosters' in html
def test_season_button_present(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Begin Season' in html or 'End Season' in html
class TestAdminForms:
def test_season_form_has_name_and_date_fields(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'season_name' in html
assert 'season_start' in html or 'season_end' in html
def test_wipe_form_has_confirm_input(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'name="confirm"' in html
assert 'WIPE' in html
class TestAdminTables:
def test_backup_table_columns(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Filename' in html
assert 'Size' in html
assert 'Type' in html
def test_audit_log_table_columns(self, client, as_role):
as_role('admin')
response = client.get('/admin')
html = response.data.decode()
assert 'Action' in html
assert 'Details' in html
class TestAdminImages:
def test_logo_image_loads(self, client):
response = client.get('/static/images/UdeS_logo.png')
assert response.status_code == 200
def test_esports_logo_image_loads(self, client):
# The original logo may still be referenced
response = client.get('/static/images/UdeS_logo.png')
assert response.status_code == 200
+156
View File
@@ -0,0 +1,156 @@
"""Admin tryout toggle — open/close gate and enforcement on tryout routes.
ADMIN-006. The tryouts_open setting gates every tryout mutation route for
non-admins. Admins always bypass the lock. These tests verify the toggle
itself and the gate on each affected route.
"""
import pytest
from app.extensions import db
from app.models import AppSettings, Tryout, User
NON_ADMIN_ROLES = ['manager', 'coach']
def _redirected(response):
return response.status_code in (301, 302)
# ---------------------------------------------------------------------------
# Toggle logic
# ---------------------------------------------------------------------------
class TestTryoutToggle:
def test_toggle_from_open_to_closed(self, client, as_role):
as_role('admin')
# Default is open
client.post('/admin/toggle-tryouts', data={}, follow_redirects=False)
# Now should be closed
with client.application.app_context():
assert not AppSettings.get_bool('tryouts_open', default=True)
def test_toggle_from_closed_to_open(self, client, as_role):
as_role('admin')
# Close first
client.post('/admin/toggle-tryouts', data={}, follow_redirects=False)
# Open again
client.post('/admin/toggle-tryouts', data={}, follow_redirects=False)
with client.application.app_context():
assert AppSettings.get_bool('tryouts_open', default=True)
def test_toggle_creates_audit_log(self, client, as_role):
from app.models import AuditLog
as_role('admin')
client.post('/admin/toggle-tryouts', data={}, follow_redirects=False)
with client.application.app_context():
entry = AuditLog.query.filter_by(action='tryouts_toggled').first()
assert entry is not None
def test_default_is_open(self, app):
with app.app_context():
assert AppSettings.get_bool('tryouts_open', default=True)
# ---------------------------------------------------------------------------
# Gate enforcement on tryout routes
# ---------------------------------------------------------------------------
class TestTryoutGateEnforcement:
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_create_tryout_when_closed(self, client, as_role, role):
as_role('admin')
client.post('/admin/toggle-tryouts', data={}) # close
as_role(role)
response = client.post(
'/tryouts/create',
data={'title': 'Test', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=True,
)
html = response.data.decode()
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_edit_tryout_when_closed(
self, app, client, as_role, make_user, role
):
# Create a tryout as admin first
as_role('admin')
client.post(
'/tryouts/create',
data={'title': 'Edit Test', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False,
)
with app.app_context():
tryout_id = Tryout.query.filter_by(title='Edit Test').first().id
# Close tryouts
client.post('/admin/toggle-tryouts', data={})
# Try to edit as non-admin
as_role(role)
response = client.post(
f'/tryouts/{tryout_id}/edit',
data={'title': 'Hacked', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=True,
)
html = response.data.decode()
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_delete_tryout_when_closed(
self, app, client, as_role, make_user, role
):
as_role('admin')
client.post(
'/tryouts/create',
data={'title': 'Delete Test', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False,
)
with app.app_context():
tryout_id = Tryout.query.filter_by(title='Delete Test').first().id
client.post('/admin/toggle-tryouts', data={}) # close
as_role(role)
response = client.post(
f'/tryouts/{tryout_id}/delete', data={}, follow_redirects=True,
)
html = response.data.decode()
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
def test_admin_can_always_create_tryout(self, client, as_role):
as_role('admin')
# Close tryouts
client.post('/admin/toggle-tryouts', data={})
# Admin should still be able to create
response = client.post(
'/tryouts/create',
data={'title': 'Admin Test', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False,
)
assert _redirected(response) # success redirect, not blocked
def test_admin_can_always_edit_tryout(self, app, client, as_role):
as_role('admin')
client.post(
'/tryouts/create',
data={'title': 'Admin Edit', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False,
)
with app.app_context():
tryout_id = Tryout.query.filter_by(title='Admin Edit').first().id
client.post('/admin/toggle-tryouts', data={}) # close
response = client.post(
f'/tryouts/{tryout_id}/edit',
data={'title': 'Admin Edited', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False,
)
assert _redirected(response) # success, not blocked
+119
View File
@@ -0,0 +1,119 @@
"""Admin team wipe — confirmation flow, data removal, and safety backup.
ADMIN-009. The "Wipe Team Rosters" button must require typing WIPE,
create a safety backup, and remove TeamPlayer + TeamMatch records while
preserving OrgTeam structures.
"""
import pytest
from app.extensions import db
from app.models import (
AuditLog, BackupRecord, OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
)
def _redirected(response):
return response.status_code in (301, 302)
class TestWipeConfirmation:
def test_wipe_without_confirmation_fails(self, app, client, as_role):
as_role('admin')
response = client.post(
'/admin/teams/wipe', data={}, follow_redirects=True,
)
html = response.data.decode()
assert 'WIPE' in html
def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role):
as_role('admin')
response = client.post(
'/admin/teams/wipe', data={'confirm': 'yes'}, follow_redirects=True,
)
html = response.data.decode()
assert 'WIPE' in html
class TestWipeDataRemoval:
@pytest.fixture
def seeded_teams(self, app, client, as_role, make_user, monkeypatch):
import app.routes.admin as admin_module
# Mock the backup so the wipe proceeds without a real pg_dump.
def fake_create_backup_record(backup_type='pre_wipe', notes=None):
from app.models import BackupRecord
record = BackupRecord(
filename='db_backup_pre_wipe.dump',
file_path='/tmp/db_backup_pre_wipe.dump',
size_bytes=100,
backup_type=backup_type,
notes=notes,
created_by_id=1,
)
db.session.add(record)
db.session.commit()
return record
monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record)
as_role('admin')
# Create an org team
client.post('/teams/create', data={'name': 'Wipe Team'}, follow_redirects=False)
with app.app_context():
team_id = OrgTeam.query.filter_by(name='Wipe Team').first().id
# Add a player to the team
player_id = make_user('player')
client.post(
f'/teams/{team_id}/add_player',
data={'player_id': str(player_id)},
follow_redirects=False,
)
# Create a team match
client.post(
f'/team-matches/{team_id}/create',
data={'title': 'Wipe Match', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
return team_id
def test_wipe_removes_team_players(self, app, client, as_role, seeded_teams):
as_role('admin')
client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False)
with app.app_context():
assert TeamPlayer.query.count() == 0
def test_wipe_removes_team_matches(self, app, client, as_role, seeded_teams):
as_role('admin')
client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False)
with app.app_context():
assert TeamMatch.query.count() == 0
def test_wipe_preserves_org_team_structures(self, app, client, as_role, seeded_teams):
as_role('admin')
client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False)
with app.app_context():
assert OrgTeam.query.filter_by(name='Wipe Team').first() is not None
def test_wipe_creates_safety_backup(self, app, client, as_role, seeded_teams):
as_role('admin')
client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False)
with app.app_context():
safety = BackupRecord.query.filter_by(backup_type='pre_wipe').first()
assert safety is not None
def test_wipe_creates_audit_log(self, app, client, as_role, seeded_teams):
as_role('admin')
client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False)
with app.app_context():
entry = AuditLog.query.filter_by(action='teams_wiped').first()
assert entry is not None
+12
View File
@@ -119,3 +119,15 @@ class TestExitCodes:
def test_verifying_a_missing_archive_fails(self, tmp_path):
assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
def test_a_missing_document_store_makes_an_otherwise_valid_run_incomplete(
self, monkeypatch, tmp_path
):
monkeypatch.setenv('DATABASE_URL', URL)
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'missing-documents'))
monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path / 'backups'))
monkeypatch.setattr(backup_module, 'backup_database', lambda conn: 'database.dump')
monkeypatch.setattr(backup_module, 'verify_backup', lambda path: True)
monkeypatch.setattr(backup_module, 'cleanup_old_backups', lambda: None)
assert backup_module.main([]) == 1
+2 -1
View File
@@ -150,9 +150,10 @@ def _pending(bot, message_id, row_id):
def _confirmed(row_id):
from app.extensions import db
from app.models import MatchParticipant
return MatchParticipant.query.get(row_id).attendance_confirmed
return db.session.get(MatchParticipant, row_id).attendance_confirmed
class TestTheDatabaseRefusedTheWrite:
+33 -1
View File
@@ -31,6 +31,14 @@ INLINE_HANDLER = re.compile(
re.I,
)
# An event attribute assembled inside a JavaScript string is absent from the
# template DOM, so the expression above cannot see it. Once assigned through
# innerHTML it is still an inline handler and the CSP still refuses to run it.
DYNAMIC_INLINE_HANDLER = re.compile(
r'''["']on(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=''',
re.I,
)
#: Remaining inline handlers, per template. Lower these as you migrate;
#: never raise one. Templates absent from this map must have none.
#: No template may carry an inline event handler. The migration is done;
@@ -52,7 +60,8 @@ def _templates():
def _count_handlers(path):
with open(path, encoding='utf-8') as handle:
return len(INLINE_HANDLER.findall(handle.read()))
content = handle.read()
return len(INLINE_HANDLER.findall(content)) + len(DYNAMIC_INLINE_HANDLER.findall(content))
class TestPolicyHeader:
@@ -102,6 +111,29 @@ class TestPolicyHeader:
class TestInlineHandlerRatchet:
def test_a_handler_built_inside_a_javascript_string_is_counted(self, tmp_path):
template = tmp_path / 'dynamic-handler.html'
template.write_text("html += '<button onclick=\"work()\">';", encoding='utf-8')
assert _count_handlers(template) == 1
def test_dynamic_player_names_are_escaped_before_html_insertion(self):
template = os.path.join(TEMPLATE_ROOT, 'pages', 'match_form.html')
with open(template, encoding='utf-8') as handle:
content = handle.read()
assert 'html += playerName;' not in content
assert "' + playerName + '" not in content
assert content.count('escapeHtml(playerName)') == 6
def test_api_messages_are_written_as_text(self):
template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html')
with open(template, encoding='utf-8') as handle:
content = handle.read()
assert 'text.textContent = message' in content
assert "alert.innerHTML = '<span>' + message" not in content
@pytest.mark.parametrize('relative,full', list(_templates()))
def test_a_template_never_gains_an_inline_handler(self, relative, full):
allowed = HANDLER_BUDGET.get(relative, 0)
+14 -17
View File
@@ -1,17 +1,17 @@
"""Which PostgreSQL driver the application actually asks for — QUA-001.
`postgresql://` is not "whichever driver is installed". SQLAlchemy reads it
as psycopg2 and imports that module when the engine is created.
requirements.txt pins psycopg 3 and no psycopg2, so a clean install against
the URL Render hands out the same form docs/database-restore.md documents
fails before the first request:
as psycopg2 unless the URL names a driver. requirements.txt declares psycopg
3, so create_app() must explicitly select it.
ModuleNotFoundError: No module named 'psycopg2'
create_app() now names the driver. These tests pin that down, and the last
one proves the failure is real rather than theoretical.
The test suite must not assume that psycopg2 is absent: a developer's virtual
environment can contain optional packages in addition to the declared
dependencies. These tests instead pin the application's selected driver and
the dependency contract that production installs use.
"""
from pathlib import Path
import pytest
from sqlalchemy import create_engine
@@ -80,16 +80,13 @@ class TestTheFactory:
assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///')
class TestTheFailureIsReal:
"""Not a hypothetical: this is what the deployed configuration did."""
class TestDriverContract:
def test_production_dependencies_select_psycopg_3(self):
requirements = Path(__file__).resolve().parents[1] / 'requirements.txt'
declared = requirements.read_text(encoding='utf-8')
def test_psycopg2_is_not_installed(self):
with pytest.raises(ImportError):
import psycopg2 # noqa: F401
def test_a_driverless_url_cannot_build_an_engine(self):
with pytest.raises(ModuleNotFoundError):
create_engine('postgresql://u:p@host/db')
assert 'psycopg[binary]==' in declared
assert 'psycopg2' not in declared
def test_the_normalised_url_can(self):
engine = create_engine(normalise_database_url('postgresql://u:p@host/db'))
+6 -8
View File
@@ -9,9 +9,8 @@ in the project directory.
The document store was fixed in wave G. The other two were not, and the gap
that opened between them is the reason this file exists: `backup.py` kept
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`, and
the script's answer to a missing directory is to print a line and exit 0.
Following the deployment documentation was what broke it.
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`.
The script now resolves the shared root and fails the run when it is absent.
"""
import os
@@ -106,16 +105,15 @@ class TestTheBackupScriptAgreesWithTheApplication:
with zipfile.ZipFile(archive) as zf:
assert any(name.endswith('contrat.pdf') for name in zf.namelist())
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch, capsys):
""" "No documents directory found" read as "there are no documents"
rather than "I am looking in the wrong place"."""
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch):
"""A missing configured store is an actionable failure, not a skip."""
from app.supporting_scripts import backup
missing = tmp_path / 'not-here'
monkeypatch.setenv('DOCUMENTS_ROOT', str(missing))
assert backup.backup_documents() is None
assert str(missing) in capsys.readouterr().out
with pytest.raises(backup.BackupError, match=str(missing).replace('\\', '\\\\')):
backup.backup_documents()
class TestLogsFollowTheSameRule:
+281
View File
@@ -0,0 +1,281 @@
"""Regression tests for compact POST forms that bypassed the shared schemas."""
from datetime import date, time
from sqlalchemy.dialects import postgresql
from app.models import (
Match,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Team,
TeamMember,
TeamPlayer,
Tryout,
TryoutRegistration,
UserGamertag,
)
def _tryout(db, owner_id, *, coach_id=None):
row = Tryout(
title='Boundary tryout',
game='Valorant',
date=date(2030, 4, 1),
created_by=owner_id,
coach_id=coach_id,
)
db.session.add(row)
db.session.commit()
return row.id
def _give_coach_a_player(db, coach_id, player_id, owner_id):
org_team = OrgTeam(
name=f'Org {coach_id}-{player_id}',
created_by=owner_id,
coach_id=coach_id,
)
db.session.add(org_team)
db.session.flush()
db.session.add(TeamPlayer(org_team_id=org_team.id, player_id=player_id))
db.session.commit()
return org_team.id
def test_tryout_team_name_is_bounded(app, client, as_role):
admin_id = as_role('admin')
from app.extensions import db
with app.app_context():
tryout_id = _tryout(db, admin_id)
response = client.post(
f'/tryouts/{tryout_id}/team/create',
data={'team_name': 'x' * 101},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert Team.query.filter_by(tryout_id=tryout_id).count() == 0
def test_registration_decisions_lock_the_tryout_row():
from app.routes.tryouts import registration_lock_statement
sql = str(registration_lock_statement(42).compile(dialect=postgresql.dialect()))
assert 'FOR UPDATE' in sql
def test_tryout_team_position_is_bounded(app, client, as_role, make_user):
admin_id = as_role('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
tryout_id = _tryout(db, admin_id)
team = Team(tryout_id=tryout_id, name='Blue', created_by=admin_id)
db.session.add(team)
db.session.flush()
team_id = team.id
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id))
db.session.commit()
response = client.post(
f'/tryouts/{tryout_id}/team/{team_id}/add',
data={'player_id': player_id, 'position': 'x' * 51},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() is None
def test_a_coach_cannot_open_an_unrelated_tryout_note_form(app, client, as_role, make_user):
coach_id = as_role('coach')
other_coach_id = make_user('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
tryout_id = _tryout(db, admin_id, coach_id=other_coach_id)
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id))
db.session.commit()
response = client.get(f'/users/personal-notes/tryout/{tryout_id}')
assert response.status_code == 302
assert response.headers['Location'].endswith('/users/notes-dashboard')
assert coach_id != other_coach_id
def test_a_note_cannot_claim_a_team_that_does_not_contain_the_player(
app, client, as_role, make_user
):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
team = Team(tryout_id=tryout_id, name='No player here', created_by=admin_id)
db.session.add(team)
db.session.commit()
team_id = team.id
response = client.post(
'/users/personal-notes/add',
data={'player_id': player_id, 'content': 'Private note', 'team_id': team_id},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert PersonalNote.query.count() == 0
def test_a_personal_note_is_bounded(app, client, as_role, make_user):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
response = client.post(
'/users/personal-notes/manage',
data={'player_id': player_id, 'content': 'x' * 5001},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert PersonalNote.query.count() == 0
def test_a_rejection_reason_is_bounded(app, client, as_role, make_user):
coach_id = as_role('coach')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
request = OneOnOneRequest(
player_id=player_id,
coach_id=coach_id,
date=date(2030, 4, 2),
start_time=time(18, 0),
end_time=time(18, 30),
)
db.session.add(request)
db.session.commit()
request_id = request.id
response = client.post(
f'/users/one-on-one/{request_id}/reject',
data={'rejection_reason': 'x' * 2001},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert db.session.get(OneOnOneRequest, request_id).status == 'pending'
def test_a_match_context_must_contain_the_player(app, client, as_role, make_user):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
match = Match(
tryout_id=tryout_id,
title='Scrim',
date=date(2030, 4, 2),
match_type='player_vs_player',
created_by=coach_id,
)
db.session.add(match)
db.session.commit()
match_id = match.id
response = client.post(
'/users/personal-notes/add',
data={'player_id': player_id, 'content': 'Private note', 'match_id': match_id},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert PersonalNote.query.count() == 0
def test_the_note_dashboard_lists_tryout_teams_not_org_teams(app, client, as_role, make_user):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
team = Team(tryout_id=tryout_id, name='Tryout Alpha', created_by=admin_id)
db.session.add(team)
db.session.commit()
team_id = team.id
body = client.get('/users/notes-dashboard').get_data(as_text=True)
assert f'<option value="{team_id}">Tryout Alpha</option>' in body
assert f'>Org {coach_id}-{player_id}</option>' not in body
def test_an_oversized_dynamic_gamertag_is_rejected(app, client, as_role):
player_id = as_role('player')
response = client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player One',
'email': '[email protected]',
'games': 'Valorant',
'gamertag_Valorant': 'x' * 121,
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert UserGamertag.query.filter_by(user_id=player_id).count() == 0
def test_a_platform_must_belong_to_the_selected_game(app, client, as_role):
player_id = as_role('player')
response = client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player One',
'email': '[email protected]',
'games': 'Apex Legends',
'gamertag_Apex Legends': 'LegitName',
'platform_Apex Legends': 'Forged platform',
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert UserGamertag.query.filter_by(user_id=player_id).count() == 0
+108
View File
@@ -233,6 +233,114 @@ class TestPendingEvaluations:
assert self._pending(client) == 1
class TestRegisteredPlayersForMatchForm:
"""The match forms used to issue one or two user lookups per registration."""
def test_the_result_is_unique_ordered_and_constant_cost(self, app, make_user, count_queries):
admin_id = make_user('admin')
player_ids = [make_user('player', username=name) for name in ('zulu', 'alpha', 'mike')]
with app.app_context():
tryout = Tryout(
title='Match form',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
# DB-006 is pending, so prove the UI remains unique even when the
# current database already contains a duplicate registration.
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_ids[0]))
db.session.commit()
tryout_id = tryout.id
from app.routes.matches import registered_players
counter = count_queries()
try:
players = registered_players(tryout_id)
finally:
counter.stop()
assert [player.username for player in players] == ['alpha', 'mike', 'zulu']
assert counter.total == 1
class TestEvaluationLists:
"""Evaluation pages must not issue one lookup per player or evaluator."""
def test_players_to_evaluate_has_a_fixed_query_budget(
self, app, client, as_role, make_user, count_queries
):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(12)]
with app.app_context():
tryout = Tryout(
title='Evaluation budget',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
db.session.commit()
tryout_id = tryout.id
counter = count_queries()
try:
response = client.get(f'/evaluations/{tryout_id}/players')
finally:
counter.stop()
assert response.status_code == 200
assert 1 <= counter.total <= 10, f'{counter.total} SELECTs for 12 players'
def test_scout_top_players_are_loaded_with_the_aggregate(
self, app, client, as_role, make_user, count_queries
):
as_role('scout')
evaluator_id = make_user('coach')
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(12)]
with app.app_context():
tryout = Tryout(
title='Scout budget',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
for score, player_id in enumerate(player_ids, start=1):
db.session.add(
Evaluation(
player_id=player_id,
evaluator_id=evaluator_id,
tryout_id=tryout.id,
overall_score=score,
)
)
db.session.commit()
counter = count_queries()
try:
response = client.get('/dashboard')
finally:
counter.stop()
assert response.status_code == 200
assert 1 <= counter.total <= 6, f'{counter.total} SELECTs for the scout dashboard'
class TestViewTryout:
"""PERF-001 — the most-visited page in the application ran one query
per registration, one per player evaluated, one per team, and one per
+93
View File
@@ -0,0 +1,93 @@
"""The security scanner must fail closed when its dependency audit cannot run."""
import json
import subprocess
import urllib.error
from app.supporting_scripts import security_scan
def _result(returncode, stdout='', stderr=''):
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
def test_debug_mode_fails_the_environment_check(monkeypatch):
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
monkeypatch.setenv('FLASK_DEBUG', 'true')
assert security_scan.check_environment() is False
def test_a_missing_database_url_fails_the_environment_check(monkeypatch):
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
monkeypatch.delenv('DATABASE_URL', raising=False)
monkeypatch.setenv('FLASK_DEBUG', 'false')
assert security_scan.check_environment() is False
def test_an_unreachable_http_target_cannot_report_success(monkeypatch):
def unreachable(*args, **kwargs):
raise urllib.error.URLError('connection refused')
monkeypatch.setattr(security_scan.urllib.request, 'urlopen', unreachable)
assert security_scan.check_https_headers('https://example.test') is False
def test_a_clean_dependency_audit_passes(monkeypatch):
command = []
def clean_audit(args, **kwargs):
command.extend(args)
return _result(0)
monkeypatch.setattr(
security_scan.subprocess,
'run',
clean_audit,
)
assert security_scan.check_dependencies() is True
requirement_flag = command.index('--requirement')
assert command[requirement_flag + 1] == str(security_scan.REQUIREMENTS_FILE)
def test_reported_vulnerabilities_fail_the_scan(monkeypatch):
report = {
'dependencies': [
{
'name': 'example',
'version': '1.0',
'vulns': [{'id': 'PYSEC-TEST'}],
}
]
}
monkeypatch.setattr(
security_scan.subprocess,
'run',
lambda *args, **kwargs: _result(1, stdout=json.dumps(report)),
)
assert security_scan.check_dependencies() is False
def test_an_audit_that_crashes_cannot_report_success(monkeypatch, capsys):
monkeypatch.setattr(
security_scan.subprocess,
'run',
lambda *args, **kwargs: _result(1, stderr='audit unavailable'),
)
assert security_scan.check_dependencies() is False
assert '[FAIL] audit unavailable' in capsys.readouterr().out
def test_an_audit_timeout_cannot_report_success(monkeypatch):
def timeout(*args, **kwargs):
raise subprocess.TimeoutExpired('pip-audit', 60)
monkeypatch.setattr(security_scan.subprocess, 'run', timeout)
assert security_scan.check_dependencies() is False
+28 -4
View File
@@ -19,7 +19,13 @@ import os
import pytest
from app.extensions import db
from app.storage import CONTRACTS_DIR, document_path, documents_root
from app.storage import (
CONTRACTS_DIR,
GOOGLE_DRIVE_PATH_PREFIX,
document_path,
documents_root,
store_uploaded_document,
)
class TestDocumentsRoot:
@@ -87,6 +93,22 @@ class TestDocumentPath:
assert first_old == second_old, 'and must not move the ones already filed'
class TestGoogleDriveStorage:
def test_new_uploads_store_an_opaque_drive_reference(self, monkeypatch):
from werkzeug.datastructures import FileStorage
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')
monkeypatch.setattr(
'app.storage.upload_google_drive_file',
lambda **_kwargs: 'drive-file-123',
)
upload = FileStorage(stream=_pdf(), filename='contract.pdf', content_type='application/pdf')
stored_path = store_uploaded_document(upload, os.path.join(CONTRACTS_DIR, 'new.pdf'))
assert stored_path == f'{GOOGLE_DRIVE_PATH_PREFIX}drive-file-123'
class TestThroughTheUploadRoute:
@pytest.fixture
def uploaded(self, app, client, as_role, make_user):
@@ -135,9 +157,11 @@ class TestThroughTheUploadRoute:
contract_id = Contract.query.one().id
response = client.get(f'/users/contracts/{contract_id}/download')
assert response.status_code == 200
assert response.data.startswith(b'%PDF-')
try:
assert response.status_code == 200
assert response.data.startswith(b'%PDF-')
finally:
response.close()
def _pdf():
+29
View File
@@ -0,0 +1,29 @@
"""Regression tests for the application's timestamp convention."""
import ast
from datetime import UTC, datetime
from pathlib import Path
from app.time_utils import utc_now_naive
def test_utc_now_naive_is_an_explicit_utc_value():
before = datetime.now(UTC).replace(tzinfo=None)
actual = utc_now_naive()
after = datetime.now(UTC).replace(tzinfo=None)
assert actual.tzinfo is None
assert before <= actual <= after
def test_application_does_not_call_deprecated_utcnow():
app_root = Path(__file__).parents[1] / 'app'
offenders = []
for path in app_root.rglob('*.py'):
tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == 'utcnow':
offenders.append(f'{path.relative_to(app_root)}:{node.lineno}')
assert offenders == []