Author SHA1 Message Date
cedrick2711 c7b321a636 petit changement pour 429 error handler (temp fix)
CI - Security, Lint & Tests / validate (push) Failing after 59s
2026-08-27 09:25:24 -04:00
cedrick2711 90c00ad29b régler problème URL lol
CI - Security, Lint & Tests / validate (push) Failing after 1m2s
2026-08-26 22:46:24 -04:00
cedrick2711 bfb25f4d16 ajout d'un toggle pour permission d'upload des documents dans admin pannel
CI - Security, Lint & Tests / validate (push) Failing after 1m3s
ajout de compteur d'heure disponible
ajout du calendrier de dispo dans les profils publics
2026-08-26 22:33:24 -04:00
cedrick2711 820cccb25e changement vers google drive storage pour les contrats.
CI - Security, Lint & Tests / validate (push) Failing after 1m15s
Tentative d'obtenir un token de connection échoue
2026-08-26 20:52:40 -04:00
cedrick2711 38defc53a5 debut changement vers google drive
CI - Security, Lint & Tests / validate (push) Failing after 1m14s
2026-08-25 19:01:28 -04:00
cedrick2711 d18979a3e9 ajout de tests
CI - Security, Lint & Tests / validate (push) Failing after 56s
2026-08-25 15:53:46 -04:00
cedrick2711 1549fbaef3 fix merge conflict
CI - Security, Lint & Tests / validate (push) Failing after 54s
2026-08-25 14:03:24 -04:00
cedrick2711 c72ed9b0b1 Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-25 14:03:07 -04:00
cedrick2711 6232b77094 ajout d'un paneau admin 2026-08-25 13:29:22 -04:00
cedrick2711 a616c79663 regler probleme avec la creation d'equipe et 1 manager par tryout changer pour plusieurs
CI - Security, Lint & Tests / validate (push) Failing after 58s
2026-08-19 21:16:25 -04:00
cedrick2711 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
66 changed files with 3959 additions and 1859 deletions
+40
View File
@@ -0,0 +1,40 @@
"""Add Drive signed upload test."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
drive_test = (
b"\n def test_signed_upload_with_google_drive(\n"
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
b" ):\n"
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
b" monkeypatch.setattr(\n"
b" 'app.storage.upload_google_drive_file',\n"
b" lambda **_kwargs: 'drive-signed-456',\n"
b" )\n"
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status == 'signed'\n"
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
)
# Add before 'class TestTheHelper:'
marker = b'\nclass TestTheHelper:'
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + drive_test + t[pos:]
p.write_bytes(t)
print('Added Drive signed upload test')
else:
print('Could not find marker')
+48
View File
@@ -0,0 +1,48 @@
"""Add new tests to test_contract_uploads.py."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
marker = b'\n\nclass TestTheHelper:'
test = (
b"\n def test_a_header_only_pdf_is_refused(\n"
b" self, app, client, as_role, make_user, contract_for\n"
b" ):\n"
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status != 'signed'\n"
b" assert contract.signed_file_path is None\n\n"
)
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + test + t[pos:]
# 2. Header-only helper test at end of TestTheHelper
trailing = t.rstrip(b'\n')
idx = t.find(trailing) + len(trailing)
helper = (
b"\n def test_a_header_only_file_is_refused(self, app):\n"
b' """A stream starting with %PDF- but holding non-PDF data is\n'
b" rejected (the header check is not enough).\"\"\"\n"
b" from werkzeug.datastructures import FileStorage\n\n"
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
b" with app.test_request_context('/'):\n"
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
)
t = t[:idx] + helper + t[idx:]
p.write_bytes(t)
print('Added new tests')
+20 -9
View File
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# ============================================================================= # =============================================================================
# Optional — storage # Contract storage — Google Drive
# ============================================================================= # =============================================================================
# Where uploaded contracts live. Empty means `documents/` beside the # New contracts are uploaded to the owner's Google Drive. The OAuth client,
# application. Set it to a path OUTSIDE the deployment directory if you move # refresh token, and folder ID are secrets/configuration: do not commit them.
# to a release-directory layout, or a deployment will take the documents with # Use `local` only for isolated development and legacy-file maintenance.
# it (OPS-011, app/storage.py). DOCUMENT_STORAGE_BACKEND=google_drive
#
# IMPORTANT: the backup script reads this same variable. Before wave J it # Target folder in the owner's personal Drive. The application keeps every
# did not, and archived `./documents` regardless — so setting this here and # contract directly in this folder and stores only each Drive file ID in the
# nowhere else produced empty contract backups that still exited 0. # database.
GOOGLE_DRIVE_FOLDER_ID=
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
# refresh token here. See docs/deployment.md before enabling this in production.
GOOGLE_DRIVE_CLIENT_ID=
GOOGLE_DRIVE_CLIENT_SECRET=
GOOGLE_DRIVE_REFRESH_TOKEN=
# Local storage is retained only for historical rows and local test runs.
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
DOCUMENTS_ROOT= DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both # Where the log files go. Empty means `logs/` beside the application. Both
+3 -1
View File
@@ -411,6 +411,7 @@ def create_app(config=None):
from app.routes.teams import teams_bp from app.routes.teams import teams_bp
from app.routes.tryouts import tryouts_bp from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp from app.routes.users import users_bp
from app.routes.admin import admin_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp) app.register_blueprint(tryouts_bp)
@@ -420,6 +421,7 @@ def create_app(config=None):
app.register_blueprint(teams_bp) app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp) app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp) app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
# Register custom Jinja filters # Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br app.jinja_env.filters['nl2br'] = nl2br
@@ -723,4 +725,4 @@ def create_app(config=None):
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000 # own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This # while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
# module defines the factory; run.py starts it for development, wsgi.py for # module defines the factory; run.py starts it for development, wsgi.py for
# production (ARCH-007). # production (ARCH-007).
+1 -1
View File
@@ -22,7 +22,7 @@ babel = Babel()
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in # No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
# app.config, which create_app fills from the environment and defaults to # app.config, which create_app fills from the environment and defaults to
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift. # `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"]) limiter = Limiter(key_func=get_remote_address, default_limits=["1000 per day", "200 per hour"])
def hash_password(password): def hash_password(password):
+144
View File
@@ -0,0 +1,144 @@
"""Google Drive storage for contract PDFs.
The application uses an OAuth refresh token for the owner's personal Google
account. Tokens and client secrets come only from environment variables; the
database stores opaque file IDs, never a credential or a shareable Drive URL.
"""
import io
import os
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
class GoogleDriveStorageError(RuntimeError):
"""A configuration or API failure while storing a contract in Drive."""
def _setting(name):
value = os.getenv(name)
if not value:
raise GoogleDriveStorageError(
f'{name} must be configured for Google Drive document storage.'
)
return value
def _drive_service():
"""Build an authorized Drive client from the owner's refresh token."""
try:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
except ImportError as exc:
raise GoogleDriveStorageError(
'Google Drive dependencies are not installed. Install requirements.txt again.'
) from exc
credentials = Credentials(
token=None,
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
token_uri='https://oauth2.googleapis.com/token',
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
scopes=[GOOGLE_DRIVE_SCOPE],
)
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
def list_folder_files():
"""Return (file_id, name) tuples for every file in the configured folder.
Used by the backup script to enumerate Drive-stored contracts. Returns
only files directly in the folder (not nested inside sub-folders).
Returns:
list[tuple[str, str]]: (file_id, name) for each file found.
"""
try:
folder_id = _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
page_token = None
results = []
while True:
response = (
_drive_service()
.files()
.list(
q=f"'{folder_id}' in parents and trashed=false",
fields='nextPageToken, files(id, name)',
pageToken=page_token,
pageSize=1000,
)
.execute()
)
for f in response.get('files', []):
results.append((f['id'], f['name']))
page_token = response.get('nextPageToken')
if not page_token:
break
return results
except GoogleDriveStorageError:
raise
except Exception as exc:
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
def upload_file(*, stream, filename, mimetype):
"""Upload a PDF to the configured owner folder and return its Drive ID."""
try:
from googleapiclient.http import MediaIoBaseUpload
stream.seek(0)
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
response = (
_drive_service()
.files()
.create(
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
media_body=media,
fields='id',
)
.execute()
)
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
file_id = response.get('id')
if not file_id:
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
return file_id
def download_file(file_id):
"""Download a Drive file into memory for Flask's authenticated response."""
try:
from googleapiclient.http import MediaIoBaseDownload
destination = io.BytesIO()
downloader = MediaIoBaseDownload(
destination,
_drive_service().files().get_media(fileId=file_id),
)
complete = False
while not complete:
_status, complete = downloader.next_chunk()
return destination.getvalue()
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
def delete_file(file_id):
"""Permanently delete a Drive document when its contract record is deleted."""
try:
_drive_service().files().delete(fileId=file_id).execute()
except GoogleDriveStorageError:
raise
except Exception as exc: # Google client exceptions share no stable base class.
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+3
View File
@@ -94,3 +94,6 @@ from app.models.contract import Contract
from app.models.team_note import TeamNote from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
+10
View File
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True 'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
), ),
) )
tryout_managers = db.Table(
'tryout_managers',
db.Column(
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
),
db.Column(
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
+2 -2
View File
@@ -71,9 +71,9 @@ PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket L
TRN_URLS = { TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/profile/{username}', 'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}', 'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}', 'Apex Legends': 'https://tracker.gg/apex/profile/{platform_code}/{username}',
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}', 'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}', 'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}', 'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
+56
View File
@@ -0,0 +1,56 @@
"""Global application settings stored as key-value pairs in the database."""
from app.extensions import db
class AppSettings(db.Model):
"""Key-value store for global application settings.
Stores toggles and configuration that admins control through the
admin panel, such as whether tryouts are open, season state, etc.
"""
__tablename__ = 'app_settings'
key = db.Column(db.String(100), primary_key=True)
value = db.Column(db.Text, nullable=True)
# ------------------------------------------------------------------
# Well-known keys (documented here for discoverability)
# ------------------------------------------------------------------
# tryouts_open – "true" / "false" (default: "true")
# document_uploads_enabled – "true" / "false" (default: "true")
# season_active – "true" / "false" (default: "false")
# season_name – e.g. "Fall 2026"
# season_start – ISO date string
# season_end – ISO date string
@staticmethod
def get(key, default=None):
"""Return the value for *key*, or *default* if not set."""
row = db.session.get(AppSettings, key)
return row.value if row is not None else default
@staticmethod
def set(key, value):
"""Upsert a setting."""
row = db.session.get(AppSettings, key)
if row is None:
row = AppSettings(key=key, value=str(value) if value is not None else None)
db.session.add(row)
else:
row.value = str(value) if value is not None else None
db.session.commit()
@staticmethod
def get_bool(key, default=False):
"""Return a boolean setting."""
val = AppSettings.get(key)
if val is None:
return default
return val.lower() in ('true', '1', 'yes', 'on')
@staticmethod
def set_bool(key, value):
"""Store a boolean setting as 'true' / 'false'."""
AppSettings.set(key, 'true' if value else 'false')
+32
View File
@@ -0,0 +1,32 @@
"""Audit log for tracking sensitive administrative actions."""
from app.extensions import db
from app.time_utils import utc_now_naive
class AuditLog(db.Model):
"""Append-only log of critical admin actions for accountability."""
__tablename__ = 'audit_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
action = db.Column(db.String(100), nullable=False)
details = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=utc_now_naive)
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
@staticmethod
def record(user_id, action, details=None, ip_address=None):
"""Create a new audit log entry."""
entry = AuditLog(
user_id=user_id,
action=action,
details=details,
ip_address=ip_address,
)
db.session.add(entry)
db.session.commit()
return entry
+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')
+18 -3
View File
@@ -1,7 +1,7 @@
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
from app.extensions import db from app.extensions import db
from app.models._associations import tryout_coaches from app.models._associations import tryout_coaches, tryout_managers
from app.time_utils import utc_now_naive from app.time_utils import utc_now_naive
@@ -20,16 +20,17 @@ class Tryout(db.Model):
max_players = db.Column(db.Integer, nullable=True) max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
coach_id = db.Column( coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration ) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=utc_now_naive) created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts') coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts') coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', backref='tryout', lazy='dynamic') teams = db.relationship('Team', backref='tryout', lazy='dynamic')
@@ -47,3 +48,17 @@ class Tryout(db.Model):
if self.end_date is not None: if self.end_date is not None:
return self.end_date < today return self.end_date < today
return self.date < today return self.date < today
def get_managers(self):
"""Managers attached to this tryout, both legacy and many-to-many."""
manager_list = list(self.managers)
if not manager_list and self.manager:
return [self.manager]
return manager_list
def get_coaches(self):
"""Coaches attached to this tryout, both legacy and many-to-many."""
coach_list = list(self.coaches)
if not coach_list and self.coach:
return [self.coach]
return coach_list
+12 -2
View File
@@ -21,7 +21,11 @@ class Manager(User):
return True return True
def can_manage_this_tryout(self, tryout): def can_manage_this_tryout(self, tryout):
return tryout.created_by == self.id or tryout.manager_id == self.id return (
tryout.created_by == self.id
or tryout.manager_id == self.id
or any(m.id == self.id for m in tryout.managers)
)
def can_manage_this_org_team(self, org_team): def can_manage_this_org_team(self, org_team):
return True return True
@@ -32,7 +36,13 @@ class Manager(User):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return ( return (
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)) Tryout.query.filter(
or_(
Tryout.created_by == self.id,
Tryout.manager_id == self.id,
Tryout.managers.any(id=self.id),
)
)
.order_by(Tryout.date) .order_by(Tryout.date)
.all() .all()
) )
+396
View File
@@ -0,0 +1,396 @@
"""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),
'document_uploads_enabled': AppSettings.get_bool('document_uploads_enabled', default=True),
'season_active': AppSettings.get_bool('season_active', default=False),
'season_name': AppSettings.get('season_name') or 'Not set',
'season_start': AppSettings.get('season_start') or 'Not set',
'season_end': AppSettings.get('season_end') or 'Not set',
}
backups = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(20).all()
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()
return render_template(
'pages/admin.html',
stats=stats,
settings=settings,
backups=backups,
audit_logs=audit_logs,
)
# ---------------------------------------------------------------------------
# Backups
# ---------------------------------------------------------------------------
def _create_backup_record(backup_type='manual', notes=None):
"""Run a database backup and return a BackupRecord, or raise BackupError."""
create_backup_dir()
conn = parse_database_url(os.getenv('DATABASE_URL'))
file_path = backup_database(conn)
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
filename = os.path.basename(file_path)
record = BackupRecord(
filename=filename,
file_path=file_path,
size_bytes=size,
backup_type=backup_type,
notes=notes,
created_by_id=current_user.id,
)
db.session.add(record)
db.session.commit()
return record
@admin_bp.route('/backup/create', methods=['POST'])
@login_required
def create_backup():
"""Create a manual database backup."""
if not require_admin():
return redirect(url_for('main.dashboard'))
notes = request.form.get('notes', '').strip() or None
try:
record = _create_backup_record(backup_type='manual', notes=notes)
except BackupError as e:
flash(f'Backup failed: {e}', 'danger')
_log('backup_failed', f'Error: {e}')
return 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'))
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
@login_required
def toggle_document_uploads():
"""Enable or disable document uploads for non-admin users."""
if not require_admin():
return redirect(url_for('main.dashboard'))
current = AppSettings.get_bool('document_uploads_enabled', default=True)
new_value = not current
AppSettings.set_bool('document_uploads_enabled', new_value)
state = 'enabled' if new_value else 'disabled'
_log('document_uploads_toggled', f'Document uploads {state}')
flash(f'Document uploads are now {state}.', 'success')
return 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'))
+21
View File
@@ -20,6 +20,7 @@ from app.models import (
TeamMatch, TeamMatch,
TeamMatchParticipant, TeamMatchParticipant,
TeamPlayer, TeamPlayer,
AppSettings,
) )
from app.pagination import paginate from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
@@ -40,6 +41,17 @@ def can_manage_team_match(team):
return can_manage_org_team(current_user, team) return can_manage_org_team(current_user, team)
def season_locked():
"""Return True when the regular season is inactive for non-admins.
Admins bypass the lock. Coaches and managers may only schedule regular
season matches while the season is active.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('season_active', default=False)
@team_matches_bp.route('') @team_matches_bp.route('')
@login_required @login_required
def list_matches(): def list_matches():
@@ -108,6 +120,10 @@ def list_matches():
def create_match(team_id): def create_match(team_id):
"""Create a new regular-season team match.""" """Create a new regular-season team match."""
team = db.get_or_404(OrgTeam, 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): if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger') flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
@@ -260,9 +276,14 @@ def delete_match(match_id):
"""Delete a team match.""" """Delete a team match."""
team_match = db.get_or_404(TeamMatch, match_id) team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team team = team_match.org_team
if season_locked():
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): if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match) db.session.delete(team_match)
db.session.commit() db.session.commit()
flash(_('Match deleted successfully.'), 'success') flash(_('Match deleted successfully.'), 'success')
+67 -6
View File
@@ -30,6 +30,7 @@ from app.models import (
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
AppSettings,
) )
from app.time_utils import utc_now_naive from app.time_utils import utc_now_naive
from app.validators import ( from app.validators import (
@@ -49,9 +50,20 @@ def can_manage():
return isinstance(current_user, (Admin, Manager)) return isinstance(current_user, (Admin, Manager))
def tryouts_locked():
"""Return True when tryouts are globally closed to coaches/managers.
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('tryouts_open', default=True)
def tryout_form_payload(): def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005).""" """The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids',), optional_blank=()) return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
def coaches_from_ids(coach_ids): def coaches_from_ids(coach_ids):
@@ -67,6 +79,13 @@ def coaches_from_ids(coach_ids):
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all() return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def managers_from_ids(manager_ids):
"""The manager accounts behind these ids, filtered by role."""
if not manager_ids:
return []
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
def _users_by_id(user_ids): def _users_by_id(user_ids):
"""Load these users in one query, keyed by id. """Load these users in one query, keyed by id.
@@ -120,6 +139,10 @@ def list_tryouts():
@login_required @login_required
def create_tryout(): def create_tryout():
"""Create a new tryout event. Requires Admin or Manager.""" """Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if not can_manage(): if not can_manage():
flash(_('You do not have permission to create tryouts.'), 'danger') flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -160,12 +183,12 @@ def create_tryout():
created_by=current_user.id, created_by=current_user.id,
status='upcoming', status='upcoming',
target_org_team_id=data['target_org_team_id'], target_org_team_id=data['target_org_team_id'],
manager_id=data['manager_id'],
) )
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
tryout.coaches = coaches_from_ids(data['coach_ids']) tryout.coaches = coaches_from_ids(data['coach_ids'])
tryout.managers = managers_from_ids(data['manager_ids'])
db.session.commit() db.session.commit()
flash(_('Tryout created successfully!'), 'success') flash(_('Tryout created successfully!'), 'success')
@@ -180,6 +203,10 @@ def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout.""" """Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger') flash(_('You do not have permission to edit this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -221,8 +248,14 @@ def edit_tryout(tryout_id):
tryout.location = data['location'] tryout.location = data['location']
tryout.max_players = data['max_players'] tryout.max_players = data['max_players']
tryout.target_org_team_id = data['target_org_team_id'] tryout.target_org_team_id = data['target_org_team_id']
tryout.manager_id = data['manager_id']
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() db.session.commit()
flash(_('Tryout updated successfully!'), 'success') flash(_('Tryout updated successfully!'), 'success')
@@ -241,7 +274,7 @@ def view_tryout(tryout_id):
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
can_view = True can_view = True
elif isinstance(current_user, Manager): elif isinstance(current_user, Manager):
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): elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
@@ -466,6 +499,10 @@ def register_for_tryout(tryout_id):
def update_status(tryout_id): def update_status(tryout_id):
"""Update the status of a tryout.""" """Update the status of a tryout."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -486,6 +523,10 @@ def update_status(tryout_id):
def update_registration_status(tryout_id, player_id): def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status.""" """Update a registration's attendance status."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -510,6 +551,10 @@ def update_registration_status(tryout_id, player_id):
def register_player(tryout_id): def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches).""" """Manually register a player for a tryout (by managers/coaches)."""
tryout = locked_tryout_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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -557,6 +602,10 @@ def register_player(tryout_id):
def remove_player(tryout_id, player_id): def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches).""" """Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -593,6 +642,10 @@ def remove_player(tryout_id, player_id):
def create_team(tryout_id): def create_team(tryout_id):
"""Create a tryout-specific team.""" """Create a tryout-specific team."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -616,6 +669,10 @@ def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team.""" """Add a player to a tryout team."""
team = db.get_or_404(Team, team_id) team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -658,6 +715,10 @@ def add_to_team(tryout_id, team_id):
def delete_tryout(tryout_id): def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" """Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger') flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -696,4 +757,4 @@ def delete_tryout(tryout_id):
db.session.delete(tryout) db.session.delete(tryout)
db.session.commit() db.session.commit()
flash(_('Tryout deleted successfully.'), 'success') flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
-1270
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -39,6 +39,10 @@ def pdf_upload_error(file, allowed_extensions):
could put an arbitrary file on the server under a name the application could put an arbitrary file on the server under a name the application
later hands back for download (SEC-021). later hands back for download (SEC-021).
The check now goes beyond the header: it parses the upload with pypdf
and requires at least one parseable page, so a file with ``%PDF-``
prepended or a renamed non-PDF is rejected.
Args: Args:
file: The uploaded FileStorage, or None. file: The uploaded FileStorage, or None.
allowed_extensions: Extensions to accept, lowercase and without dot. allowed_extensions: Extensions to accept, lowercase and without dot.
@@ -53,11 +57,27 @@ def pdf_upload_error(file, allowed_extensions):
if not (stem and dot) or extension.lower() not in allowed_extensions: if not (stem and dot) or extension.lower() not in allowed_extensions:
return _('Only PDF files are allowed for contracts.') return _('Only PDF files are allowed for contracts.')
# Fast-fail: the PDF signature must appear at the start of the stream.
head = file.stream.read(len(PDF_SIGNATURE)) head = file.stream.read(len(PDF_SIGNATURE))
file.stream.seek(0) file.stream.seek(0)
if head != PDF_SIGNATURE: if head != PDF_SIGNATURE:
return _('That file is not a PDF, whatever its name says.') return _('That file is not a PDF, whatever its name says.')
# Full-content validation: parse with pypdf and require at least one
# page. A file with a forged header but no real PDF structure (e.g. a
# renamed executable with ``%PDF-`` prepended) will fail here.
try:
from pypdf import PdfReader
from pypdf.errors import PyPdfError
reader = PdfReader(file.stream, strict=False)
if len(reader.pages) == 0:
return _('That file is not a PDF, whatever its name says.')
except (PyPdfError, ValueError, TypeError):
return _('That file is not a PDF, whatever its name says.')
finally:
file.stream.seek(0)
return None return None
+50 -2
View File
@@ -44,6 +44,7 @@ from app.routes.users._shared import (
) )
from app.routes.users.blueprint import users_bp from app.routes.users.blueprint import users_bp
from app.storage import discard_documents from app.storage import discard_documents
from app.timeslots import total_hours
from app.validators import CreateUserSchema, EditUserSchema from app.validators import CreateUserSchema, EditUserSchema
@@ -59,7 +60,39 @@ def list_users():
# query without a stable ORDER BY can show the same row twice and never # query without a stable ORDER BY can show the same row twice and never
# show another (MNT-14). # show another (MNT-14).
page = paginate(User.query.order_by(User.role, User.username, User.id)) page = paginate(User.query.order_by(User.role, User.username, User.id))
return render_template('pages/users.html', users=page.items, pagination=page, roles=USER_TYPES)
# Build a per-user hours map for the current page with two bounded
# queries (one per availability table), avoiding an N+1 per row.
user_ids = [u.id for u in page.items]
hours_by_user: dict[int, float] = {}
for slots, fk in [
(
PlayerDisponibility.query.filter(
PlayerDisponibility.player_id.in_(user_ids),
).all(),
'player_id',
),
(
CoachAvailability.query.filter(
CoachAvailability.coach_id.in_(user_ids),
).all(),
'coach_id',
),
]:
buckets: dict[int, list] = {}
for slot in slots:
buckets.setdefault(getattr(slot, fk), []).append(slot)
for uid, group in buckets.items():
hours_by_user[uid] = total_hours(group)
return render_template(
'pages/users.html',
users=page.items,
pagination=page,
roles=USER_TYPES,
hours=hours_by_user,
)
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST']) @users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
@@ -380,4 +413,19 @@ def create_user():
def view_user(user_id): def view_user(user_id):
"""View a public profile for any user.""" """View a public profile for any user."""
user = db.get_or_404(User, user_id) user = db.get_or_404(User, user_id)
return render_template('pages/view_user.html', profile_user=user)
# Load availability for players and coaches so the public profile
# can render a read-only weekly grid.
availability = None
if user.role == 'player':
availability = user.disponibilities
elif user.role == 'coach':
availability = user.coach_availabilities
availability_hours = total_hours(availability) if availability else None
return render_template(
'pages/view_user.html',
profile_user=user,
availability=availability,
availability_hours=availability_hours,
)
+61 -18
View File
@@ -11,6 +11,7 @@ from werkzeug.utils import secure_filename
from app.extensions import db from app.extensions import db
from app.models import Admin, Coach, Contract, Manager, Player, User from app.models import Admin, Coach, Contract, Manager, Player, User
from app.models.admin_settings import AppSettings
from app.permissions import can_manage_player_contract, coach_player_ids from app.permissions import can_manage_player_contract, coach_player_ids
from app.routes.users._shared import ( from app.routes.users._shared import (
ALLOWED_CONTRACT_EXTENSIONS, ALLOWED_CONTRACT_EXTENSIONS,
@@ -18,7 +19,13 @@ from app.routes.users._shared import (
pdf_upload_error, pdf_upload_error,
) )
from app.routes.users.blueprint import users_bp from app.routes.users.blueprint import users_bp
from app.storage import CONTRACTS_DIR, document_path from app.storage import (
CONTRACTS_DIR,
GoogleDriveStorageError,
is_google_drive_path,
open_document,
store_uploaded_document,
)
from app.time_utils import utc_now_naive from app.time_utils import utc_now_naive
from app.validators import UploadContractSchema from app.validators import UploadContractSchema
@@ -43,6 +50,17 @@ def manageable_players():
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
def document_uploads_locked():
"""Return True when document uploads are globally disabled.
Admins are always allowed to bypass the lock. Managers, coaches, and
players are blocked when the toggle is off.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('document_uploads_enabled', default=True)
@users_bp.route('/contracts') @users_bp.route('/contracts')
@login_required @login_required
def list_contracts(): def list_contracts():
@@ -86,6 +104,10 @@ def upload_contract():
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger') flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts')) return redirect(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts'))
players = manageable_players() players = manageable_players()
if request.method == 'POST': if request.method == 'POST':
@@ -126,9 +148,13 @@ def upload_contract():
if team: if team:
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename) relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
absolute_path = document_path(relative_path) try:
os.makedirs(os.path.dirname(absolute_path), exist_ok=True) stored_path = store_uploaded_document(file, relative_path)
file.save(absolute_path) except GoogleDriveStorageError:
flash(
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
)
return redirect(url_for('users.upload_contract'))
contract = Contract( contract = Contract(
player_id=player_id, player_id=player_id,
@@ -136,7 +162,7 @@ def upload_contract():
uploaded_by_id=current_user.id, uploaded_by_id=current_user.id,
original_filename=original_filename, original_filename=original_filename,
stored_filename=stored_filename, stored_filename=stored_filename,
file_path=relative_path, file_path=stored_path,
notes=notes if notes else None, notes=notes if notes else None,
) )
db.session.add(contract) db.session.add(contract)
@@ -159,6 +185,10 @@ def upload_signed_contract(contract_id):
flash(_('Only the player can upload their signed contract.'), 'danger') flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return redirect(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts'))
file = request.files.get('signed_file') file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error: if error:
@@ -166,11 +196,22 @@ def upload_signed_contract(contract_id):
return redirect(url_for('users.list_contracts')) return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}" signed_filename = f"signed_{contract.stored_filename}"
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename) # Legacy local records keep their signed copy beside the original. Drive
file.save(document_path(signed_path)) # identifiers are opaque rather than filenames, so new Drive records use
# a fresh logical path and receive a second Drive ID.
signed_path = (
os.path.join(CONTRACTS_DIR, signed_filename)
if is_google_drive_path(contract.file_path)
else contract.file_path.replace(contract.stored_filename, signed_filename)
)
try:
stored_signed_path = store_uploaded_document(file, signed_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
contract.signed_filename = signed_filename contract.signed_filename = signed_filename
contract.signed_file_path = signed_path contract.signed_file_path = stored_signed_path
contract.status = 'signed' contract.status = 'signed'
contract.signed_at = utc_now_naive() contract.signed_at = utc_now_naive()
db.session.commit() db.session.commit()
@@ -186,11 +227,12 @@ def download_contract(contract_id):
if not contract.can_view(current_user): if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger') flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts')) return redirect(url_for('users.list_contracts'))
return send_file( try:
document_path(contract.file_path), document = open_document(contract.file_path)
as_attachment=True, except GoogleDriveStorageError:
download_name=contract.original_filename, flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
) return 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') @users_bp.route('/contracts/<int:contract_id>/download_signed')
@@ -204,8 +246,9 @@ def download_signed_contract(contract_id):
if not contract.signed_file_path: if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger') flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts')) return redirect(url_for('users.list_contracts'))
return send_file( try:
document_path(contract.signed_file_path), document = open_document(contract.signed_file_path)
as_attachment=True, except GoogleDriveStorageError:
download_name=contract.signed_filename, flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
) return redirect(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
+58
View File
@@ -1256,6 +1256,64 @@ a:hover { color: var(--primary-dark); }
color: white !important; color: white !important;
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
} }
/* Read-only weekly availability grid (public profile) */
.readonly-availability-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-top: 8px;
}
.ra-day-column {
background: var(--bg-secondary);
border-radius: 8px;
padding: 8px;
min-height: 120px;
}
.ra-day-header {
text-align: center;
font-weight: 600;
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 6px;
color: var(--primary);
font-size: 0.85rem;
}
.ra-slot {
padding: 4px 6px;
margin: 3px 0;
border-radius: 4px;
font-size: 0.75rem;
text-align: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.ra-slot.selected {
background: var(--primary);
color: white;
border-color: var(--primary-dark);
}
.ra-slot.ra-empty {
color: var(--gray-400);
border: 1px dashed var(--gray-300);
background: transparent;
}
@media (max-width: 768px) {
.readonly-availability-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.readonly-availability-grid {
grid-template-columns: 1fr;
}
}
.merged-disponibility-time-block.high-availability { .merged-disponibility-time-block.high-availability {
background: var(--success); background: var(--success);
+19 -1
View File
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
* Confirmation before a destructive submit. * Confirmation before a destructive submit.
* *
* <form data-confirm="Delete this match?"> * <form data-confirm="Delete this match?">
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
* data-confirm-input-message="Type DELETE to continue."
* data-confirm="Delete this record?">
* *
* Replaces onsubmit="return confirm(...)", and keeps the wording in the * Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated. * markup where it can be translated.
*/ */
document.addEventListener('submit', function (event) { document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]'); const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) { if (!form) {
return;
}
const inputName = form.getAttribute('data-confirm-input');
if (inputName) {
const input = form.elements.namedItem(inputName);
const expectedValue = form.getAttribute('data-confirm-value') || '';
if (!input || input.value.trim() !== expectedValue) {
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
event.preventDefault();
return;
}
}
if (!window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault(); event.preventDefault();
} }
}); });
+82 -7
View File
@@ -28,8 +28,23 @@ absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002). migration and can ship before Alembic does (DB-002).
""" """
import io
import logging
import os import os
from app.google_drive import (
GoogleDriveStorageError,
)
from app.google_drive import (
delete_file as delete_google_drive_file,
)
from app.google_drive import (
download_file as download_google_drive_file,
)
from app.google_drive import (
upload_file as upload_google_drive_file,
)
#: Environment override for the document root. What a release-directory #: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not #: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current. #: inside whichever one is current.
@@ -45,6 +60,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root. #: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés' CONTRACTS_DIR = 'contrats signés'
#: Database marker for documents stored remotely. Existing rows continue to
#: hold relative or absolute filesystem paths, so switching storage does not
#: invalidate contracts already uploaded before the Google Drive move.
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
#: Storage backends deliberately stay explicit. Google Drive is the
#: production default; local disk exists only for legacy rows and isolated
#: test/development environments.
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
def project_root(): def project_root():
"""Absolute path of the project, derived from this file's location. """Absolute path of the project, derived from this file's location.
@@ -91,6 +116,56 @@ def _rooted(env_name, default_name):
return os.path.join(project_root(), default_name) return os.path.join(project_root(), default_name)
def document_storage_backend():
"""Return the configured backend for new document uploads."""
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'local').strip().lower()
if backend not in {'google_drive', 'local'}:
raise ValueError(
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
)
return backend
def is_google_drive_path(stored_path):
"""Whether a database path references a Google Drive file."""
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
def _google_drive_id(stored_path):
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
if not file_id:
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
return file_id
def store_uploaded_document(file_storage, relative_path):
"""Store an uploaded document and return its durable database reference.
Local storage retains the relative-path format used by existing rows. A
Google Drive upload returns an opaque Drive file identifier prefixed with
``gdrive://`` so it cannot be mistaken for a filesystem path.
"""
if document_storage_backend() == 'local':
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file_storage.save(absolute_path)
return relative_path
file_id = upload_google_drive_file(
stream=file_storage.stream,
filename=os.path.basename(relative_path),
mimetype=file_storage.mimetype or 'application/pdf',
)
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
def open_document(stored_path):
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
if is_google_drive_path(stored_path):
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
return document_path(stored_path)
def discard_documents(stored_paths): def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012). """Remove these documents from disk. Returns how many went (DATA-012).
@@ -107,27 +182,27 @@ def discard_documents(stored_paths):
A path that cannot be removed is logged and skipped. Nothing here should A path that cannot be removed is logged and skipped. Nothing here should
be able to abort the deletion of an account. be able to abort the deletion of an account.
""" """
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
removed = 0 removed = 0
for stored_path in stored_paths: for stored_path in stored_paths:
if not stored_path: if not stored_path:
continue continue
target = document_path(stored_path)
try: try:
os.remove(target) if is_google_drive_path(stored_path):
delete_google_drive_file(_google_drive_id(stored_path))
else:
os.remove(document_path(stored_path))
removed += 1 removed += 1
except FileNotFoundError: except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous # Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line. # attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target) logger.info('Document already absent: %s', stored_path)
except OSError as exc: except (GoogleDriveStorageError, OSError) as exc:
logger.error( logger.error(
'Could not remove %s (%s). It is now an orphan: no database row ' 'Could not remove %s (%s). It is now an orphan: no database row '
'refers to it, so nothing in the application will ever offer to ' 'refers to it, so nothing in the application will ever offer to '
'delete it again.', 'delete it again.',
target, stored_path,
exc, exc,
) )
return removed return removed
+81 -2
View File
@@ -42,7 +42,11 @@ from urllib.parse import unquote, urlparse
# to be — see DOCUMENTS_DIR below. # to be — see DOCUMENTS_DIR below.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above from app.storage import ( # noqa: E402 — needs the path above
backups_root,
document_storage_backend,
documents_root,
)
# Configuration # Configuration
BACKUP_DIR = backups_root() BACKUP_DIR = backups_root()
@@ -292,6 +296,75 @@ def backup_documents():
return zip_path return zip_path
def backup_drive_documents():
"""Download every file from the configured Google Drive folder and archive it.
Called **in addition to** ``backup_documents()`` when the storage backend is
``google_drive``. When the backend is ``local`` this function silently
skips — local backups already handled by ``backup_documents()``.
Returns:
str | None: Path to the created archive, or None when skipped.
Raises:
BackupError: If Drive is configured but listing or downloading fails.
"""
if document_storage_backend() != 'google_drive':
print('[SKIP] Google Drive is not configured; local documents only.')
return None
try:
from app.google_drive import (
GoogleDriveStorageError,
list_folder_files,
)
from app.google_drive import (
download_file as download_drive_file,
)
except ImportError:
raise BackupError(
'Google Drive dependencies are not installed. Install requirements.txt again.'
) from None
print('[INFO] Listing Google Drive contract folder...')
try:
files = list_folder_files()
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
raise BackupError(f'Google Drive listing failed: {exc}') from exc
if not files:
print('[INFO] Google Drive folder is empty; no Drive documents to back up.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_drive_{timestamp}')
download = {}
for file_id, name in files:
print(f' Downloading {name} ({file_id})...')
try:
download[name] = download_drive_file(file_id)
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
raise BackupError(f'Google Drive download failed for {name}: {exc}') from exc
try:
import zipfile
zip_path = f'{archive_basename}.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, data in download.items():
zf.writestr(name, data)
except Exception as exc: # noqa: BLE001 — boundary
raise BackupError(f'Drive document archive failed: {exc}') from exc
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Drive document archiver reported success but produced an empty file.')
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Drive documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
def cleanup_old_backups(): def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS.""" """Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR): if not os.path.exists(BACKUP_DIR):
@@ -365,10 +438,16 @@ def main(argv=None):
except BackupError as exc: except BackupError as exc:
documents_ok = False documents_ok = False
print(f'[ERROR] {exc}') print(f'[ERROR] {exc}')
drive_ok = True
try:
backup_drive_documents()
except BackupError as exc:
drive_ok = False
print(f'[ERROR] {exc}')
cleanup_old_backups() cleanup_old_backups()
print() print()
if verified and documents_ok: if verified and documents_ok and drive_ok:
print('=== Backup completed successfully ===') print('=== Backup completed successfully ===')
return 0 return 0
+121
View File
@@ -0,0 +1,121 @@
"""Obtain a Google Drive refresh token for the application.
Run this once from a development machine that has a browser. It opens a local
OAuth redirect server, asks you to log in, and prints the refresh token.
Usage:
python app/supporting_scripts/get_refresh_token.py
Requires:
pip install google-auth-oauthlib
Before running:
1. Create a Google Cloud project and enable the Drive API.
2. Create a **Desktop** OAuth 2.0 client ID.
3. Add your email as a test user in the OAuth consent screen.
4. Run this script and paste the values when prompted.
"""
import os
import sys
def _prompt(label, env_name, default=""):
"""Ask the user for a value, accepting an env-var fallback or a default."""
candidate = os.getenv(env_name) or default
hint = f" [{candidate}]" if candidate else ""
raw = input(f"{label}{hint}: ").strip()
if raw:
return raw
if candidate:
return candidate
return default
def _validate_client_id(client_id):
"""Warn if the client ID doesn't look like a real OAuth client ID."""
if client_id == "YOUR_CLIENT_ID.apps.googleusercontent.com":
print("[ERROR] You must provide a real client ID from the Google Cloud console.")
return False
if not client_id.endswith(".apps.googleusercontent.com"):
print(
f"[WARNING] Client ID should end with '.apps.googleusercontent.com'.\n"
f" Got: {client_id!r}\n"
f" If this is a placeholder, the OAuth request will fail.\n"
)
return True
def _validate_client_secret(secret):
if secret == "YOUR_CLIENT_SECRET":
print("[ERROR] You must provide a real client secret from the Google Cloud console.")
return False
# The GOCSPX- prefix is now used by every client type, so it no longer
# tells us anything about whether the client was created as a Desktop app.
return True
def main():
print("=" * 60)
print("Google Drive Refresh Token — Setup")
print("=" * 60)
print()
# When both env vars are set, skip prompts entirely for headless runs.
env_id = os.getenv("GOOGLE_DRIVE_CLIENT_ID")
env_secret = os.getenv("GOOGLE_DRIVE_CLIENT_SECRET")
if env_id and env_secret:
client_id = env_id
client_secret = env_secret
print(f"Using Client ID from environment: {client_id[:30]}...")
else:
print("Enter your Google Cloud OAuth credentials.")
print("You can also set GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET")
print("environment variables and re-run to skip the prompts.")
print()
client_id = _prompt("Client ID", "GOOGLE_DRIVE_CLIENT_ID")
client_secret = _prompt("Client Secret", "GOOGLE_DRIVE_CLIENT_SECRET")
if not _validate_client_id(client_id) or not _validate_client_secret(client_secret):
sys.exit(1)
print()
print("Opening browser to authenticate with Google...")
print("(If it doesn't open, copy the URL below into your browser.)")
print()
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPE = "https://www.googleapis.com/auth/drive.file"
client_config = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
flow = InstalledAppFlow.from_client_config(client_config, scopes=[SCOPE])
credentials = flow.run_local_server(host="localhost", port=8080, open_browser=True)
print()
print("=" * 60)
print("SUCCESS — copy these values into your .env file:")
print("=" * 60)
print()
print("DOCUMENT_STORAGE_BACKEND=google_drive")
print(f"GOOGLE_DRIVE_CLIENT_ID={credentials.client_id}")
print(f"GOOGLE_DRIVE_CLIENT_SECRET={client_secret}")
print(f"GOOGLE_DRIVE_REFRESH_TOKEN={credentials.refresh_token}")
print("GOOGLE_DRIVE_FOLDER_ID=<get this from the Drive folder URL>")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+6
View File
@@ -90,6 +90,12 @@
<span>{{ _('Manage Users') }}</span> <span>{{ _('Manage Users') }}</span>
</a> </a>
</li> </li>
<li>
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
<i class="fas fa-cogs"></i>
<span>Admin Panel</span>
</a>
</li>
{% endif %} {% endif %}
{% if current_user.role == 'player' %} {% if current_user.role == 'player' %}
<li> <li>
+294
View File
@@ -0,0 +1,294 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Panel{% endblock %}
{% block page_title %}Admin Panel{% endblock %}
{% block content %}
<!-- Stats Bar -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-primary">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_users }}</h3>
<p>Total Users</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-success">
<i class="fas fa-user"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_players }}</h3>
<p>Players</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-warning">
<i class="fas fa-shield-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_org_teams }}</h3>
<p>Org Teams</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-info">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.active_tryouts }}</h3>
<p>Active Tryouts</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-secondary">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3>{{ stats.upcoming_tryouts }}</h3>
<p>Upcoming</p>
</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Left column -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-toggle-{% if settings.tryouts_open %}on{% else %}off{% endif %}"></i> Tryouts Access</h3>
</div>
<div class="card-body">
<p>
Status:
<span class="badge badge-{% if settings.tryouts_open %}success{% else %}danger{% endif %}">
{% if settings.tryouts_open %}OPEN{% else %}CLOSED{% endif %}
</span>
</p>
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
{% if settings.tryouts_open %}
Coaches and managers can create and modify tryouts.
{% else %}
Only admins can create or modify tryouts. Coaches and managers are locked out.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-toggle-{% if settings.document_uploads_enabled %}on{% else %}off{% endif %}"></i> Document Uploads</h3>
</div>
<div class="card-body">
<p>
Status:
<span class="badge badge-{% if settings.document_uploads_enabled %}success{% else %}danger{% endif %}">
{% if settings.document_uploads_enabled %}ENABLED{% else %}DISABLED{% endif %}
</span>
</p>
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
{% if settings.document_uploads_enabled %}
Managers and coaches can upload contracts. Players can upload signed contracts.
{% else %}
Only admins can upload or sign contracts. Other roles are blocked.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_document_uploads') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.document_uploads_enabled %}warning{% else %}success{% endif %}">
{% if settings.document_uploads_enabled %}Disable Uploads{% else %}Enable Uploads{% endif %}
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> Season Management</h3>
</div>
<div class="card-body">
<p>
Season:
<span class="badge badge-{% if settings.season_active %}success{% else %}info{% endif %}">
{% if settings.season_active %}ACTIVE{% else %}INACTIVE{% endif %}
</span>
</p>
<p><strong>Name:</strong> {{ settings.season_name }}</p>
<p><strong>Start:</strong> {{ settings.season_start }}</p>
<p><strong>End:</strong> {{ settings.season_end }}</p>
{% if not settings.season_active %}
<hr style="margin: 12px 0;">
<form method="POST" 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 %}
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="form-group col-4"> <div class="form-group col-4">
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label> <label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
<div class="score-input"> <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 }}" oninput="this.nextElementSibling.textContent = this.value"> <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> <span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
</div> </div>
</div> </div>
+2 -2
View File
@@ -94,8 +94,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label for="signed_file">{{ _('Select Signed Contract File') }}</label> <label for="signed_file">{{ _('Select Signed Contract File') }}</label>
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required> <input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small> <small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button> <button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
+11 -3
View File
@@ -15,7 +15,7 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" id="select-all" onclick="toggleAll(this)"></th> <th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
<th>Player</th> <th>Player</th>
<th>Contact</th> <th>Contact</th>
<th>Attendance</th> <th>Attendance</th>
@@ -70,12 +70,20 @@
</div> </div>
</div> </div>
<script> {% endblock %}
function toggleAll(master) {
{% block scripts %}
<script nonce="{{ csp_nonce }}">
function toggleAll() {
var boxes = document.querySelectorAll('.player-check'); var boxes = document.querySelectorAll('.player-check');
var master = document.getElementById('select-all');
for (var i = 0; i < boxes.length; i++) { for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = master.checked; boxes[i].checked = master.checked;
} }
} }
registerActions({
'toggle-all': toggleAll,
});
</script> </script>
{% endblock %} {% endblock %}
+44
View File
@@ -145,6 +145,49 @@
</div> </div>
</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 --> <!-- Contracts Card -->
{% if user.role == 'player' %} {% if user.role == 'player' %}
<div class="card"> <div class="card">
@@ -607,6 +650,7 @@ document.addEventListener('DOMContentLoaded', function() {
// dispatched by the delegated listener in main.js. This replaces inline // dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise. // onclick attributes, which no CSP nonce is able to authorise.
registerActions({ registerActions({
'save-disponibilities': saveDisponibilities,
'clear-disponibilities': clearDisponibilities, 'clear-disponibilities': clearDisponibilities,
'clear-availability': clearAllAvailability, 'clear-availability': clearAllAvailability,
}); });
+3
View File
@@ -300,6 +300,9 @@
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}"> <script nonce="{{ csp_nonce }}">
function showCreateForm() { function showCreateForm() {
document.getElementById('createTeamForm').classList.remove('hidden'); document.getElementById('createTeamForm').classList.remove('hidden');
+14 -7
View File
@@ -67,15 +67,22 @@
</select> </select>
</div> </div>
<div class="form-group col-6"> <div class="form-group col-6">
<label for="manager_id">{{ _('Assigned Manager') }}</label> <label>{{ _('Assigned Managers') }}</label>
<select id="manager_id" name="manager_id" class="form-select"> <div class="checkbox-grid">
<option value="">{{ _('-- No manager assigned --') }}</option>
{% for manager in managers %} {% for manager in managers %}
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}> {% set is_checked = false %}
{{ manager.username }} {% if tryout %}
</option> {% for m in tryout.get_managers() %}
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
{% endfor %}
{% 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 %} {% endfor %}
</select> </div>
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
+2 -2
View File
@@ -24,8 +24,8 @@
<div class="form-group"> <div class="form-group">
<label for="contract_file">{{ _('Contract File') }}</label> <label for="contract_file">{{ _('Contract File') }}</label>
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required> <input type="file" id="contract_file" name="contract_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small> <small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
+8
View File
@@ -21,6 +21,7 @@
<th>{{ _('Username') }}</th> <th>{{ _('Username') }}</th>
<th>{{ _('Email') }}</th> <th>{{ _('Email') }}</th>
<th>{{ _('Role') }}</th> <th>{{ _('Role') }}</th>
<th>{{ _('Hours') }}</th>
<th>{{ _('Status') }}</th> <th>{{ _('Status') }}</th>
<th>{{ _('Joined') }}</th> <th>{{ _('Joined') }}</th>
<th>{{ _('Actions') }}</th> <th>{{ _('Actions') }}</th>
@@ -38,6 +39,13 @@
<td>{{ u.username }}</td> <td>{{ u.username }}</td>
<td>{{ u.email }}</td> <td>{{ u.email }}</td>
<td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></td> <td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></td>
<td>
{% if u.role in ('player', 'coach') %}
{{ '%.1f'|format(hours.get(u.id, 0.0)) }}
{% else %}
—
{% endif %}
</td>
<td> <td>
{% if u.is_active_account %} {% if u.is_active_account %}
<span class="badge badge-success">Active</span> <span class="badge badge-success">Active</span>
+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> <span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Manager</span> <span class="detail-label">Manager(s)</span>
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span> <span class="detail-value">
{% set mgrs = tryout.get_managers() %}
{% if mgrs %}
{{ mgrs | map(attribute='username') | join(', ') }}
{% else %}
Not assigned
{% endif %}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Coaches</span> <span class="detail-label">Coaches</span>
<span class="detail-value"> <span class="detail-value">
{% if tryout.coaches %} {% set cos = tryout.get_coaches() %}
{{ tryout.coaches | map(attribute='username') | join(', ') }} {% if cos %}
{% elif tryout.coach %} {{ cos | map(attribute='username') | join(', ') }}
{{ tryout.coach.username }}
{% else %} {% else %}
Not assigned Not assigned
{% endif %} {% endif %}
@@ -534,6 +540,9 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %}
{% block scripts %}
<style> <style>
.presence-toggle-btn { .presence-toggle-btn {
padding: 2px 7px; padding: 2px 7px;
+25
View File
@@ -81,6 +81,31 @@
</table> </table>
</div> </div>
{% endif %} {% endif %}
{% if profile_user.role in ('player', 'coach') and availability %}
<hr class="my-4">
<h4 class="mb-3"><i class="fas fa-clock"></i> {{ _('Availability') }}
<small class="text-muted">({{ '%.1f'|format(availability_hours or 0.0) }} h)</small>
</h4>
<div class="readonly-availability-grid">
{% set day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] %}
{% for day_idx in range(7) %}
<div class="ra-day-column">
<div class="ra-day-header">{{ day_names[day_idx][:3] }}</div>
{% set day_slots = availability | selectattr('day_of_week', 'equalto', day_idx) | list %}
{% if day_slots %}
{% for slot in day_slots | sort(attribute='start_time') %}
<div class="ra-slot selected">
{{ slot.start_time.strftime('%H:%M') }}–{{ slot.end_time.strftime('%H:%M') }}
</div>
{% endfor %}
{% else %}
<div class="ra-slot ra-empty">—</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+36
View File
@@ -58,3 +58,39 @@ def day_name(day_of_week):
if 0 <= day_of_week < len(DAY_NAMES): if 0 <= day_of_week < len(DAY_NAMES):
return DAY_NAMES[day_of_week] return DAY_NAMES[day_of_week]
return f'Day {day_of_week}' return f'Day {day_of_week}'
def slot_duration_minutes(start_time, end_time):
"""Minutes a slot spans, wrapping past midnight like slot_end does.
Works on SQLite where ``Time`` columns are stored as text — the
arithmetic stays in pure Python so it is portable across databases.
Args:
start_time: A ``datetime.time``.
end_time: A ``datetime.time`` (may be earlier if the slot wraps
past midnight).
Returns:
int: Duration in whole minutes.
"""
anchor = date_cls(2000, 1, 1)
start_dt = datetime.combine(anchor, start_time)
end_dt = datetime.combine(anchor, end_time)
if end_dt <= start_dt:
end_dt += timedelta(days=1)
return int((end_dt - start_dt).total_seconds() // 60)
def total_hours(slots):
"""Aggregate slot durations into a single float, rounded to one decimal.
Args:
slots: Iterable of objects with ``start_time`` and ``end_time``
attributes (``PlayerDisponibility`` or ``CoachAvailability``).
Returns:
float: Total hours across all slots, e.g. ``8.5``.
"""
total_minutes = sum(slot_duration_minutes(s.start_time, s.end_time) for s in slots)
return round(total_minutes / 60, 1)
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -949,6 +949,7 @@ class TryoutSchema(StripMixin):
target_org_team_id = fields.Integer(allow_none=True, load_default=None) target_org_team_id = fields.Integer(allow_none=True, load_default=None)
manager_id = fields.Integer(allow_none=True, load_default=None) manager_id = fields.Integer(allow_none=True, load_default=None)
coach_ids = fields.List(fields.Integer(), load_default=list) coach_ids = fields.List(fields.Integer(), load_default=list)
manager_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema @validates_schema
def validate_span(self, data, **kwargs): def validate_span(self, data, **kwargs):
+1
View File
@@ -15,6 +15,7 @@ incident.
|---|---|---| |---|---|---|
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record | | `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record |
| `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths | | `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths |
| `documents_backup_drive_<timestamp>.zip` | Google Drive contract folder | Drive-stored contracts downloaded and archived when `DOCUMENT_STORAGE_BACKEND=google_drive` |
Losing either one alone loses data. A database restore without the document Losing either one alone loses data. A database restore without the document
archive leaves contract rows pointing at files that no longer exist. archive leaves contract rows pointing at files that no longer exist.
+22
View File
@@ -67,6 +67,28 @@ documents every variable. Copying it verbatim gives a configuration that
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
than one that starts and is wide open (OPS-003). than one that starts and is wide open (OPS-003).
### Google Drive contract storage
New contracts are stored in the owner's personal Google Drive when
`DOCUMENT_STORAGE_BACKEND=google_drive`. Enable the Google Drive API in a
Google Cloud project, create an OAuth client for the owner, and authorize it
once with the `drive.file` scope. Configure the resulting values outside the
repository:
```text
DOCUMENT_STORAGE_BACKEND=google_drive
GOOGLE_DRIVE_FOLDER_ID=<owner folder id>
GOOGLE_DRIVE_CLIENT_ID=<OAuth client id>
GOOGLE_DRIVE_CLIENT_SECRET=<OAuth client secret>
GOOGLE_DRIVE_REFRESH_TOKEN=<owner refresh token>
```
The refresh token can create, download, and delete contracts created by this
application. Keep all four values out of source control. The database stores
only `gdrive://<file-id>` references, so existing local-file rows continue to
work after the change. Use `DOCUMENT_STORAGE_BACKEND=local` only for tests,
local development, or while migrating legacy files.
### Binding and proxy trust — read this before going live (OPS-002) ### Binding and proxy trust — read this before going live (OPS-002)
Two variables decide whether the rate limiter, the account lockout and the Two variables decide whether the rate limiter, the account lockout and the
+2 -1
View File
@@ -82,8 +82,9 @@ Run through this checklist before deploying to production.
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`) - [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
- [ ] File extensions restricted to PDF only - [ ] File extensions restricted to PDF only
- [ ] File content validated by parsing with pypdf (`PdfReader`) — header (`%PDF-`) + at least one parseable page
- [ ] Files stored with UUID filenames (not original names) - [ ] Files stored with UUID filenames (not original names)
- [ ] Upload directory outside web root - [ ] Upload directory outside web root (or Google Drive in production)
## Phase 13 — Logging & Monitoring ## Phase 13 — Logging & Monitoring
+1 -1
View File
@@ -31,7 +31,7 @@ show_missing = true
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py312" target-version = "py313"
exclude = [".venv", "venv", "migrations", "docs"] exclude = [".venv", "venv", "migrations", "docs"]
[tool.ruff.lint] [tool.ruff.lint]
+3
View File
@@ -24,6 +24,9 @@ Flask-SQLAlchemy==3.1.1
Flask-WTF==1.3.0 Flask-WTF==1.3.0
frozenlist==1.8.0 frozenlist==1.8.0
greenlet==3.5.4 greenlet==3.5.4
google-api-python-client==2.198.0
google-auth==2.56.3
pypdf==6.16.2
idna==3.18 idna==3.18
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 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. cannot leave a PDF in someone's working tree.
""" """
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents')) monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents'))
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'local')
@pytest.fixture @pytest.fixture
@@ -194,6 +195,11 @@ def as_role(app, client, make_user, login):
from app.models import User from app.models import User
username = _db.session.get(User, user_id).username 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) response = login(username)
assert response.status_code in (301, 302), ( assert response.status_code in (301, 302), (
f'login for {username} did not redirect: {response.status_code}' 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'
)
+135
View File
@@ -0,0 +1,135 @@
"""Admin document-upload toggle — enable/disable gate and enforcement
on contract upload routes.
ADMIN-006 style. The document_uploads_enabled setting gates every
contract upload route for non-admins. Admins always bypass the lock.
"""
import pytest
from app.extensions import db
from app.models import AppSettings, Contract, User
NON_ADMIN_ROLES = ['manager', 'coach']
def _redirected(response):
return response.status_code in (301, 302)
class TestDocumentUploadToggle:
"""Toggle logic — on/off round-trip and audit log."""
def test_toggle_from_enabled_to_disabled(self, client, as_role):
as_role('admin')
# Default is enabled
client.post('/admin/toggle-document-uploads', data={}, follow_redirects=False)
with client.application.app_context():
assert not AppSettings.get_bool('document_uploads_enabled', default=True)
def test_toggle_from_disabled_to_enabled(self, client, as_role):
as_role('admin')
client.post('/admin/toggle-document-uploads', data={}, follow_redirects=False)
client.post('/admin/toggle-document-uploads', data={}, follow_redirects=False)
with client.application.app_context():
assert AppSettings.get_bool('document_uploads_enabled', default=True)
def test_toggle_creates_audit_log(self, client, as_role):
from app.models import AuditLog
as_role('admin')
client.post('/admin/toggle-document-uploads', data={}, follow_redirects=False)
with client.application.app_context():
entry = AuditLog.query.filter_by(action='document_uploads_toggled').first()
assert entry is not None
def test_default_is_enabled(self, app):
with app.app_context():
assert AppSettings.get_bool('document_uploads_enabled', default=True)
class TestDocumentUploadGateEnforcement:
"""Non-admin roles are blocked when the toggle is off."""
def _contract_for(self, app, player_id):
"""Create a minimal contract for enforcement tests."""
from datetime import datetime
with app.app_context():
admin = User.query.filter_by(role='admin').first()
contract = Contract(
player_id=player_id,
uploaded_by_id=admin.id,
original_filename='test.pdf',
stored_filename='test.pdf',
file_path='contrats signes/test.pdf',
status='pending_player_signing',
uploaded_at=datetime.utcnow(),
)
db.session.add(contract)
db.session.commit()
return contract.id
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_upload_contract_when_disabled(
self, app, client, as_role, make_user, role
):
as_role('admin')
client.post('/admin/toggle-document-uploads', data={}) # disable
as_role(role)
response = client.post(
'/users/contracts/upload',
data={'player_id': '', 'contract_file': ''},
follow_redirects=True,
)
html = response.data.decode()
# Default locale is French — the flash message says "désactivés"
assert 'désactivés' in html or 'disabled' in html.lower()
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_admin_can_always_upload_contract(self, client, as_role, role):
as_role('admin')
# Pre-create a player for the contract
as_role('player')
client.post('/admin/toggle-document-uploads', data={}) # disable
# Admin should still be able to reach the upload page
response = client.get('/users/contracts/upload', follow_redirects=False)
assert _redirected(response) or response.status_code == 200
def test_player_cannot_upload_signed_when_disabled(self, app, client, as_role, make_user):
as_role('admin')
client.post('/admin/toggle-document-uploads', data={}) # disable while logged in as admin
player_id = make_user('player')
contract_id = self._contract_for(app, player_id)
# Log in as the player
with app.app_context():
username = db.session.get(User, player_id).username
client.post('/auth/login', data={'username': username, 'password': 'Password123'})
response = client.post(
f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': ''},
follow_redirects=True,
)
html = response.data.decode()
assert 'désactivés' in html or 'disabled' in html.lower()
def test_admin_can_always_upload(self, app, client, as_role):
as_role('admin')
client.post('/admin/toggle-document-uploads', data={}) # disable
# Admin GET the upload page — should be allowed (not redirected away)
response = client.get('/users/contracts/upload', follow_redirects=False)
# The admin bypasses the lock so they see the upload form (200)
# or get redirected (if no players exist, etc). Either way is fine
# as long as it's not the flash about uploads being disabled.
body = response.data.decode() if response.status_code == 200 else ''
# The toggle flash ("désactivés") could still be present, but the
# upload-disabled message ("désactivés") must not appear in the body.
# We check for the French lock message specifically.
assert 'actuellement désactivés' not in body
+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
+50
View File
@@ -0,0 +1,50 @@
"""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/toggle-document-uploads',
'/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
+116
View File
@@ -0,0 +1,116 @@
"""Tests for the hours column in the user list and the read-only
availability grid on public profiles."""
from datetime import time
from app.extensions import db
from app.models import (
CoachAvailability,
PlayerDisponibility,
)
class TestHoursColumn:
"""The manage-users table shows available hours for players/coaches."""
def test_admin_sees_hours_header(self, app, client, as_role):
as_role('admin')
body = client.get('/users').get_data(as_text=True)
# Default locale is French, so _("Hours") renders as "Heures"
assert 'Heures' in body
def test_player_without_slots_shows_0(self, app, client, as_role, make_user):
as_role('admin')
make_user('player') # create a player but stay logged in as admin
body = client.get('/users').get_data(as_text=True)
assert '0.0' in body
def test_player_with_slots_shows_hours(self, app, client, as_role, make_user):
as_role('admin')
player_id = make_user('player')
with app.app_context():
db.session.add(
PlayerDisponibility(
player_id=player_id,
day_of_week=0,
start_time=time(17, 0),
end_time=time(19, 0),
)
)
db.session.add(
PlayerDisponibility(
player_id=player_id,
day_of_week=1,
start_time=time(18, 0),
end_time=time(20, 0),
)
)
db.session.commit()
body = client.get('/users').get_data(as_text=True)
# 2h + 2h = 4.0
assert '4.0' in body
def test_coach_without_slots_shows_0(self, app, client, as_role, make_user):
as_role('admin')
make_user('coach')
body = client.get('/users').get_data(as_text=True)
assert '0.0' in body
def test_admin_roles_show_dash(self, app, client, as_role, make_user):
as_role('admin')
make_user('manager')
make_user('scout')
body = client.get('/users').get_data(as_text=True)
# managers and scouts should show mdash
assert '\u2014' in body
class TestAvailabilityOnPublicProfile:
"""view_user renders a weekly availability grid for players/coaches."""
def test_player_without_availability_shows_no_grid(self, app, client, as_role):
as_role('admin')
player_id = as_role('player')
body = client.get(f'/users/{player_id}/view').get_data(as_text=True)
# Default locale is French
assert 'Disponibilit\u00e9s' not in body
def test_player_with_slots_shows_grid(self, app, client, as_role, make_user):
as_role('admin')
player_id = make_user('player')
with app.app_context():
db.session.add(
PlayerDisponibility(
player_id=player_id,
day_of_week=2,
start_time=time(18, 0),
end_time=time(18, 30),
)
)
db.session.commit()
body = client.get(f'/users/{player_id}/view').get_data(as_text=True)
assert 'Disponibilit\u00e9s' in body
assert '18:00' in body
def test_coach_with_slots_shows_grid(self, app, client, as_role, make_user):
as_role('admin')
coach_id = make_user('coach')
with app.app_context():
db.session.add(
CoachAvailability(
coach_id=coach_id,
day_of_week=3,
start_time=time(10, 0),
end_time=time(10, 30),
)
)
db.session.commit()
body = client.get(f'/users/{coach_id}/view').get_data(as_text=True)
assert 'Disponibilit\u00e9s' in body
assert '10:00' in body
def test_admin_profile_shows_no_grid(self, app, client, as_role):
admin_id = as_role('admin')
body = client.get(f'/users/{admin_id}/view').get_data(as_text=True)
assert 'Disponibilit\u00e9s' not in body
+55 -7
View File
@@ -14,13 +14,25 @@ import io
import os import os
import pytest import pytest
from pypdf import PdfWriter as _Pw
from app.extensions import db from app.extensions import db
from app.models import Contract from app.models import Contract
PDF_BYTES = b'%PDF-1.7\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF\n'
def _valid_pdf_bytes():
w = _Pw()
w.add_blank_page(612, 792)
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
_PDF_BYTES = _valid_pdf_bytes()
NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header
PDF_HEADER_ONLY_BYTES = b'%PDF-1.7\n' + b'MZ' * 100 # PDF header over non-PDF content
@pytest.fixture @pytest.fixture
def contract_for(app, tmp_path): def contract_for(app, tmp_path):
@@ -29,7 +41,7 @@ def contract_for(app, tmp_path):
def _make(player_id, uploader_id): def _make(player_id, uploader_id):
stored = 'deadbeef.pdf' stored = 'deadbeef.pdf'
path = tmp_path / stored path = tmp_path / stored
path.write_bytes(PDF_BYTES) path.write_bytes(_PDF_BYTES)
with app.app_context(): with app.app_context():
contract = Contract( contract = Contract(
player_id=player_id, player_id=player_id,
@@ -89,7 +101,7 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},
content_type='multipart/form-data', content_type='multipart/form-data',
follow_redirects=True, follow_redirects=True,
) )
@@ -110,7 +122,7 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},
content_type='multipart/form-data', content_type='multipart/form-data',
follow_redirects=True, follow_redirects=True,
) )
@@ -118,6 +130,31 @@ class TestSignedUpload:
with app.app_context(): with app.app_context():
assert db.session.get(Contract, contract_id).status != 'signed' assert db.session.get(Contract, contract_id).status != 'signed'
def test_signed_upload_with_google_drive(
self, app, client, as_role, make_user, contract_for, monkeypatch
):
"""Signing with Drive backend stores gdrive://<id> in signed_file_path."""
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')
monkeypatch.setattr(
'app.storage.upload_google_drive_file',
lambda **_kwargs: 'drive-signed-456',
)
player_id = as_role('player')
admin_id = make_user('admin')
contract_id, directory = contract_for(player_id, admin_id)
client.post(
f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},
content_type='multipart/form-data',
follow_redirects=True,
)
with app.app_context():
contract = db.session.get(Contract, contract_id)
assert contract.status == 'signed'
assert contract.signed_file_path == 'gdrive://drive-signed-456'
class TestTheHelper: class TestTheHelper:
def test_it_reports_a_missing_file(self, app): def test_it_reports_a_missing_file(self, app):
@@ -133,16 +170,27 @@ class TestTheHelper:
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='c.pdf') upload = FileStorage(stream=io.BytesIO(_PDF_BYTES), filename='c.pdf')
with app.test_request_context('/'): with app.test_request_context('/'):
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None
assert upload.stream.read() == PDF_BYTES assert upload.stream.read() == _PDF_BYTES
def test_a_name_without_a_dot_is_refused(self, app): def test_a_name_without_a_dot_is_refused(self, app):
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='pdf') upload = FileStorage(stream=io.BytesIO(_PDF_BYTES), filename='pdf')
with app.test_request_context('/'):
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)
def test_a_header_only_file_is_refused(self, app):
"""A stream starting with %PDF- but holding non-PDF data is
rejected (the header check is not enough)."""
from werkzeug.datastructures import FileStorage
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')
with app.test_request_context('/'): with app.test_request_context('/'):
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)
+14 -17
View File
@@ -1,17 +1,17 @@
"""Which PostgreSQL driver the application actually asks for — QUA-001. """Which PostgreSQL driver the application actually asks for — QUA-001.
`postgresql://…` is not "whichever driver is installed". SQLAlchemy reads it `postgresql://…` is not "whichever driver is installed". SQLAlchemy reads it
as psycopg2 and imports that module when the engine is created. as psycopg2 unless the URL names a driver. requirements.txt declares psycopg
requirements.txt pins psycopg 3 and no psycopg2, so a clean install against 3, so create_app() must explicitly select it.
the URL Render hands out — the same form docs/database-restore.md documents
— fails before the first request:
ModuleNotFoundError: No module named 'psycopg2' The test suite must not assume that psycopg2 is absent: a developer's virtual
environment can contain optional packages in addition to the declared
create_app() now names the driver. These tests pin that down, and the last dependencies. These tests instead pin the application's selected driver and
one proves the failure is real rather than theoretical. the dependency contract that production installs use.
""" """
from pathlib import Path
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -80,16 +80,13 @@ class TestTheFactory:
assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///') assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///')
class TestTheFailureIsReal: class TestDriverContract:
"""Not a hypothetical: this is what the deployed configuration did.""" 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): assert 'psycopg[binary]==' in declared
with pytest.raises(ImportError): assert 'psycopg2' not in declared
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')
def test_the_normalised_url_can(self): def test_the_normalised_url_can(self):
engine = create_engine(normalise_database_url('postgresql://u:p@host/db')) engine = create_engine(normalise_database_url('postgresql://u:p@host/db'))
+39 -5
View File
@@ -19,7 +19,27 @@ import os
import pytest import pytest
from app.extensions import db 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,
)
def _valid_pdf():
"""A real one-page PDF that passes pypdf content validation."""
import io
from pypdf import PdfWriter
w = PdfWriter()
w.add_blank_page(612, 792)
buf = io.BytesIO()
w.write(buf)
buf.seek(0)
return buf
class TestDocumentsRoot: class TestDocumentsRoot:
@@ -87,6 +107,22 @@ class TestDocumentPath:
assert first_old == second_old, 'and must not move the ones already filed' 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: class TestThroughTheUploadRoute:
@pytest.fixture @pytest.fixture
def uploaded(self, app, client, as_role, make_user): def uploaded(self, app, client, as_role, make_user):
@@ -143,10 +179,8 @@ class TestThroughTheUploadRoute:
def _pdf(): def _pdf():
"""The smallest thing pdf_upload_error accepts.""" """The smallest valid one-page PDF that passes pypdf content validation."""
import io return _valid_pdf()
return io.BytesIO(b'%PDF-1.4\n%%EOF\n')
class TestDeletingAnAccountTakesItsDocuments: class TestDeletingAnAccountTakesItsDocuments: