Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a271cfd8 | ||
|
|
742995d3cd | ||
|
|
cf551867c9 | ||
|
|
b43c5b1734 | ||
|
|
85eca9edd4 | ||
|
|
c7b321a636 | ||
|
|
90c00ad29b | ||
|
|
bfb25f4d16 | ||
|
|
820cccb25e | ||
|
|
38defc53a5 | ||
|
|
d18979a3e9 | ||
|
|
1549fbaef3 | ||
|
|
c72ed9b0b1 | ||
|
|
6232b77094 | ||
|
|
a616c79663 | ||
|
|
9fc4ba0b98 | ||
|
|
53e390672e |
@@ -98,6 +98,9 @@ coverage.xml
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||||
# voir l'historique du commit `4fd27ba`).
|
# voir l'historique du commit `4fd27ba`).
|
||||||
|
# Données PostgreSQL locales — gérées par app/local_postgres.py
|
||||||
|
instance/pgdata/
|
||||||
|
instance/pg.log
|
||||||
instance/
|
instance/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
+29
-9
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
|
|||||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Optional — storage
|
# Contract storage — Google Drive
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
|
||||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
# refresh token, and folder ID are secrets/configuration: do not commit them.
|
||||||
# to a release-directory layout, or a deployment will take the documents with
|
# Use `local` only for isolated development and legacy-file maintenance.
|
||||||
# it (OPS-011, app/storage.py).
|
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||||
#
|
|
||||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
# Target folder in the owner's personal Drive. The application keeps every
|
||||||
# did not, and archived `./documents` regardless — so setting this here and
|
# contract directly in this folder and stores only each Drive file ID in the
|
||||||
# nowhere else produced empty contract backups that still exited 0.
|
# database.
|
||||||
|
GOOGLE_DRIVE_FOLDER_ID=
|
||||||
|
|
||||||
|
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
|
||||||
|
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
|
||||||
|
# refresh token here. See docs/deployment.md before enabling this in production.
|
||||||
|
GOOGLE_DRIVE_CLIENT_ID=
|
||||||
|
GOOGLE_DRIVE_CLIENT_SECRET=
|
||||||
|
GOOGLE_DRIVE_REFRESH_TOKEN=
|
||||||
|
|
||||||
|
# Local storage is retained only for historical rows and local test runs.
|
||||||
|
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
|
||||||
DOCUMENTS_ROOT=
|
DOCUMENTS_ROOT=
|
||||||
|
|
||||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
# Where the log files go. Empty means `logs/` beside the application. Both
|
||||||
@@ -140,5 +151,14 @@ AUTO_CREATE_TABLES=true
|
|||||||
# FORCE_HTTPS=false no redirect to HTTPS
|
# FORCE_HTTPS=false no redirect to HTTPS
|
||||||
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||||
#
|
#
|
||||||
|
# LOCAL_PG=true start a local PostgreSQL server (requires PostgreSQL
|
||||||
|
# installed on the system). Uses port 5433,
|
||||||
|
# data in instance/pgdata/, database team_tryouts.
|
||||||
|
#
|
||||||
|
# LOCAL_PG_PORT=5433 override the PostgreSQL port
|
||||||
|
# LOCAL_PG_USER=team_tryouts_db override the PostgreSQL user
|
||||||
|
# LOCAL_PG_DATABASE=team_tryouts_db override the database name
|
||||||
|
#
|
||||||
|
#
|
||||||
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||||
# development session cannot accidentally inherit a production binding.
|
# development session cannot accidentally inherit a production binding.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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):
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,428 @@
|
|||||||
|
"""Manage a local PostgreSQL instance for development.
|
||||||
|
|
||||||
|
Searches for PostgreSQL binaries on the system, initialises a data directory
|
||||||
|
under ``instance/pgdata/``, starts the server on port 5433, creates the
|
||||||
|
``team_tryouts`` database, and registers cleanup on exit.
|
||||||
|
|
||||||
|
Usage from run.py::
|
||||||
|
|
||||||
|
if os.getenv('LOCAL_PG', '').lower() == 'true':
|
||||||
|
from app.local_postgres import start as start_local_pg
|
||||||
|
os.environ['DATABASE_URL'] = start_local_pg()
|
||||||
|
|
||||||
|
Environment used (all optional):
|
||||||
|
LOCAL_PG_PORT Port to bind (default 5433)
|
||||||
|
LOCAL_PG_USER Database superuser name (default team_tryouts_dev)
|
||||||
|
LOCAL_PG_DATABASE Database name (default team_tryouts)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DATADIR = pathlib.Path(__file__).resolve().parent.parent / 'instance' / 'pgdata'
|
||||||
|
PG_LOG = DATADIR.parent / 'pg.log'
|
||||||
|
|
||||||
|
PORT = int(os.getenv('LOCAL_PG_PORT', '5433'))
|
||||||
|
_USER = os.getenv('LOCAL_PG_USER', 'team_tryouts_dev')
|
||||||
|
_DATABASE = os.getenv('LOCAL_PG_DATABASE', 'team_tryouts')
|
||||||
|
|
||||||
|
DATABASE_URL = f'postgresql://{_USER}@127.0.0.1:{PORT}/{_DATABASE}'
|
||||||
|
|
||||||
|
_started = False
|
||||||
|
|
||||||
|
#: Name of the system Windows PostgreSQL service we temporarily stopped.
|
||||||
|
_stopped_system_service_name: str | None = None
|
||||||
|
|
||||||
|
logger = logging.getLogger('app.local_postgres')
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Binary discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_WIN_PG_ROOTS = [
|
||||||
|
pathlib.Path(os.environ.get('PROGRAMFILES', 'C:\\Program Files')) / 'PostgreSQL',
|
||||||
|
pathlib.Path(os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)')) / 'PostgreSQL',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_binary(name: str) -> pathlib.Path | None:
|
||||||
|
"""Locate a PostgreSQL binary (pg_ctl, initdb, psql)."""
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return pathlib.Path(found)
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
for root in _WIN_PG_ROOTS:
|
||||||
|
if not root.is_dir():
|
||||||
|
continue
|
||||||
|
versions = sorted(
|
||||||
|
(p for p in root.iterdir() if p.is_dir()),
|
||||||
|
key=lambda p: p.name,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for version_dir in versions:
|
||||||
|
candidate = version_dir / 'bin' / f'{name}.exe'
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Windows service management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Path to the Windows service controller.
|
||||||
|
_SC_EXE = pathlib.Path(os.environ.get('SYSTEMROOT', 'C:\\Windows')) / 'System32' / 'sc.exe'
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_system_service() -> str | None:
|
||||||
|
"""Find a running Windows PostgreSQL service installed by the EDB installer.
|
||||||
|
|
||||||
|
Returns the service name (e.g. ``postgresql-x64-18``) or None.
|
||||||
|
"""
|
||||||
|
if sys.platform != 'win32' or not _SC_EXE.is_file():
|
||||||
|
return None
|
||||||
|
for root in _WIN_PG_ROOTS:
|
||||||
|
if not root.is_dir():
|
||||||
|
continue
|
||||||
|
versions = sorted(
|
||||||
|
(p for p in root.iterdir() if p.is_dir()),
|
||||||
|
key=lambda p: p.name,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for version_dir in versions:
|
||||||
|
srv = f'postgresql-x64-{version_dir.name}'
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(_SC_EXE), 'query', srv],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and 'RUNNING' in result.stdout:
|
||||||
|
return srv
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_system_service(name: str):
|
||||||
|
"""Stop a Windows system service."""
|
||||||
|
print(f'[INFO] Stopping system PostgreSQL service "{name}" ...')
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[str(_SC_EXE), 'stop', name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError) as exc:
|
||||||
|
print(f'[WARNING] Could not stop service "{name}": {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
def _start_system_service(name: str):
|
||||||
|
"""Start a Windows system service."""
|
||||||
|
print(f'[INFO] Restarting system PostgreSQL service "{name}" ...')
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[str(_SC_EXE), 'start', name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError) as exc:
|
||||||
|
print(f'[WARNING] Could not restart service "{name}": {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data directory management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TRUST_HBA = """\
|
||||||
|
# Local development configuration — trust all local connections.
|
||||||
|
# Installed by app/local_postgres.py.
|
||||||
|
local all all trust
|
||||||
|
host all all 127.0.0.1/32 trust
|
||||||
|
host all all ::1/128 trust
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _init_cluster():
|
||||||
|
"""Create the data directory if it does not exist yet."""
|
||||||
|
if DATADIR.is_dir() and (DATADIR / 'PG_VERSION').is_file():
|
||||||
|
return
|
||||||
|
|
||||||
|
initdb = _find_binary('initdb')
|
||||||
|
if not initdb:
|
||||||
|
print(
|
||||||
|
'[ERROR] PostgreSQL not found. Install it from:',
|
||||||
|
'https://www.postgresql.org/download/windows/',
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'[INFO] Initialising PostgreSQL data directory at {DATADIR} ...')
|
||||||
|
DATADIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write a non-empty password to a temporary file. PostgreSQL >= 18
|
||||||
|
# rejects an empty password (NUL device), and we immediately overwrite
|
||||||
|
# pg_hba.conf with trust auth anyway, so the value is irrelevant.
|
||||||
|
pw_file = tempfile.NamedTemporaryFile( # noqa: SIM115 — delete=False needed for initdb --pwfile
|
||||||
|
mode='w',
|
||||||
|
delete=False,
|
||||||
|
suffix='.pw',
|
||||||
|
prefix='pg_',
|
||||||
|
)
|
||||||
|
pw_file.write('dev\n')
|
||||||
|
pw_file.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
str(initdb),
|
||||||
|
'--locale=C',
|
||||||
|
'--encoding=UTF8',
|
||||||
|
'--data-checksums',
|
||||||
|
'--username',
|
||||||
|
_USER,
|
||||||
|
'--pwfile',
|
||||||
|
pw_file.name,
|
||||||
|
'-D',
|
||||||
|
str(DATADIR),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print(f'[ERROR] initdb failed (exit {exc.returncode}):')
|
||||||
|
if exc.stderr:
|
||||||
|
print(exc.stderr)
|
||||||
|
if exc.stdout:
|
||||||
|
print(exc.stdout)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(pw_file.name)
|
||||||
|
|
||||||
|
_set_postgresql_conf()
|
||||||
|
|
||||||
|
hba_path = DATADIR / 'pg_hba.conf'
|
||||||
|
hba_path.write_text(_TRUST_HBA, encoding='utf-8')
|
||||||
|
print('[INFO] pg_hba.conf set to trust local connections.')
|
||||||
|
|
||||||
|
|
||||||
|
def _set_postgresql_conf():
|
||||||
|
"""Write postgresql.conf to match our development settings."""
|
||||||
|
conf_path = DATADIR / 'postgresql.conf'
|
||||||
|
if not conf_path.is_file():
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = conf_path.read_text(encoding='utf-8').splitlines()
|
||||||
|
lines.append('\n# ---- local_postgres.py overrides ----')
|
||||||
|
lines.append(f'port = {PORT}')
|
||||||
|
lines.append("listen_addresses = '127.0.0.1'")
|
||||||
|
lines.append("unix_socket_directories = ''")
|
||||||
|
lines.append('autovacuum = off') # avoid DLL conflicts with system PostgreSQL service
|
||||||
|
lines.append('logging_collector = off')
|
||||||
|
lines.append('log_destination = stderr')
|
||||||
|
conf_path.write_text('\n'.join(lines), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server start / stop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_ctl(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||||
|
"""Run ``pg_ctl`` with the data directory pre-filled."""
|
||||||
|
pg_ctl = _find_binary('pg_ctl')
|
||||||
|
if not pg_ctl:
|
||||||
|
raise RuntimeError('pg_ctl binary not found')
|
||||||
|
|
||||||
|
return subprocess.run(
|
||||||
|
[str(pg_ctl), *args, '-D', str(DATADIR), '-w'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=cwd or str(DATADIR),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _server_running() -> bool:
|
||||||
|
"""Return True if the server is already up on our port."""
|
||||||
|
try:
|
||||||
|
result = _pg_ctl(['status'])
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, RuntimeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _create_database():
|
||||||
|
"""Create the database if it does not exist."""
|
||||||
|
psql = _find_binary('psql')
|
||||||
|
if not psql:
|
||||||
|
raise RuntimeError('psql binary not found')
|
||||||
|
|
||||||
|
check = subprocess.run(
|
||||||
|
[
|
||||||
|
str(psql),
|
||||||
|
'-h',
|
||||||
|
'127.0.0.1',
|
||||||
|
'-p',
|
||||||
|
str(PORT),
|
||||||
|
'-U',
|
||||||
|
_USER,
|
||||||
|
'-d',
|
||||||
|
'postgres',
|
||||||
|
'-tAc',
|
||||||
|
f"SELECT 1 FROM pg_database WHERE datname = '{_DATABASE}'",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, 'PGPASSWORD': ''},
|
||||||
|
)
|
||||||
|
|
||||||
|
if check.stdout.strip() == '1':
|
||||||
|
print(f'[INFO] Database "{_DATABASE}" already exists.')
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
str(psql),
|
||||||
|
'-h',
|
||||||
|
'127.0.0.1',
|
||||||
|
'-p',
|
||||||
|
str(PORT),
|
||||||
|
'-U',
|
||||||
|
_USER,
|
||||||
|
'-d',
|
||||||
|
'postgres',
|
||||||
|
'-c',
|
||||||
|
f'CREATE DATABASE "{_DATABASE}"',
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, 'PGPASSWORD': ''},
|
||||||
|
)
|
||||||
|
print(f'[INFO] Created database "{_DATABASE}".')
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(*_args):
|
||||||
|
"""Stop the PostgreSQL server with a fast shutdown.
|
||||||
|
|
||||||
|
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
||||||
|
normal exit, and terminal close.
|
||||||
|
"""
|
||||||
|
global _started
|
||||||
|
global _stopped_system_service_name
|
||||||
|
if not _started:
|
||||||
|
return
|
||||||
|
_started = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _pg_ctl(['stop', '-m', 'fast'])
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.info('pg_ctl stop returned %d: %s', result.returncode, result.stderr.strip())
|
||||||
|
else:
|
||||||
|
logger.info('Local PostgreSQL stopped.')
|
||||||
|
except Exception as exc: # noqa: BLE001 — shutdown must never raise
|
||||||
|
logger.warning('Failed to stop local PostgreSQL: %s', exc)
|
||||||
|
|
||||||
|
# Restart the system service if we stopped it earlier.
|
||||||
|
if _stopped_system_service_name:
|
||||||
|
srv = _stopped_system_service_name
|
||||||
|
_stopped_system_service_name = None
|
||||||
|
_start_system_service(srv)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> str:
|
||||||
|
"""Start a local PostgreSQL server for development.
|
||||||
|
|
||||||
|
Returns the ``DATABASE_URL`` the application should use.
|
||||||
|
|
||||||
|
The caller is expected to set this on ``os.environ['DATABASE_URL']``
|
||||||
|
before invoking ``create_app()``.
|
||||||
|
|
||||||
|
This function is idempotent: if the server is already running and the
|
||||||
|
database exists, it returns immediately.
|
||||||
|
"""
|
||||||
|
global _started
|
||||||
|
global _stopped_system_service_name
|
||||||
|
|
||||||
|
# On Windows, temporarily stop the system PostgreSQL service so its child
|
||||||
|
# processes (autovacuum, etc.) do not collide with our local instance.
|
||||||
|
if _stopped_system_service_name is None: # only do this once per process
|
||||||
|
srv = _detect_system_service()
|
||||||
|
if srv:
|
||||||
|
_stop_system_service(srv)
|
||||||
|
_stopped_system_service_name = srv
|
||||||
|
|
||||||
|
# 1. Initialise if needed.
|
||||||
|
_init_cluster()
|
||||||
|
|
||||||
|
# 2. Start if not already running.
|
||||||
|
if not _server_running():
|
||||||
|
print(f'[INFO] Starting PostgreSQL on port {PORT} ...')
|
||||||
|
PG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pg_ctl = _find_binary('pg_ctl')
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
str(pg_ctl),
|
||||||
|
'start',
|
||||||
|
'-D',
|
||||||
|
str(DATADIR),
|
||||||
|
'-l',
|
||||||
|
str(PG_LOG),
|
||||||
|
'-w', # wait for readiness
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print('[ERROR] pg_ctl start failed.')
|
||||||
|
# Also show the server log — it often has the real error.
|
||||||
|
try:
|
||||||
|
log_text = PG_LOG.read_text(encoding='utf-8')
|
||||||
|
if log_text.strip():
|
||||||
|
print(f'Server log ({PG_LOG}):')
|
||||||
|
print(log_text)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
print(f'[INFO] PostgreSQL is up (pid in {PG_LOG}).')
|
||||||
|
else:
|
||||||
|
print(f'[INFO] PostgreSQL is already running on port {PORT}.')
|
||||||
|
|
||||||
|
# 3. Create the application database (idempotent).
|
||||||
|
_create_database()
|
||||||
|
|
||||||
|
# 4. Register cleanup.
|
||||||
|
if not _started:
|
||||||
|
_started = True
|
||||||
|
atexit.register(_stop)
|
||||||
|
with contextlib.suppress(ValueError, OSError):
|
||||||
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||||
|
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||||
|
return DATABASE_URL
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Global application settings stored as key-value pairs in the database."""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(db.Model):
|
||||||
|
"""Key-value store for global application settings.
|
||||||
|
|
||||||
|
Stores toggles and configuration that admins control through the
|
||||||
|
admin panel, such as whether tryouts are open, season state, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'app_settings'
|
||||||
|
|
||||||
|
key = db.Column(db.String(100), primary_key=True)
|
||||||
|
value = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Well-known keys (documented here for discoverability)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# tryouts_open – "true" / "false" (default: "true")
|
||||||
|
# document_uploads_enabled – "true" / "false" (default: "true")
|
||||||
|
# season_active – "true" / "false" (default: "false")
|
||||||
|
# season_name – e.g. "Fall 2026"
|
||||||
|
# season_start – ISO date string
|
||||||
|
# season_end – ISO date string
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(key, default=None):
|
||||||
|
"""Return the value for *key*, or *default* if not set."""
|
||||||
|
row = db.session.get(AppSettings, key)
|
||||||
|
return row.value if row is not None else default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set(key, value):
|
||||||
|
"""Upsert a setting."""
|
||||||
|
row = db.session.get(AppSettings, key)
|
||||||
|
if row is None:
|
||||||
|
row = AppSettings(key=key, value=str(value) if value is not None else None)
|
||||||
|
db.session.add(row)
|
||||||
|
else:
|
||||||
|
row.value = str(value) if value is not None else None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_bool(key, default=False):
|
||||||
|
"""Return a boolean setting."""
|
||||||
|
val = AppSettings.get(key)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_bool(key, value):
|
||||||
|
"""Store a boolean setting as 'true' / 'false'."""
|
||||||
|
AppSettings.set(key, 'true' if value else 'false')
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Audit log for tracking sensitive administrative actions."""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""Append-only log of critical admin actions for accountability."""
|
||||||
|
|
||||||
|
__tablename__ = 'audit_logs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
action = db.Column(db.String(100), nullable=False)
|
||||||
|
details = db.Column(db.Text, nullable=True)
|
||||||
|
ip_address = db.Column(db.String(64), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def record(user_id, action, details=None, ip_address=None):
|
||||||
|
"""Create a new audit log entry."""
|
||||||
|
entry = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Records of database backups created through the admin panel."""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRecord(db.Model):
|
||||||
|
"""Metadata for a database backup stored on disk."""
|
||||||
|
|
||||||
|
__tablename__ = 'backup_records'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
filename = db.Column(db.String(255), nullable=False)
|
||||||
|
file_path = db.Column(db.String(500), nullable=False)
|
||||||
|
size_bytes = db.Column(db.BigInteger, nullable=True)
|
||||||
|
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
|
||||||
@@ -1,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
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'))
|
||||||
+125
-61
@@ -26,11 +26,13 @@ from app.models import (
|
|||||||
PlayerDisponibility,
|
PlayerDisponibility,
|
||||||
Scout,
|
Scout,
|
||||||
Team,
|
Team,
|
||||||
|
TeamMatch,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.permissions import visible_org_teams
|
||||||
from app.services.scheduling import notify_participants, zip_participants
|
from app.services.scheduling import notify_participants, zip_participants
|
||||||
from app.validators import MatchEditSchema, MatchSchema
|
from app.validators import MatchEditSchema, MatchSchema
|
||||||
|
|
||||||
@@ -74,6 +76,25 @@ def default_end_time(date, start_time):
|
|||||||
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
|
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_event_time_fields(date, start_time, end_time):
|
||||||
|
"""FullCalendar date/time fields for a match.
|
||||||
|
|
||||||
|
A date-only event renders in the all-day row; a ``start``/``end`` with a
|
||||||
|
time places it on the clock for the same calendar day. Matches, team
|
||||||
|
matches and one-on-one sessions are stored as a date plus optional time
|
||||||
|
columns, so the feed translates that into the shape FullCalendar expects.
|
||||||
|
"""
|
||||||
|
if not start_time:
|
||||||
|
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
|
||||||
|
fields = {
|
||||||
|
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
|
||||||
|
'allDay': False,
|
||||||
|
}
|
||||||
|
if end_time:
|
||||||
|
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def create_participants(match, data):
|
def create_participants(match, data):
|
||||||
"""Attach participants to a match, per its type.
|
"""Attach participants to a match, per its type.
|
||||||
|
|
||||||
@@ -165,13 +186,17 @@ def api_events():
|
|||||||
president — and then issue one MatchParticipant query per match to find
|
president — and then issue one MatchParticipant query per match to find
|
||||||
out whether the viewer was in it. The calendar's cost grew with the
|
out whether the viewer was in it. The calendar's cost grew with the
|
||||||
whole history, on every navigation.
|
whole history, on every navigation.
|
||||||
|
|
||||||
|
Tryout matches, regular-season team matches and approved one-on-one
|
||||||
|
sessions share this feed. Timed rows carry `start`/`end` so
|
||||||
|
FullCalendar places them on the clock instead of the all-day row.
|
||||||
"""
|
"""
|
||||||
events = []
|
events = []
|
||||||
tryouts = get_visible_tryouts_for_user()
|
tryouts = get_visible_tryouts_for_user()
|
||||||
|
window_start, window_end = calendar_window(request.args)
|
||||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||||
|
|
||||||
if tryouts_by_id:
|
if tryouts_by_id:
|
||||||
window_start, window_end = calendar_window(request.args)
|
|
||||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||||
if window_start:
|
if window_start:
|
||||||
query = query.filter(Match.date >= window_start)
|
query = query.filter(Match.date >= window_start)
|
||||||
@@ -225,30 +250,71 @@ def api_events():
|
|||||||
|
|
||||||
user_participant = mine_by_match.get(match.id)
|
user_participant = mine_by_match.get(match.id)
|
||||||
|
|
||||||
events.append(
|
event = {
|
||||||
{
|
'id': f'match_{match.id}',
|
||||||
'id': f'match_{match.id}',
|
'title': match.title,
|
||||||
'title': match.title,
|
'type': 'match',
|
||||||
'date': match.date.strftime('%Y-%m-%d'),
|
'color': match_color,
|
||||||
'type': 'match',
|
'extendedProps': {
|
||||||
'color': match_color,
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'extendedProps': {
|
'status': match.status,
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'description': match.description or '',
|
||||||
'status': match.status,
|
'match_type': match.match_type,
|
||||||
'description': match.description or '',
|
'tryout_id': tryout.id,
|
||||||
'match_type': match.match_type,
|
'match_id': match.id,
|
||||||
'tryout_id': tryout.id,
|
'start_time': start_time_str,
|
||||||
'match_id': match.id,
|
'end_time': end_time_str,
|
||||||
'start_time': start_time_str,
|
'participants': participants_str,
|
||||||
'end_time': end_time_str,
|
'user_participant_id': user_participant.id if user_participant else None,
|
||||||
'participants': participants_str,
|
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||||
'user_participant_id': user_participant.id if user_participant else None,
|
if user_participant
|
||||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
else False,
|
||||||
if user_participant
|
},
|
||||||
else False,
|
}
|
||||||
},
|
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
|
||||||
}
|
events.append(event)
|
||||||
)
|
|
||||||
|
# Regular-season team matches: a separate table from tryout matches, but
|
||||||
|
# the same calendar. Visible to the roles that can see the org team.
|
||||||
|
if isinstance(current_user, (Admin, Manager)):
|
||||||
|
team_query = TeamMatch.query
|
||||||
|
elif isinstance(current_user, (Coach, Player)):
|
||||||
|
team_ids = [team.id for team in visible_org_teams(current_user)]
|
||||||
|
team_query = (
|
||||||
|
TeamMatch.query.filter(TeamMatch.org_team_id.in_(team_ids))
|
||||||
|
if team_ids
|
||||||
|
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
team_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||||
|
|
||||||
|
if window_start:
|
||||||
|
team_query = team_query.filter(TeamMatch.date >= window_start)
|
||||||
|
if window_end:
|
||||||
|
team_query = team_query.filter(TeamMatch.date <= window_end)
|
||||||
|
|
||||||
|
for team_match in team_query.options(joinedload(TeamMatch.org_team)).all():
|
||||||
|
start_time_str = team_match.start_time.strftime('%H:%M') if team_match.start_time else None
|
||||||
|
end_time_str = team_match.end_time.strftime('%H:%M') if team_match.end_time else None
|
||||||
|
event = {
|
||||||
|
'id': f'team_match_{team_match.id}',
|
||||||
|
'title': team_match.title,
|
||||||
|
'type': 'team_match',
|
||||||
|
'color': '#3b82f6',
|
||||||
|
'extendedProps': {
|
||||||
|
'location': team_match.location or 'TBD',
|
||||||
|
'status': team_match.status,
|
||||||
|
'description': team_match.description or '',
|
||||||
|
'org_team': team_match.org_team.name if team_match.org_team else '',
|
||||||
|
'opponent': team_match.opponent or '',
|
||||||
|
'start_time': start_time_str,
|
||||||
|
'end_time': end_time_str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
event.update(
|
||||||
|
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
|
||||||
|
)
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
# Add approved One on One sessions for the current user (player or coach)
|
# Add approved One on One sessions for the current user (player or coach)
|
||||||
if isinstance(current_user, Player):
|
if isinstance(current_user, Player):
|
||||||
@@ -263,23 +329,22 @@ def api_events():
|
|||||||
one_on_ones = []
|
one_on_ones = []
|
||||||
|
|
||||||
for ooo in one_on_ones:
|
for ooo in one_on_ones:
|
||||||
events.append(
|
event = {
|
||||||
{
|
'id': f'one_on_one_{ooo.id}',
|
||||||
'id': f'one_on_one_{ooo.id}',
|
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
'type': 'one_on_one',
|
||||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
'color': '#8b5cf6',
|
||||||
'type': 'one_on_one',
|
'extendedProps': {
|
||||||
'color': '#8b5cf6',
|
'location': 'Discord / Voice Chat',
|
||||||
'extendedProps': {
|
'status': 'approved',
|
||||||
'location': 'Discord / Voice Chat',
|
'description': ooo.points or 'One on One session',
|
||||||
'status': 'approved',
|
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||||
'description': ooo.points or 'One on One session',
|
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
},
|
||||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
}
|
||||||
},
|
event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
|
||||||
}
|
events.append(event)
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(events)
|
return jsonify(events)
|
||||||
|
|
||||||
@@ -351,25 +416,24 @@ def api_events_for_tryout(tryout_id):
|
|||||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||||
|
|
||||||
events.append(
|
event = {
|
||||||
{
|
'id': f'match_{match.id}',
|
||||||
'id': f'match_{match.id}',
|
'title': match.title,
|
||||||
'title': match.title,
|
'type': 'match',
|
||||||
'date': match.date.strftime('%Y-%m-%d'),
|
'color': match_color,
|
||||||
'type': 'match',
|
'extendedProps': {
|
||||||
'color': match_color,
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'extendedProps': {
|
'status': match.status,
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'match_type': match.match_type,
|
||||||
'status': match.status,
|
'tryout_id': tryout.id,
|
||||||
'match_type': match.match_type,
|
'match_id': match.id,
|
||||||
'tryout_id': tryout.id,
|
'participants': participants_str,
|
||||||
'match_id': match.id,
|
'start_time': start_time_str,
|
||||||
'participants': participants_str,
|
'end_time': end_time_str,
|
||||||
'start_time': start_time_str,
|
},
|
||||||
'end_time': end_time_str,
|
}
|
||||||
},
|
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
|
||||||
}
|
events.append(event)
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(events)
|
return jsonify(events)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
+246
-8
@@ -16,6 +16,7 @@ from app.models import (
|
|||||||
ESPORT_GAMES,
|
ESPORT_GAMES,
|
||||||
GAME_POSITIONS,
|
GAME_POSITIONS,
|
||||||
Admin,
|
Admin,
|
||||||
|
AppSettings,
|
||||||
Coach,
|
Coach,
|
||||||
Evaluation,
|
Evaluation,
|
||||||
Manager,
|
Manager,
|
||||||
@@ -37,6 +38,7 @@ from app.validators import (
|
|||||||
TryoutRegistrationStatusSchema,
|
TryoutRegistrationStatusSchema,
|
||||||
TryoutSchema,
|
TryoutSchema,
|
||||||
TryoutStatusSchema,
|
TryoutStatusSchema,
|
||||||
|
TryoutTeamMemberPositionSchema,
|
||||||
TryoutTeamMemberSchema,
|
TryoutTeamMemberSchema,
|
||||||
TryoutTeamSchema,
|
TryoutTeamSchema,
|
||||||
)
|
)
|
||||||
@@ -49,9 +51,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 +80,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 +140,13 @@ 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 +187,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 +207,13 @@ 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 +255,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')
|
||||||
@@ -240,9 +280,7 @@ def view_tryout(tryout_id):
|
|||||||
can_view = False
|
can_view = False
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Admin):
|
||||||
can_view = True
|
can_view = True
|
||||||
elif isinstance(current_user, Manager):
|
elif isinstance(current_user, (Manager, Coach)):
|
||||||
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
|
|
||||||
elif isinstance(current_user, Coach):
|
|
||||||
can_view = current_user.can_manage_this_tryout(tryout)
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
elif isinstance(current_user, Player):
|
elif isinstance(current_user, Player):
|
||||||
is_registered = (
|
is_registered = (
|
||||||
@@ -308,7 +346,12 @@ def view_tryout(tryout_id):
|
|||||||
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
||||||
for row in member_rows:
|
for row in member_rows:
|
||||||
members_by_team.setdefault(row.team_id, []).append(
|
members_by_team.setdefault(row.team_id, []).append(
|
||||||
{'player': member_players.get(row.player_id), 'position': row.position}
|
{
|
||||||
|
'player': member_players.get(row.player_id),
|
||||||
|
'position': row.position,
|
||||||
|
'member_id': row.id,
|
||||||
|
'player_id': row.player_id,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
|
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
|
||||||
|
|
||||||
@@ -466,6 +509,13 @@ 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 +536,13 @@ 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 +567,13 @@ 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 +621,13 @@ 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 +664,13 @@ 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 +694,13 @@ 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))
|
||||||
@@ -653,11 +738,164 @@ def add_to_team(tryout_id, team_id):
|
|||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_team(tryout_id, team_id):
|
||||||
|
"""Rename a tryout-specific team."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
team.name = data['team_name']
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_team(tryout_id, team_id):
|
||||||
|
"""Delete a tryout-specific team and its memberships."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
name = team.name
|
||||||
|
|
||||||
|
# Personal note references outlive the team.
|
||||||
|
PersonalNote.query.filter_by(team_id=team_id).update(
|
||||||
|
{'team_id': None}, synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Matches referencing this team: null the FK so the match survives.
|
||||||
|
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
|
||||||
|
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
|
||||||
|
|
||||||
|
# Clean participants for those matches (they are no longer in that team).
|
||||||
|
for match in Match.query.filter(
|
||||||
|
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
|
||||||
|
).all():
|
||||||
|
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
|
||||||
|
db.session.delete(team)
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Team "%(name)s" deleted.', name=name), 'success')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def remove_from_team(tryout_id, team_id, player_id):
|
||||||
|
"""Remove a player from a tryout team."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
# Also remove the player from any match participants that were tied to this team.
|
||||||
|
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||||
|
if match_ids:
|
||||||
|
MatchParticipant.query.filter(
|
||||||
|
MatchParticipant.match_id.in_(match_ids),
|
||||||
|
MatchParticipant.player_id == player_id,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Player removed from team.'), 'success')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route(
|
||||||
|
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
def update_member_position(tryout_id, team_id, member_id):
|
||||||
|
"""Set or clear a team member's in-game position (role)."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
member = db.get_or_404(TeamMember, member_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id or member.team_id != team_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
member.position = data['position'] or None
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Position updated.'), 'success')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_tryout(tryout_id):
|
def delete_tryout(tryout_id):
|
||||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||||
tryout = 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'))
|
||||||
|
|||||||
-1270
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
|
|||||||
sessions.
|
sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
from flask import jsonify, request
|
||||||
from flask_babel import gettext as _
|
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
@@ -201,60 +200,50 @@ def delete_disponibility(disponibility_id):
|
|||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
@users_bp.route('/coach-availability', methods=['POST'])
|
||||||
@json_endpoint
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def manage_coach_availability():
|
def manage_coach_availability():
|
||||||
"""Manage coach availability for One on One sessions."""
|
"""Manage coach availability for One on One sessions."""
|
||||||
if not isinstance(current_user, Coach):
|
if not isinstance(current_user, Coach):
|
||||||
flash(_('Only coaches can manage availability.'), 'danger')
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
data = request.get_json(silent=True) or {}
|
||||||
data = request.get_json(silent=True) or {}
|
accepted, rejected = _load_slots(data.get('slots'))
|
||||||
accepted, rejected = _load_slots(data.get('slots'))
|
|
||||||
|
|
||||||
# Validate everything before deleting anything.
|
# Validate everything before deleting anything.
|
||||||
#
|
#
|
||||||
# This route replaces the coach's availability: it deleted every
|
# This route replaces the coach's availability: it deleted every
|
||||||
# existing slot and then re-added the ones it could parse, skipping
|
# existing slot and then re-added the ones it could parse, skipping
|
||||||
# the rest in silence and answering `{'success': true}`. A payload
|
# the rest in silence and answering `{'success': true}`. A payload
|
||||||
# the browser mangled therefore wiped a coach's bookable hours and
|
# the browser mangled therefore wiped a coach's bookable hours and
|
||||||
# reported success — and one-on-one requests are refused against
|
# reported success — and one-on-one requests are refused against
|
||||||
# exactly this table, so the coach became unbookable with nothing to
|
# exactly this table, so the coach became unbookable with nothing to
|
||||||
# show for it. Refusing the whole batch is the only safe answer when
|
# show for it. Refusing the whole batch is the only safe answer when
|
||||||
# the operation is a replacement (MNT-12).
|
# the operation is a replacement (MNT-12).
|
||||||
if rejected:
|
if rejected:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
'error': 'Invalid slots; nothing was changed.',
|
'error': 'Invalid slots; nothing was changed.',
|
||||||
'rejected': rejected,
|
'rejected': rejected,
|
||||||
}
|
}
|
||||||
), 400
|
), 400
|
||||||
|
|
||||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||||
|
|
||||||
for slot in accepted:
|
for slot in accepted:
|
||||||
start_time = slot['start_time']
|
start_time = slot['start_time']
|
||||||
db.session.add(
|
db.session.add(
|
||||||
CoachAvailability(
|
CoachAvailability(
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
day_of_week=slot['day_of_week'],
|
day_of_week=slot['day_of_week'],
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=slot_end(start_time),
|
end_time=slot_end(start_time),
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'success': True, 'saved': len(accepted)})
|
return jsonify({'success': True, 'saved': len(accepted)})
|
||||||
|
|
||||||
existing_availability = CoachAvailability.query.filter_by(
|
|
||||||
coach_id=current_user.id,
|
|
||||||
).all()
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'pages/coach_availability.html', existing_availability=existing_availability
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -346,6 +346,14 @@ function showEventModal(event) {
|
|||||||
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
|
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Team match details
|
||||||
|
if (type === 'team_match') {
|
||||||
|
grid.appendChild(detailItem('Team', document.createTextNode(props.org_team || 'Team')));
|
||||||
|
if (props.opponent) {
|
||||||
|
grid.appendChild(detailItem('Opponent', document.createTextNode(props.opponent)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (props.description) {
|
if (props.description) {
|
||||||
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
{% extends "layouts/base.html" %}
|
|
||||||
{% block title %}{{ _('Manage Availability') }} - UdeS team manager{% endblock %}
|
|
||||||
{% block page_title %}{{ _('Manage Availability') }}{% endblock %}
|
|
||||||
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3><i class="fas fa-clock"></i> {{ _('Set Your Weekly Availability') }}</h3>
|
|
||||||
<p class="text-muted small">{{ _('Select time slots when you\'re available for One on One sessions') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="availability-grid" id="availability-grid">
|
|
||||||
<p class="text-muted">{{ _('Loading availability grid...') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions mt-4">
|
|
||||||
<button type="button" class="btn btn-primary" data-action="save-availability">
|
|
||||||
<i class="fas fa-save"></i> {{ _('Save Availability') }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
|
||||||
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<style>
|
|
||||||
.availability-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-column {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-header {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot {
|
|
||||||
padding: 6px 8px;
|
|
||||||
margin: 4px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot:hover {
|
|
||||||
background: var(--primary-light);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot.selected {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slot.selected:hover {
|
|
||||||
background: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.availability-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.availability-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script nonce="{{ csp_nonce }}">
|
|
||||||
// Time slots from 8:00 AM to 10:00 PM (30-minute intervals)
|
|
||||||
const TIME_SLOTS = [];
|
|
||||||
for (let h = 8; h <= 22; h++) {
|
|
||||||
for (let m = 0; m < 60; m += 30) {
|
|
||||||
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
|
||||||
const displayHour = h > 12 ? h - 12 : h;
|
|
||||||
const displayAmpm = h >= 12 ? 'PM' : 'AM';
|
|
||||||
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
|
|
||||||
TIME_SLOTS.push({ time: timeStr, display: displayTime });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Day names
|
|
||||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
||||||
|
|
||||||
// Track selected slots: {day_of_week: [time_strings]}
|
|
||||||
let selectedSlots = {};
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
loadExistingAvailability();
|
|
||||||
renderGrid();
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadExistingAvailability() {
|
|
||||||
// Load from existing data
|
|
||||||
{% for av in existing_availability %}
|
|
||||||
if (!selectedSlots[{{ av.day_of_week }}]) {
|
|
||||||
selectedSlots[{{ av.day_of_week }}] = [];
|
|
||||||
}
|
|
||||||
selectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
|
|
||||||
{% endfor %}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGrid() {
|
|
||||||
const grid = document.getElementById('availability-grid');
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
DAYS.forEach((day, dayIndex) => {
|
|
||||||
html += '<div class="day-column">';
|
|
||||||
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
|
|
||||||
|
|
||||||
TIME_SLOTS.forEach(slot => {
|
|
||||||
const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time);
|
|
||||||
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
|
|
||||||
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" data-action="toggle-slot">' + slot.display + '</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSlot(element) {
|
|
||||||
const dayOfWeek = Number(element.getAttribute('data-day'));
|
|
||||||
const timeStr = element.getAttribute('data-time');
|
|
||||||
if (!selectedSlots[dayOfWeek]) {
|
|
||||||
selectedSlots[dayOfWeek] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = selectedSlots[dayOfWeek].indexOf(timeStr);
|
|
||||||
if (index === -1) {
|
|
||||||
selectedSlots[dayOfWeek].push(timeStr);
|
|
||||||
element.classList.add('selected');
|
|
||||||
} else {
|
|
||||||
selectedSlots[dayOfWeek].splice(index, 1);
|
|
||||||
element.classList.remove('selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAvailability() {
|
|
||||||
const slots = [];
|
|
||||||
for (let day in selectedSlots) {
|
|
||||||
selectedSlots[day].forEach(time => {
|
|
||||||
slots.push({ day_of_week: parseInt(day), start_time: time });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('{{ url_for("users.manage_coach_availability") }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ slots: slots })
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
flash('Availability saved!', 'success');
|
|
||||||
} else {
|
|
||||||
flash(data.error || 'Error saving availability.', 'danger');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(error) {
|
|
||||||
console.error('Save error:', error);
|
|
||||||
flash('Error saving availability.', 'danger');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllAvailability() {
|
|
||||||
if (!confirm('Are you sure you want to clear all your availability slots?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('{{ url_for("users.clear_coach_availability") }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
selectedSlots = {};
|
|
||||||
renderGrid();
|
|
||||||
flash('Availability cleared!', 'success');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash(message, type) {
|
|
||||||
const flashContainer = document.querySelector('.flash-messages');
|
|
||||||
const alert = document.createElement('div');
|
|
||||||
alert.className = 'alert alert-' + type + ' alert-dismissible';
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent = message;
|
|
||||||
const close = document.createElement('button');
|
|
||||||
close.type = 'button';
|
|
||||||
close.className = 'alert-close';
|
|
||||||
close.dataset.action = 'remove-element';
|
|
||||||
close.textContent = '×';
|
|
||||||
alert.append(text, close);
|
|
||||||
flashContainer.appendChild(alert);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-save on change (debounced)
|
|
||||||
let saveTimeout;
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.classList.contains('time-slot')) {
|
|
||||||
clearTimeout(saveTimeout);
|
|
||||||
saveTimeout = setTimeout(saveAvailability, 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Behaviours are declared in the markup with data-action / data-change and
|
|
||||||
// dispatched by the delegated listener in main.js. This replaces inline
|
|
||||||
// onclick attributes, which no CSP nonce is able to authorise.
|
|
||||||
registerActions({
|
|
||||||
'save-availability': saveAvailability,
|
|
||||||
'clear-availability': clearAllAvailability,
|
|
||||||
'toggle-slot': toggleSlot,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -94,8 +94,8 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -393,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
loadCoachAvailability();
|
loadCoachAvailability();
|
||||||
renderCoachGrid();
|
renderCoachGrid();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Behaviours are declared in the markup with data-action / data-change and
|
||||||
|
// dispatched by the delegated listener in main.js. This replaces inline
|
||||||
|
// onclick attributes, which no CSP nonce is able to authorise.
|
||||||
|
registerActions({
|
||||||
|
'clear-availability': clearAllAvailability,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -608,7 +615,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// onclick attributes, which no CSP nonce is able to authorise.
|
// onclick attributes, which no CSP nonce is able to authorise.
|
||||||
registerActions({
|
registerActions({
|
||||||
'clear-disponibilities': clearDisponibilities,
|
'clear-disponibilities': clearDisponibilities,
|
||||||
'clear-availability': clearAllAvailability,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -243,7 +249,14 @@
|
|||||||
|
|
||||||
{% for team in team_data %}
|
{% for team in team_data %}
|
||||||
<div class="team-card mb-3">
|
<div class="team-card mb-3">
|
||||||
<h4 class="team-name">{{ team.team.name }}</h4>
|
<div class="team-card-header">
|
||||||
|
<h4 class="team-name">{{ team.team.name }}</h4>
|
||||||
|
{% if can_edit and not tryout.is_ended %}
|
||||||
|
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
|
||||||
|
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<ul class="team-members">
|
<ul class="team-members">
|
||||||
{% for member in team.members %}
|
{% for member in team.members %}
|
||||||
<li>
|
<li>
|
||||||
@@ -257,32 +270,96 @@
|
|||||||
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if can_edit and not tryout.is_ended and registered_players %}
|
|
||||||
{% set positions = game_positions.get(tryout.game, []) %}
|
|
||||||
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
||||||
<select name="player_id" class="form-select" required>
|
|
||||||
<option value="">{{ _('Select player...') }}</option>
|
|
||||||
{% for p in registered_players %}
|
|
||||||
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
|
||||||
<option value="{{ p.id }}">{{ p.username }}</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% if positions %}
|
|
||||||
<select name="position" class="form-select mx-2">
|
|
||||||
<option value="">{{ _('-- Select Position --') }}</option>
|
|
||||||
{% for pos in positions %}
|
|
||||||
<option value="{{ pos }}">{{ pos }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% else %}
|
|
||||||
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2">
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit and not tryout.is_ended %}
|
||||||
|
{% set positions = game_positions.get(tryout.game, []) %}
|
||||||
|
<!-- Edit Team Modal -->
|
||||||
|
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
|
||||||
|
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Rename -->
|
||||||
|
<form method="POST" action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<label for="renameInput{{ team.team.id }}" class="mr-2" style="white-space:nowrap;">{{ _('Team name') }}</label>
|
||||||
|
<input type="text" id="renameInput{{ team.team.id }}" name="team_name" value="{{ team.team.name }}" required class="form-input" style="flex:1;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary ml-2">{{ _('Rename') }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<!-- Roster -->
|
||||||
|
<h5 class="mb-2"><i class="fas fa-users"></i> {{ _('Roster') }}</h5>
|
||||||
|
<div class="roster-rows">
|
||||||
|
{% for member in team.members %}
|
||||||
|
<div class="roster-row">
|
||||||
|
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
|
||||||
|
<span class="roster-player-name">{{ member.player.username }}</span>
|
||||||
|
<form method="POST" action="{{ url_for('tryouts.update_member_position', tryout_id=tryout.id, team_id=team.team.id, member_id=member.member_id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<select name="position" class="form-select roster-role-select" data-submit-on-change>
|
||||||
|
<option value="">{{ _('No role') }}</option>
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}" {% if member.position == pos %}selected{% endif %}>{{ pos }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('tryouts.remove_from_team', tryout_id=tryout.id, team_id=team.team.id, player_id=member.player_id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from this team?', player=member.player.username) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger roster-remove-btn" title="{{ _('Remove from team') }}">
|
||||||
|
<i class="fas fa-user-minus"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">{{ _('No players assigned yet.') }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if registered_players %}
|
||||||
|
<hr class="section-divider">
|
||||||
|
<h5 class="mb-2"><i class="fas fa-user-plus"></i> {{ _('Add Player') }}</h5>
|
||||||
|
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<select name="player_id" class="form-select" required style="flex:1;">
|
||||||
|
<option value="">{{ _('Select player...') }}</option>
|
||||||
|
{% for p in registered_players %}
|
||||||
|
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
||||||
|
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if positions %}
|
||||||
|
<select name="position" class="form-select mx-2">
|
||||||
|
<option value="">{{ _('-- Select Position --') }}</option>
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}">{{ pos }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="section-divider" style="border-color: var(--danger);">
|
||||||
|
<div class="text-center">
|
||||||
|
<form method="POST" action="{{ url_for('tryouts.delete_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="inline-form" data-confirm="{{ _('Delete this team? Players already in the team will be removed and matches referencing it will be unlinked.') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
|
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -452,6 +529,12 @@
|
|||||||
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
||||||
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||||
</a>
|
</a>
|
||||||
|
<form method="POST" action="{{ url_for('matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
|
||||||
|
<i class="fas fa-trash"></i> {{ _('Delete') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
|
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
|
||||||
<i class="fas fa-sticky-note"></i> {{ _('Note') }}
|
<i class="fas fa-sticky-note"></i> {{ _('Note') }}
|
||||||
@@ -534,6 +617,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<style>
|
<style>
|
||||||
.presence-toggle-btn {
|
.presence-toggle-btn {
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
@@ -559,8 +645,56 @@
|
|||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||||
}
|
}
|
||||||
|
/* Team card header — edit button aligned right */
|
||||||
|
.team-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.team-card-header .team-name {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
/* Roster rows inside modal */
|
||||||
|
.roster-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.roster-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.roster-player-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.roster-role-select {
|
||||||
|
width: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
.roster-remove-btn {
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .roster-row {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script nonce="{{ csp_nonce }}">
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
function showModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
|
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
|
||||||
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
||||||
|
|
||||||
@@ -618,6 +752,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
registerActions({
|
registerActions({
|
||||||
'show-create-team': showCreateTeam,
|
'show-create-team': showCreateTeam,
|
||||||
'hide-create-team': hideCreateTeam,
|
'hide-create-team': hideCreateTeam,
|
||||||
|
'show-modal': function (element) {
|
||||||
|
showModal(element.getAttribute('data-modal-id'));
|
||||||
|
},
|
||||||
|
'hide-modal': function (element) {
|
||||||
|
hideModal(element.getAttribute('data-modal-id'));
|
||||||
|
},
|
||||||
'toggle-tryout-presence': function (element) {
|
'toggle-tryout-presence': function (element) {
|
||||||
toggleTryoutPresence(
|
toggleTryoutPresence(
|
||||||
element.getAttribute('data-match-id'),
|
element.getAttribute('data-match-id'),
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -605,6 +605,18 @@ class TryoutTeamMemberSchema(StripMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutTeamMemberPositionSchema(StripMixin):
|
||||||
|
"""Update a team member's in-game position (role)."""
|
||||||
|
|
||||||
|
position = fields.String(
|
||||||
|
load_default='',
|
||||||
|
validate=validate.Length(
|
||||||
|
max=50,
|
||||||
|
error=_l('Position must be 50 characters or less.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NoteContentSchema(StripMixin):
|
class NoteContentSchema(StripMixin):
|
||||||
"""Bounded text stored as a team or personal coaching note."""
|
"""Bounded text stored as a team or personal coaching note."""
|
||||||
|
|
||||||
@@ -949,6 +961,7 @@ class TryoutSchema(StripMixin):
|
|||||||
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
||||||
manager_id = fields.Integer(allow_none=True, load_default=None)
|
manager_id = fields.Integer(allow_none=True, load_default=None)
|
||||||
coach_ids = fields.List(fields.Integer(), load_default=list)
|
coach_ids = fields.List(fields.Integer(), load_default=list)
|
||||||
|
manager_ids = fields.List(fields.Integer(), load_default=list)
|
||||||
|
|
||||||
@validates_schema
|
@validates_schema
|
||||||
def validate_span(self, data, **kwargs):
|
def validate_span(self, data, **kwargs):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ was marked by the automated pass. Jinja escaped it to `&times;`, so
|
|||||||
the button would have shown the literal text `×` instead of ×.
|
the button would have shown the literal text `×` instead of ×.
|
||||||
Anything that is markup rather than prose must stay out of `_()`.
|
Anything that is markup rather than prose must stay out of `_()`.
|
||||||
|
|
||||||
**Strings inside `<script>` blocks.** Eight remain, in `match_form.html`,
|
**Strings inside `<script>` blocks.** Seven remain, in `match_form.html` and
|
||||||
`one_on_one.html` and `coach_availability.html`. They work, but they are
|
`one_on_one.html`. They work, but they are
|
||||||
fragile: Jinja escapes `& < > " '` inside a script block, and those
|
fragile: Jinja escapes `& < > " '` inside a script block, and those
|
||||||
entities are *not* decoded there — a translation containing an apostrophe
|
entities are *not* decoded there — a translation containing an apostrophe
|
||||||
would land in the JavaScript string as `'`. The French wording uses
|
would land in the JavaScript string as `'`. The French wording uses
|
||||||
|
|||||||
+1
-1
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,9 +25,53 @@ import sys
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from app.app import create_app # noqa: E402
|
from app.app import create_app # noqa: E402
|
||||||
|
from app.extensions import db as _db # noqa: E402
|
||||||
|
from app.extensions import hash_password # noqa: E402
|
||||||
|
from app.local_postgres import start as start_local_pg # noqa: E402
|
||||||
|
from app.models import Admin, Coach, Manager, Player # noqa: E402
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Local PostgreSQL (development only)
|
||||||
|
# ==========================================================================
|
||||||
|
if os.getenv('LOCAL_PG', '').lower() == 'true':
|
||||||
|
os.environ['DATABASE_URL'] = start_local_pg()
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
# --- Seed the database with default test users (idempotent) ---------
|
||||||
|
with app.app_context():
|
||||||
|
if not Admin.query.first():
|
||||||
|
print('[INFO] Seeding database with default test users ...')
|
||||||
|
users = [
|
||||||
|
Admin(username='admin', role='admin', full_name='Admin', email='[email protected]'),
|
||||||
|
Coach(
|
||||||
|
username='coach',
|
||||||
|
role='coach',
|
||||||
|
full_name='Coach Test',
|
||||||
|
email='[email protected]',
|
||||||
|
),
|
||||||
|
Manager(
|
||||||
|
username='manager',
|
||||||
|
role='manager',
|
||||||
|
full_name='Manager Test',
|
||||||
|
email='[email protected]',
|
||||||
|
),
|
||||||
|
Player(
|
||||||
|
username='player',
|
||||||
|
role='player',
|
||||||
|
full_name='Player Test',
|
||||||
|
email='[email protected]',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for u in users:
|
||||||
|
u.password_hash = hash_password('password')
|
||||||
|
_db.session.add(u)
|
||||||
|
_db.session.commit()
|
||||||
|
print('[INFO] Seeded 4 default users (password for all: password).')
|
||||||
|
|
||||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
app.logger.warning(
|
app.logger.warning(
|
||||||
|
|||||||
@@ -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}'
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+103
-3
@@ -168,7 +168,7 @@ class TestTheWindow:
|
|||||||
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
|
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
|
||||||
).get_json()
|
).get_json()
|
||||||
|
|
||||||
assert _match_event(payload)['date'] == '2030-05-02'
|
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
||||||
|
|
||||||
def test_a_match_outside_the_window_is_not(
|
def test_a_match_outside_the_window_is_not(
|
||||||
self, app, client, as_role, make_user, match_factory
|
self, app, client, as_role, make_user, match_factory
|
||||||
@@ -192,7 +192,7 @@ class TestTheWindow:
|
|||||||
|
|
||||||
payload = client.get('/matches/api/events').get_json()
|
payload = client.get('/matches/api/events').get_json()
|
||||||
|
|
||||||
assert _match_event(payload)['date'] == '2030-05-02'
|
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
||||||
|
|
||||||
def test_unparseable_bounds_are_ignored_rather_than_rejected(
|
def test_unparseable_bounds_are_ignored_rather_than_rejected(
|
||||||
self, app, client, as_role, make_user, match_factory
|
self, app, client, as_role, make_user, match_factory
|
||||||
@@ -206,7 +206,7 @@ class TestTheWindow:
|
|||||||
response = client.get('/matches/api/events?start=whenever&end=')
|
response = client.get('/matches/api/events?start=whenever&end=')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert _match_event(response.get_json())['date'] == '2030-05-02'
|
assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
|
||||||
|
|
||||||
def test_the_viewers_own_participation_still_comes_back(
|
def test_the_viewers_own_participation_still_comes_back(
|
||||||
self, app, client, as_role, make_user, match_factory
|
self, app, client, as_role, make_user, match_factory
|
||||||
@@ -258,3 +258,103 @@ class TestTheWindowParser:
|
|||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
assert calendar_window(request.args) == (None, None)
|
assert calendar_window(request.args) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimedEvents:
|
||||||
|
"""Matches with time fields render on the clock, not as all-day."""
|
||||||
|
|
||||||
|
def test_timed_match_has_start_and_end(self, app, client, as_role, make_user, match_factory):
|
||||||
|
player_id = make_user('player')
|
||||||
|
admin_id = as_role('admin')
|
||||||
|
match_factory(admin_id, player_id)
|
||||||
|
|
||||||
|
ev = _match_event(client.get('/matches/api/events').get_json())
|
||||||
|
|
||||||
|
assert ev['start'] == '2030-05-02T18:00:00'
|
||||||
|
assert ev['end'] == '2030-05-02T19:00:00'
|
||||||
|
assert ev['allDay'] is False
|
||||||
|
|
||||||
|
def test_allday_match_has_date_when_no_time(
|
||||||
|
self, app, client, as_role, make_user, match_factory
|
||||||
|
):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Match, MatchParticipant, Tryout, TryoutRegistration
|
||||||
|
|
||||||
|
player_id = make_user('player')
|
||||||
|
admin_id = as_role('admin')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='All-day tryout',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 5, 1),
|
||||||
|
created_by=admin_id,
|
||||||
|
status='upcoming',
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||||
|
|
||||||
|
match = Match(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
title='All-day scrim',
|
||||||
|
description=None,
|
||||||
|
date=date(2030, 5, 2),
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
|
match_type='player_scrim',
|
||||||
|
created_by=admin_id,
|
||||||
|
)
|
||||||
|
db.session.add(match)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
payload = client.get('/matches/api/events').get_json()
|
||||||
|
ev = next(e for e in payload if e['title'] == 'All-day scrim')
|
||||||
|
|
||||||
|
assert ev['date'] == '2030-05-02'
|
||||||
|
assert ev['allDay'] is True
|
||||||
|
assert 'start' not in ev
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeamMatchCalendarEvents:
|
||||||
|
"""Regular-season team matches appear on the main calendar."""
|
||||||
|
|
||||||
|
def test_team_matches_appear_for_admin(self, app, client, as_role, make_user):
|
||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import OrgTeam, TeamMatch
|
||||||
|
|
||||||
|
admin_id = as_role('admin')
|
||||||
|
with app.app_context():
|
||||||
|
team = OrgTeam(name='Test Org', created_by=admin_id)
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.flush()
|
||||||
|
tm = TeamMatch(
|
||||||
|
org_team_id=team.id,
|
||||||
|
title='Semifinal',
|
||||||
|
opponent='Enemy Team',
|
||||||
|
date=date(2030, 6, 15),
|
||||||
|
start_time=time(20, 0),
|
||||||
|
end_time=time(21, 30),
|
||||||
|
location='Main arena',
|
||||||
|
status='scheduled',
|
||||||
|
created_by=admin_id,
|
||||||
|
)
|
||||||
|
db.session.add(tm)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
payload = client.get('/matches/api/events').get_json()
|
||||||
|
team_events = [e for e in payload if e['type'] == 'team_match']
|
||||||
|
|
||||||
|
assert len(team_events) == 1
|
||||||
|
ev = team_events[0]
|
||||||
|
assert ev['title'] == 'Semifinal'
|
||||||
|
assert ev['start'] == '2030-06-15T20:00:00'
|
||||||
|
assert ev['allDay'] is False
|
||||||
|
assert ev['extendedProps']['org_team'] == 'Test Org'
|
||||||
|
assert ev['extendedProps']['opponent'] == 'Enemy Team'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -126,14 +126,6 @@ class TestInlineHandlerRatchet:
|
|||||||
assert "' + playerName + '" not in content
|
assert "' + playerName + '" not in content
|
||||||
assert content.count('escapeHtml(playerName)') == 6
|
assert content.count('escapeHtml(playerName)') == 6
|
||||||
|
|
||||||
def test_api_messages_are_written_as_text(self):
|
|
||||||
template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html')
|
|
||||||
with open(template, encoding='utf-8') as handle:
|
|
||||||
content = handle.read()
|
|
||||||
|
|
||||||
assert 'text.textContent = message' in content
|
|
||||||
assert "alert.innerHTML = '<span>' + message" not in content
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('relative,full', list(_templates()))
|
@pytest.mark.parametrize('relative,full', list(_templates()))
|
||||||
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
||||||
allowed = HANDLER_BUDGET.get(relative, 0)
|
allowed = HANDLER_BUDGET.get(relative, 0)
|
||||||
|
|||||||
+14
-17
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user