Compare commits
11
Commits
e15b3c1293
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38defc53a5 | ||
|
|
d18979a3e9 | ||
|
|
1549fbaef3 | ||
|
|
c72ed9b0b1 | ||
|
|
6232b77094 | ||
|
|
a616c79663 | ||
|
|
48ca62cdd0 | ||
|
|
1bef716a12 | ||
|
|
a0e31d1a2f | ||
|
|
9fc4ba0b98 | ||
|
|
53e390672e |
+20
-9
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||
|
||||
# =============================================================================
|
||||
# Optional — storage
|
||||
# Contract storage — Google Drive
|
||||
# =============================================================================
|
||||
|
||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
||||
# to a release-directory layout, or a deployment will take the documents with
|
||||
# it (OPS-011, app/storage.py).
|
||||
#
|
||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
||||
# did not, and archived `./documents` regardless — so setting this here and
|
||||
# nowhere else produced empty contract backups that still exited 0.
|
||||
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
|
||||
# refresh token, and folder ID are secrets/configuration: do not commit them.
|
||||
# Use `local` only for isolated development and legacy-file maintenance.
|
||||
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||
|
||||
# Target folder in the owner's personal Drive. The application keeps every
|
||||
# contract directly in this folder and stores only each Drive file ID in the
|
||||
# database.
|
||||
GOOGLE_DRIVE_FOLDER_ID=
|
||||
|
||||
# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
|
||||
# Desktop OAuth client, authorize the Drive scope once, and place the resulting
|
||||
# refresh token here. See docs/deployment.md before enabling this in production.
|
||||
GOOGLE_DRIVE_CLIENT_ID=
|
||||
GOOGLE_DRIVE_CLIENT_SECRET=
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN=
|
||||
|
||||
# Local storage is retained only for historical rows and local test runs.
|
||||
# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
|
||||
DOCUMENTS_ROOT=
|
||||
|
||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
||||
|
||||
@@ -411,6 +411,7 @@ def create_app(config=None):
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.users import users_bp
|
||||
from app.routes.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
@@ -420,6 +421,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
app.register_blueprint(team_matches_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Google Drive storage for contract PDFs.
|
||||
|
||||
The application uses an OAuth refresh token for the owner's personal Google
|
||||
account. Tokens and client secrets come only from environment variables; the
|
||||
database stores opaque file IDs, never a credential or a shareable Drive URL.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
|
||||
GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
|
||||
GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
|
||||
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
|
||||
class GoogleDriveStorageError(RuntimeError):
|
||||
"""A configuration or API failure while storing a contract in Drive."""
|
||||
|
||||
|
||||
def _setting(name):
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise GoogleDriveStorageError(f'{name} must be configured for Google Drive document storage.')
|
||||
return value
|
||||
|
||||
|
||||
def _drive_service():
|
||||
"""Build an authorized Drive client from the owner's refresh token."""
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError as exc:
|
||||
raise GoogleDriveStorageError(
|
||||
'Google Drive dependencies are not installed. Install requirements.txt again.'
|
||||
) from exc
|
||||
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
|
||||
client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
|
||||
scopes=[GOOGLE_DRIVE_SCOPE],
|
||||
)
|
||||
return build('drive', 'v3', credentials=credentials, cache_discovery=False)
|
||||
|
||||
|
||||
def upload_file(*, stream, filename, mimetype):
|
||||
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseUpload
|
||||
|
||||
stream.seek(0)
|
||||
media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
|
||||
response = (
|
||||
_drive_service()
|
||||
.files()
|
||||
.create(
|
||||
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
||||
media_body=media,
|
||||
fields='id',
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
|
||||
|
||||
file_id = response.get('id')
|
||||
if not file_id:
|
||||
raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
|
||||
return file_id
|
||||
|
||||
|
||||
def download_file(file_id):
|
||||
"""Download a Drive file into memory for Flask's authenticated response."""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
destination = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(
|
||||
destination,
|
||||
_drive_service().files().get_media(fileId=file_id),
|
||||
)
|
||||
complete = False
|
||||
while not complete:
|
||||
_status, complete = downloader.next_chunk()
|
||||
return destination.getvalue()
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
|
||||
|
||||
|
||||
def delete_file(file_id):
|
||||
"""Permanently delete a Drive document when its contract record is deleted."""
|
||||
try:
|
||||
_drive_service().files().delete(fileId=file_id).execute()
|
||||
except GoogleDriveStorageError:
|
||||
raise
|
||||
except Exception as exc: # Google client exceptions share no stable base class.
|
||||
raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -20,6 +20,7 @@ from app.models._constants import (
|
||||
GAME_PLATFORMS,
|
||||
PLATFORM_CODES,
|
||||
TRN_URLS,
|
||||
EVALUATION_CRITERIA,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -93,3 +94,6 @@ from app.models.contract import Contract
|
||||
from app.models.team_note import TeamNote
|
||||
from app.models.personal_note import PersonalNote
|
||||
from app.models.one_on_one_request import OneOnOneRequest
|
||||
from app.models.admin_settings import AppSettings
|
||||
from app.models.backup_record import BackupRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
tryout_managers = db.Table(
|
||||
'tryout_managers',
|
||||
db.Column(
|
||||
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
|
||||
|
||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||
|
||||
# Ordered list of (field_name, human_label) pairs for the player evaluation
|
||||
# score criteria. Kept in a single place so the evaluation forms, batch
|
||||
# evaluation page, and any future reporting all stay in sync.
|
||||
EVALUATION_CRITERIA = [
|
||||
('mecanics_score', 'Mecanics'),
|
||||
('cohesion_score', 'Cohesion'),
|
||||
('communication_score', 'Communication'),
|
||||
('gamesense_score', 'Gamesense'),
|
||||
('versatility_score', 'Versatility'),
|
||||
('discipline_score', 'Discipline'),
|
||||
('analysis_score', 'Analysis'),
|
||||
('sport_ethics_score', 'Sport Ethics'),
|
||||
('mental_score', 'Mental'),
|
||||
]
|
||||
|
||||
ESPORT_GAMES = [
|
||||
'Valorant',
|
||||
'League of Legends',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Global application settings stored as key-value pairs in the database."""
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class AppSettings(db.Model):
|
||||
"""Key-value store for global application settings.
|
||||
|
||||
Stores toggles and configuration that admins control through the
|
||||
admin panel, such as whether tryouts are open, season state, etc.
|
||||
"""
|
||||
|
||||
__tablename__ = 'app_settings'
|
||||
|
||||
key = db.Column(db.String(100), primary_key=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Well-known keys (documented here for discoverability)
|
||||
# ------------------------------------------------------------------
|
||||
# tryouts_open – "true" / "false" (default: "true")
|
||||
# season_active – "true" / "false" (default: "false")
|
||||
# season_name – e.g. "Fall 2026"
|
||||
# season_start – ISO date string
|
||||
# season_end – ISO date string
|
||||
|
||||
@staticmethod
|
||||
def get(key, default=None):
|
||||
"""Return the value for *key*, or *default* if not set."""
|
||||
row = db.session.get(AppSettings, key)
|
||||
return row.value if row is not None else default
|
||||
|
||||
@staticmethod
|
||||
def set(key, value):
|
||||
"""Upsert a setting."""
|
||||
row = db.session.get(AppSettings, key)
|
||||
if row is None:
|
||||
row = AppSettings(key=key, value=str(value) if value is not None else None)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.value = str(value) if value is not None else None
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_bool(key, default=False):
|
||||
"""Return a boolean setting."""
|
||||
val = AppSettings.get(key)
|
||||
if val is None:
|
||||
return default
|
||||
return val.lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
@staticmethod
|
||||
def set_bool(key, value):
|
||||
"""Store a boolean setting as 'true' / 'false'."""
|
||||
AppSettings.set(key, 'true' if value else 'false')
|
||||
@@ -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."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -20,16 +20,17 @@ class Tryout(db.Model):
|
||||
max_players = db.Column(db.Integer, nullable=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
||||
coach_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
|
||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
@@ -47,3 +48,17 @@ class Tryout(db.Model):
|
||||
if self.end_date is not None:
|
||||
return self.end_date < today
|
||||
return self.date < today
|
||||
|
||||
def get_managers(self):
|
||||
"""Managers attached to this tryout, both legacy and many-to-many."""
|
||||
manager_list = list(self.managers)
|
||||
if not manager_list and self.manager:
|
||||
return [self.manager]
|
||||
return manager_list
|
||||
|
||||
def get_coaches(self):
|
||||
"""Coaches attached to this tryout, both legacy and many-to-many."""
|
||||
coach_list = list(self.coaches)
|
||||
if not coach_list and self.coach:
|
||||
return [self.coach]
|
||||
return coach_list
|
||||
|
||||
@@ -21,7 +21,11 @@ class Manager(User):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
||||
return (
|
||||
tryout.created_by == self.id
|
||||
or tryout.manager_id == self.id
|
||||
or any(m.id == self.id for m in tryout.managers)
|
||||
)
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
@@ -32,7 +36,13 @@ class Manager(User):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return (
|
||||
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
|
||||
Tryout.query.filter(
|
||||
or_(
|
||||
Tryout.created_by == self.id,
|
||||
Tryout.manager_id == self.id,
|
||||
Tryout.managers.any(id=self.id),
|
||||
)
|
||||
)
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Admin panel routes for system-level management.
|
||||
|
||||
Provides backup/restore, tryout open/close toggling, season lifecycle
|
||||
management, team wiping, and audit log viewing. All routes are restricted
|
||||
to administrators.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Blueprint, render_template, redirect, url_for, flash, request,
|
||||
send_file,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
|
||||
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
|
||||
)
|
||||
from app.supporting_scripts.backup import (
|
||||
BACKUP_DIR, BackupError, backup_database, backup_documents,
|
||||
create_backup_dir, parse_database_url, verify_backup,
|
||||
)
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def require_admin():
|
||||
"""Return True if current user is an Admin, else flash and redirect."""
|
||||
if isinstance(current_user, Admin):
|
||||
return True
|
||||
flash('Only the president can access the admin panel.', 'danger')
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Best-effort client IP for audit logging."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
def _log(action, details=None):
|
||||
"""Record an audit log entry for the current user."""
|
||||
AuditLog.record(
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
details=details,
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin_bp.route('')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the admin panel dashboard."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
stats = {
|
||||
'total_users': User.query.count(),
|
||||
'total_players': User.query.filter_by(role='player').count(),
|
||||
'total_org_teams': OrgTeam.query.count(),
|
||||
'active_tryouts': Tryout.query.filter_by(status='in_progress').count(),
|
||||
'upcoming_tryouts': Tryout.query.filter_by(status='upcoming').count(),
|
||||
}
|
||||
|
||||
settings = {
|
||||
'tryouts_open': AppSettings.get_bool('tryouts_open', default=True),
|
||||
'season_active': AppSettings.get_bool('season_active', default=False),
|
||||
'season_name': AppSettings.get('season_name') or 'Not set',
|
||||
'season_start': AppSettings.get('season_start') or 'Not set',
|
||||
'season_end': AppSettings.get('season_end') or 'Not set',
|
||||
}
|
||||
|
||||
backups = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(20).all()
|
||||
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()
|
||||
|
||||
return render_template(
|
||||
'pages/admin.html',
|
||||
stats=stats,
|
||||
settings=settings,
|
||||
backups=backups,
|
||||
audit_logs=audit_logs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _create_backup_record(backup_type='manual', notes=None):
|
||||
"""Run a database backup and return a BackupRecord, or raise BackupError."""
|
||||
create_backup_dir()
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
file_path = backup_database(conn)
|
||||
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
record = BackupRecord(
|
||||
filename=filename,
|
||||
file_path=file_path,
|
||||
size_bytes=size,
|
||||
backup_type=backup_type,
|
||||
notes=notes,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
return record
|
||||
|
||||
|
||||
@admin_bp.route('/backup/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_backup():
|
||||
"""Create a manual database backup."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
notes = request.form.get('notes', '').strip() or None
|
||||
try:
|
||||
record = _create_backup_record(backup_type='manual', notes=notes)
|
||||
except BackupError as e:
|
||||
flash(f'Backup failed: {e}', 'danger')
|
||||
_log('backup_failed', f'Error: {e}')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
|
||||
flash(f'Backup created successfully: {record.filename}', 'success')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/download')
|
||||
@login_required
|
||||
def download_backup(backup_id):
|
||||
"""Download a backup file."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if not os.path.exists(record.file_path):
|
||||
flash('Backup file is missing from disk.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_downloaded', f'File: {record.filename}')
|
||||
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_backup(backup_id):
|
||||
"""Delete a backup file and its record."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if os.path.exists(record.file_path):
|
||||
try:
|
||||
os.remove(record.file_path)
|
||||
except OSError as e:
|
||||
flash(f'Could not remove backup file: {e}', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_deleted', f'File: {record.filename}')
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
flash(f'Backup {record.filename} deleted.', 'success')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
||||
@login_required
|
||||
def restore_backup(backup_id):
|
||||
"""Restore a selected backup.
|
||||
|
||||
A safety backup of the current state is created first, then the
|
||||
selected dump is restored via pg_restore.
|
||||
"""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
record = BackupRecord.query.get_or_404(backup_id)
|
||||
if not os.path.exists(record.file_path):
|
||||
flash('Backup file is missing from disk.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Create a safety backup of the current state before restoring.
|
||||
try:
|
||||
safety = _create_backup_record(
|
||||
backup_type='pre_restore',
|
||||
notes='Automatic safety backup before restoring ' + record.filename,
|
||||
)
|
||||
except BackupError as e:
|
||||
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Restore using pg_restore
|
||||
import subprocess
|
||||
from app.supporting_scripts.backup import (
|
||||
PG_RESTORE, dump_environment, parse_database_url,
|
||||
)
|
||||
|
||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||
cmd = [
|
||||
PG_RESTORE,
|
||||
'--host', conn['host'],
|
||||
'--port', conn['port'],
|
||||
'--username', conn['user'],
|
||||
'--dbname', conn['dbname'],
|
||||
'--clean', '--if-exists', '--no-owner',
|
||||
record.file_path,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=dump_environment(conn),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or 'pg_restore failed')
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
|
||||
flash(f'Restore failed: {e}', 'danger')
|
||||
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
_log('backup_restored', f'File: {record.filename}')
|
||||
flash(
|
||||
f'Backup {record.filename} restored successfully. The database has been rolled back.',
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tryouts open/close toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/toggle-tryouts', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_tryouts():
|
||||
"""Toggle the global tryout open/close state."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
current = AppSettings.get_bool('tryouts_open', default=True)
|
||||
new_value = not current
|
||||
AppSettings.set_bool('tryouts_open', new_value)
|
||||
|
||||
state = 'opened' if new_value else 'closed'
|
||||
_log('tryouts_toggled', f'Tryouts {state}')
|
||||
flash(f'Tryouts are now {state}.', 'success')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Season lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/season/start', methods=['POST'])
|
||||
@login_required
|
||||
def start_season():
|
||||
"""Begin a new regular season."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if AppSettings.get_bool('season_active', default=False):
|
||||
flash('A season is already active. End it before starting a new one.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
name = request.form.get('season_name', '').strip()
|
||||
start_date = request.form.get('season_start', '').strip()
|
||||
if not start_date:
|
||||
flash('A season start date is required.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
try:
|
||||
datetime.strptime(start_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid season start date format.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
AppSettings.set('season_name', name or 'Untitled Season')
|
||||
AppSettings.set('season_start', start_date)
|
||||
AppSettings.set('season_end', None)
|
||||
AppSettings.set_bool('season_active', True)
|
||||
|
||||
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
|
||||
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/season/end', methods=['POST'])
|
||||
@login_required
|
||||
def end_season():
|
||||
"""End the current regular season."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if not AppSettings.get_bool('season_active', default=False):
|
||||
flash('No season is currently active.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
end_date = request.form.get('season_end', '').strip()
|
||||
if not end_date:
|
||||
flash('A season end date is required.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
try:
|
||||
datetime.strptime(end_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid season end date format.', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
name = AppSettings.get('season_name')
|
||||
AppSettings.set('season_end', end_date)
|
||||
AppSettings.set_bool('season_active', False)
|
||||
|
||||
_log('season_ended', f'Season: {name}, End: {end_date}')
|
||||
flash(f'Season "{name}" has ended.', 'success')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wipe teams for new season
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/teams/wipe', methods=['POST'])
|
||||
@login_required
|
||||
def wipe_teams():
|
||||
"""Wipe team rosters and season matches for a new season.
|
||||
|
||||
Players are removed from org teams (TeamPlayer records), regular-season
|
||||
matches and their participants are deleted. OrgTeam structures, coaches,
|
||||
and managers are preserved.
|
||||
"""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
confirm = request.form.get('confirm', '').strip()
|
||||
if confirm != 'WIPE':
|
||||
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Safety backup before destructive operation
|
||||
try:
|
||||
_create_backup_record(
|
||||
backup_type='pre_wipe',
|
||||
notes='Automatic safety backup before wiping teams',
|
||||
)
|
||||
except BackupError as e:
|
||||
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
roster_count = TeamPlayer.query.count()
|
||||
match_count = TeamMatch.query.count()
|
||||
|
||||
# Delete team match participants first (FK order)
|
||||
TeamMatchParticipant.query.delete()
|
||||
TeamMatch.query.delete()
|
||||
TeamPlayer.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
_log('teams_wiped', f'Removed {roster_count} roster entries and {match_count} season matches')
|
||||
flash(
|
||||
f'Teams wiped for the new season. Removed {roster_count} roster entries '
|
||||
f'and {match_count} regular-season matches.',
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
@@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Coach, Manager, Player,
|
||||
User, Tryout, Evaluation, TryoutRegistration,
|
||||
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
@@ -245,3 +253,94 @@ def players_to_evaluate(tryout_id):
|
||||
]
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def batch_evaluate(tryout_id):
|
||||
"""Evaluate multiple players at once in a tryout.
|
||||
|
||||
GET renders a single form listing every selected player with their
|
||||
evaluation criteria. POST saves (creates or updates) all of them.
|
||||
"""
|
||||
if not current_user.can_evaluate():
|
||||
flash('You do not have permission to evaluate players.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
# Resolve selected player ids (query string on GET, hidden fields on POST).
|
||||
player_ids = []
|
||||
for raw in request.values.getlist('player_ids'):
|
||||
try:
|
||||
pid = int(raw)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if pid not in player_ids:
|
||||
player_ids.append(pid)
|
||||
|
||||
if not player_ids:
|
||||
flash('Please select at least one player to evaluate.', 'warning')
|
||||
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||
|
||||
players = []
|
||||
for pid in player_ids:
|
||||
player = User.query.get(pid)
|
||||
if not player or not isinstance(player, Player):
|
||||
continue
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=pid,
|
||||
).first() is not None
|
||||
if not is_registered:
|
||||
continue
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
|
||||
).first()
|
||||
existing_scores = {
|
||||
field_name: getattr(existing, field_name) if existing else None
|
||||
for field_name, _ in EVALUATION_CRITERIA
|
||||
}
|
||||
players.append({
|
||||
'player': player,
|
||||
'existing': existing,
|
||||
'existing_scores': existing_scores,
|
||||
})
|
||||
|
||||
if not players:
|
||||
flash('No valid players selected for evaluation.', 'danger')
|
||||
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||
|
||||
if request.method == 'POST':
|
||||
saved = 0
|
||||
for entry in players:
|
||||
pid = entry['player'].id
|
||||
scores = {
|
||||
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||
for field_name, _ in EVALUATION_CRITERIA
|
||||
}
|
||||
comments = request.form.get(f'comments_{pid}')
|
||||
position = request.form.get(f'position_recommendation_{pid}')
|
||||
|
||||
existing = entry['existing']
|
||||
if existing:
|
||||
_apply_evaluation(existing, scores, comments, position)
|
||||
else:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id, player_id=pid,
|
||||
evaluator_id=current_user.id,
|
||||
)
|
||||
_apply_evaluation(evaluation, scores, comments, position)
|
||||
db.session.add(evaluation)
|
||||
saved += 1
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_template('pages/batch_evaluate.html',
|
||||
tryout=tryout, players=players,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
game_positions=GAME_POSITIONS)
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models import (
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
AppSettings,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
@@ -40,6 +41,17 @@ def can_manage_team_match(team):
|
||||
return can_manage_org_team(current_user, team)
|
||||
|
||||
|
||||
def season_locked():
|
||||
"""Return True when the regular season is inactive for non-admins.
|
||||
|
||||
Admins bypass the lock. Coaches and managers may only schedule regular
|
||||
season matches while the season is active.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return False
|
||||
return not AppSettings.get_bool('season_active', default=False)
|
||||
|
||||
|
||||
@team_matches_bp.route('')
|
||||
@login_required
|
||||
def list_matches():
|
||||
@@ -108,6 +120,10 @@ def list_matches():
|
||||
def create_match(team_id):
|
||||
"""Create a new regular-season team match."""
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if season_locked():
|
||||
flash('The regular season is not active. Begin a season before scheduling matches.', 'warning')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if not can_manage_team_match(team):
|
||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
@@ -260,9 +276,14 @@ def delete_match(match_id):
|
||||
"""Delete a team match."""
|
||||
team_match = db.get_or_404(TeamMatch, match_id)
|
||||
team = team_match.org_team
|
||||
if season_locked():
|
||||
flash('The regular season is not active. Begin a season before deleting matches.', 'warning')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if not can_manage_team_match(team):
|
||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
db.session.delete(team_match)
|
||||
db.session.commit()
|
||||
flash(_('Match deleted successfully.'), 'success')
|
||||
|
||||
+65
-4
@@ -30,6 +30,7 @@ from app.models import (
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
AppSettings,
|
||||
)
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import (
|
||||
@@ -49,9 +50,20 @@ def can_manage():
|
||||
return isinstance(current_user, (Admin, Manager))
|
||||
|
||||
|
||||
def tryouts_locked():
|
||||
"""Return True when tryouts are globally closed to coaches/managers.
|
||||
|
||||
Admins are always allowed to bypass the lock. Coaches and managers can
|
||||
only make changes when the global tryout switch is open.
|
||||
"""
|
||||
if isinstance(current_user, Admin):
|
||||
return False
|
||||
return not AppSettings.get_bool('tryouts_open', default=True)
|
||||
|
||||
|
||||
def tryout_form_payload():
|
||||
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
||||
return form_payload(list_fields=('coach_ids',), optional_blank=())
|
||||
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
|
||||
|
||||
|
||||
def coaches_from_ids(coach_ids):
|
||||
@@ -67,6 +79,13 @@ def coaches_from_ids(coach_ids):
|
||||
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
|
||||
|
||||
|
||||
def managers_from_ids(manager_ids):
|
||||
"""The manager accounts behind these ids, filtered by role."""
|
||||
if not manager_ids:
|
||||
return []
|
||||
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load these users in one query, keyed by id.
|
||||
|
||||
@@ -120,6 +139,10 @@ def list_tryouts():
|
||||
@login_required
|
||||
def create_tryout():
|
||||
"""Create a new tryout event. Requires Admin or Manager."""
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
if not can_manage():
|
||||
flash(_('You do not have permission to create tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -160,12 +183,12 @@ def create_tryout():
|
||||
created_by=current_user.id,
|
||||
status='upcoming',
|
||||
target_org_team_id=data['target_org_team_id'],
|
||||
manager_id=data['manager_id'],
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tryout created successfully!'), 'success')
|
||||
@@ -180,6 +203,10 @@ def edit_tryout(tryout_id):
|
||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -221,8 +248,14 @@ def edit_tryout(tryout_id):
|
||||
tryout.location = data['location']
|
||||
tryout.max_players = data['max_players']
|
||||
tryout.target_org_team_id = data['target_org_team_id']
|
||||
tryout.manager_id = data['manager_id']
|
||||
|
||||
# Only update staff lists when the form explicitly sends them.
|
||||
# An absent checkbox group (all unchecked or JS failed) means
|
||||
# "don't change", not "remove everyone".
|
||||
if 'coach_ids' in request.form:
|
||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||
if 'manager_ids' in request.form:
|
||||
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tryout updated successfully!'), 'success')
|
||||
@@ -241,7 +274,7 @@ def view_tryout(tryout_id):
|
||||
if isinstance(current_user, Admin):
|
||||
can_view = True
|
||||
elif isinstance(current_user, Manager):
|
||||
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Coach):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Player):
|
||||
@@ -466,6 +499,10 @@ def register_for_tryout(tryout_id):
|
||||
def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -486,6 +523,10 @@ def update_status(tryout_id):
|
||||
def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -510,6 +551,10 @@ def update_registration_status(tryout_id, player_id):
|
||||
def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = locked_tryout_or_404(tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -557,6 +602,10 @@ def register_player(tryout_id):
|
||||
def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -593,6 +642,10 @@ def remove_player(tryout_id, player_id):
|
||||
def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
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))
|
||||
@@ -616,6 +669,10 @@ def add_to_team(tryout_id, team_id):
|
||||
"""Add a player to 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))
|
||||
@@ -658,6 +715,10 @@ def add_to_team(tryout_id, team_id):
|
||||
def delete_tryout(tryout_id):
|
||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
+1312
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,13 @@ from app.routes.users._shared import (
|
||||
pdf_upload_error,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.storage import CONTRACTS_DIR, document_path
|
||||
from app.storage import (
|
||||
CONTRACTS_DIR,
|
||||
GoogleDriveStorageError,
|
||||
is_google_drive_path,
|
||||
open_document,
|
||||
store_uploaded_document,
|
||||
)
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import UploadContractSchema
|
||||
|
||||
@@ -126,9 +132,11 @@ def upload_contract():
|
||||
if team:
|
||||
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
||||
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file.save(absolute_path)
|
||||
try:
|
||||
stored_path = store_uploaded_document(file, relative_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
contract = Contract(
|
||||
player_id=player_id,
|
||||
@@ -136,7 +144,7 @@ def upload_contract():
|
||||
uploaded_by_id=current_user.id,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=relative_path,
|
||||
file_path=stored_path,
|
||||
notes=notes if notes else None,
|
||||
)
|
||||
db.session.add(contract)
|
||||
@@ -166,11 +174,22 @@ def upload_signed_contract(contract_id):
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
signed_filename = f"signed_{contract.stored_filename}"
|
||||
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
file.save(document_path(signed_path))
|
||||
# Legacy local records keep their signed copy beside the original. Drive
|
||||
# identifiers are opaque rather than filenames, so new Drive records use
|
||||
# a fresh logical path and receive a second Drive ID.
|
||||
signed_path = (
|
||||
os.path.join(CONTRACTS_DIR, signed_filename)
|
||||
if is_google_drive_path(contract.file_path)
|
||||
else contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
)
|
||||
try:
|
||||
stored_signed_path = store_uploaded_document(file, signed_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
contract.signed_filename = signed_filename
|
||||
contract.signed_file_path = signed_path
|
||||
contract.signed_file_path = stored_signed_path
|
||||
contract.status = 'signed'
|
||||
contract.signed_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
@@ -186,11 +205,12 @@ def download_contract(contract_id):
|
||||
if not contract.can_view(current_user):
|
||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.original_filename,
|
||||
)
|
||||
try:
|
||||
document = open_document(contract.file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(document, as_attachment=True, download_name=contract.original_filename)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download_signed')
|
||||
@@ -204,8 +224,9 @@ def download_signed_contract(contract_id):
|
||||
if not contract.signed_file_path:
|
||||
flash(_('No signed contract available.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.signed_file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.signed_filename,
|
||||
)
|
||||
try:
|
||||
document = open_document(contract.signed_file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
||||
|
||||
@@ -2037,3 +2037,30 @@ a:hover { color: var(--primary-dark); }
|
||||
.honeypot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Batch Evaluation - 2 cards wide layout */
|
||||
.batch-eval-form {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.batch-eval-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.batch-eval-grid .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
|
||||
.batch-eval-grid .form-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.batch-eval-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
|
||||
* Confirmation before a destructive submit.
|
||||
*
|
||||
* <form data-confirm="Delete this match?">
|
||||
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
|
||||
* data-confirm-input-message="Type DELETE to continue."
|
||||
* data-confirm="Delete this record?">
|
||||
*
|
||||
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
||||
* markup where it can be translated.
|
||||
*/
|
||||
document.addEventListener('submit', function (event) {
|
||||
const form = event.target.closest('[data-confirm]');
|
||||
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputName = form.getAttribute('data-confirm-input');
|
||||
if (inputName) {
|
||||
const input = form.elements.namedItem(inputName);
|
||||
const expectedValue = form.getAttribute('data-confirm-value') || '';
|
||||
if (!input || input.value.trim() !== expectedValue) {
|
||||
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.confirm(form.getAttribute('data-confirm'))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
+76
-7
@@ -28,8 +28,17 @@ absolute path and are returned untouched, so this change needs no data
|
||||
migration and can ship before Alembic does (DB-002).
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.google_drive import (
|
||||
GoogleDriveStorageError,
|
||||
delete_file as delete_google_drive_file,
|
||||
download_file as download_google_drive_file,
|
||||
upload_file as upload_google_drive_file,
|
||||
)
|
||||
|
||||
#: Environment override for the document root. What a release-directory
|
||||
#: deployment sets, to a path outside the releases — alongside them, not
|
||||
#: inside whichever one is current.
|
||||
@@ -45,6 +54,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
|
||||
#: Sub-directory holding uploaded contracts, under the document root.
|
||||
CONTRACTS_DIR = 'contrats signés'
|
||||
|
||||
#: Database marker for documents stored remotely. Existing rows continue to
|
||||
#: hold relative or absolute filesystem paths, so switching storage does not
|
||||
#: invalidate contracts already uploaded before the Google Drive move.
|
||||
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
|
||||
|
||||
#: Storage backends deliberately stay explicit. Google Drive is the
|
||||
#: production default; local disk exists only for legacy rows and isolated
|
||||
#: test/development environments.
|
||||
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
|
||||
|
||||
|
||||
def project_root():
|
||||
"""Absolute path of the project, derived from this file's location.
|
||||
@@ -91,6 +110,56 @@ def _rooted(env_name, default_name):
|
||||
return os.path.join(project_root(), default_name)
|
||||
|
||||
|
||||
def document_storage_backend():
|
||||
"""Return the configured backend for new document uploads."""
|
||||
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'google_drive').strip().lower()
|
||||
if backend not in {'google_drive', 'local'}:
|
||||
raise ValueError(
|
||||
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
def is_google_drive_path(stored_path):
|
||||
"""Whether a database path references a Google Drive file."""
|
||||
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
|
||||
|
||||
|
||||
def _google_drive_id(stored_path):
|
||||
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
|
||||
if not file_id:
|
||||
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
|
||||
return file_id
|
||||
|
||||
|
||||
def store_uploaded_document(file_storage, relative_path):
|
||||
"""Store an uploaded document and return its durable database reference.
|
||||
|
||||
Local storage retains the relative-path format used by existing rows. A
|
||||
Google Drive upload returns an opaque Drive file identifier prefixed with
|
||||
``gdrive://`` so it cannot be mistaken for a filesystem path.
|
||||
"""
|
||||
if document_storage_backend() == 'local':
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file_storage.save(absolute_path)
|
||||
return relative_path
|
||||
|
||||
file_id = upload_google_drive_file(
|
||||
stream=file_storage.stream,
|
||||
filename=os.path.basename(relative_path),
|
||||
mimetype=file_storage.mimetype or 'application/pdf',
|
||||
)
|
||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||
|
||||
|
||||
def open_document(stored_path):
|
||||
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||
if is_google_drive_path(stored_path):
|
||||
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
|
||||
return document_path(stored_path)
|
||||
|
||||
|
||||
def discard_documents(stored_paths):
|
||||
"""Remove these documents from disk. Returns how many went (DATA-012).
|
||||
|
||||
@@ -107,27 +176,27 @@ def discard_documents(stored_paths):
|
||||
A path that cannot be removed is logged and skipped. Nothing here should
|
||||
be able to abort the deletion of an account.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
removed = 0
|
||||
for stored_path in stored_paths:
|
||||
if not stored_path:
|
||||
continue
|
||||
target = document_path(stored_path)
|
||||
try:
|
||||
os.remove(target)
|
||||
if is_google_drive_path(stored_path):
|
||||
delete_google_drive_file(_google_drive_id(stored_path))
|
||||
else:
|
||||
os.remove(document_path(stored_path))
|
||||
removed += 1
|
||||
except FileNotFoundError:
|
||||
# Already gone. Two contracts sharing a stem, or a previous
|
||||
# attempt: not a problem, and not worth an error line.
|
||||
logger.info('Document already absent: %s', target)
|
||||
except OSError as exc:
|
||||
logger.info('Document already absent: %s', stored_path)
|
||||
except (GoogleDriveStorageError, OSError) as exc:
|
||||
logger.error(
|
||||
'Could not remove %s (%s). It is now an orphan: no database row '
|
||||
'refers to it, so nothing in the application will ever offer to '
|
||||
'delete it again.',
|
||||
target,
|
||||
stored_path,
|
||||
exc,
|
||||
)
|
||||
return removed
|
||||
|
||||
@@ -90,6 +90,12 @@
|
||||
<span>{{ _('Manage Users') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-cogs"></i>
|
||||
<span>Admin Panel</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' %}
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Admin Panel{% endblock %}
|
||||
{% block page_title %}Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-primary">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_users }}</h3>
|
||||
<p>Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-success">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_players }}</h3>
|
||||
<p>Players</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-warning">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_org_teams }}</h3>
|
||||
<p>Org Teams</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.active_tryouts }}</h3>
|
||||
<p>Active Tryouts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-secondary">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.upcoming_tryouts }}</h3>
|
||||
<p>Upcoming</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Left column -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-toggle-{% if settings.tryouts_open %}on{% else %}off{% endif %}"></i> Tryouts Access</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Status:
|
||||
<span class="badge badge-{% if settings.tryouts_open %}success{% else %}danger{% endif %}">
|
||||
{% if settings.tryouts_open %}OPEN{% else %}CLOSED{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
|
||||
{% if settings.tryouts_open %}
|
||||
Coaches and managers can create and modify tryouts.
|
||||
{% else %}
|
||||
Only admins can create or modify tryouts. Coaches and managers are locked out.
|
||||
{% endif %}
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
|
||||
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-check"></i> Season Management</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Season:
|
||||
<span class="badge badge-{% if settings.season_active %}success{% else %}info{% endif %}">
|
||||
{% if settings.season_active %}ACTIVE{% else %}INACTIVE{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Name:</strong> {{ settings.season_name }}</p>
|
||||
<p><strong>Start:</strong> {{ settings.season_start }}</p>
|
||||
<p><strong>End:</strong> {{ settings.season_end }}</p>
|
||||
|
||||
{% if not settings.season_active %}
|
||||
<hr style="margin: 12px 0;">
|
||||
<form method="POST" action="{{ url_for('admin.start_season') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Season Name</label>
|
||||
<input type="text" name="season_name" class="form-input" placeholder="e.g. Fall 2026" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Start Date</label>
|
||||
<input type="date" name="season_start" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Begin Season</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<hr style="margin: 12px 0;">
|
||||
<form method="POST" action="{{ url_for('admin.end_season') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>End Date</label>
|
||||
<input type="date" name="season_end" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning">End Season</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wipe Teams -->
|
||||
<div class="card" style="border-left: 4px solid var(--danger);">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-trash-alt" style="color: var(--danger);"></i> Wipe Teams for New Season</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
|
||||
This removes all players from organization teams and deletes all regular-season matches.
|
||||
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('admin.wipe_teams') }}"
|
||||
data-confirm-input="confirm" data-confirm-value="WIPE"
|
||||
data-confirm-input-message="You must type WIPE to confirm."
|
||||
data-confirm="This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Type <strong>WIPE</strong> to confirm:</label>
|
||||
<input type="text" name="confirm" class="form-input" placeholder="WIPE" autocomplete="off" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Wipe Team Rosters</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Backup -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-database"></i> Manual Backup</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
|
||||
Creates a full PostgreSQL database dump (.sql file). Stored on the server.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('admin.create_backup') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Notes (optional)</label>
|
||||
<input type="text" name="notes" class="form-input" placeholder="What's this backup for?">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Backup Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup History -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-history"></i> Backup History & Restore</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td>{{ b.created_at.strftime('%Y-%m-%d %H:%M') if b.created_at else '—' }}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ b.filename }}">{{ b.filename }}</td>
|
||||
<td>{{ (b.size_bytes / 1024)|round(1) }} KB</td>
|
||||
<td><span class="badge badge-info">{{ b.backup_type }}</span></td>
|
||||
<td style="white-space: nowrap;">
|
||||
<a href="{{ url_for('admin.download_backup', backup_id=b.id) }}" class="btn btn-sm btn-primary" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
|
||||
data-confirm="Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
|
||||
data-confirm="Delete backup {{ b.filename }}?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Details</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in audit_logs %}
|
||||
<tr>
|
||||
<td>{{ entry.created_at.strftime('%Y-%m-%d %H:%M') if entry.created_at else '—' }}</td>
|
||||
<td>{{ entry.user.username if entry.user else 'System' }}</td>
|
||||
<td><span class="badge badge-info">{{ entry.action }}</span></td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
|
||||
<td>{{ entry.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
|
||||
{% block page_title %}Evaluate Players{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
{% for entry in players %}
|
||||
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
|
||||
{% endfor %}
|
||||
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
|
||||
<div class="batch-eval-grid">
|
||||
{% for entry in players %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<i class="fas fa-user"></i> {{ entry.player.username }}
|
||||
{% if entry.existing %}
|
||||
<span class="badge badge-success">Already Evaluated</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Not Evaluated</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="eval-player-info mb-4">
|
||||
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
|
||||
<div>
|
||||
<h3>{{ entry.player.username }}</h3>
|
||||
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set pid = entry.player.id %}
|
||||
{% set existing = entry.existing %}
|
||||
{% set existing_scores = entry.existing_scores %}
|
||||
|
||||
<div class="form-row">
|
||||
{% for field_name, label in evaluation_criteria %}
|
||||
<div class="form-group col-4">
|
||||
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" data-mirror>
|
||||
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-12">
|
||||
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
|
||||
{% if positions %}
|
||||
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
|
||||
<option value="">-- Select Position --</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="comments_{{ pid }}">Comments</label>
|
||||
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save All Evaluations
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -6,23 +6,29 @@
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
|
||||
<h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
|
||||
<form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Player') }}</th>
|
||||
<th>{{ _('Contact') }}</th>
|
||||
<th>{{ _('Attendance') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
<th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
|
||||
<th>Player</th>
|
||||
<th>Contact</th>
|
||||
<th>Attendance</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
@@ -41,19 +47,43 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
|
||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
|
||||
<td colspan="6" class="text-center">No players registered for this tryout.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-clipboard-check"></i> Evaluate Selected
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function toggleAll() {
|
||||
var boxes = document.querySelectorAll('.player-check');
|
||||
var master = document.getElementById('select-all');
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
boxes[i].checked = master.checked;
|
||||
}
|
||||
}
|
||||
|
||||
registerActions({
|
||||
'toggle-all': toggleAll,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -145,6 +145,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Disponibilities Card -->
|
||||
{% if user.role == 'player' %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> My Disponibilities</h3>
|
||||
<p class="text-muted small">Your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="disponibilities-grid">
|
||||
<p class="text-muted">Loading...</p>
|
||||
</div>
|
||||
<div class="form-actions mt-3">
|
||||
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
|
||||
<i class="fas fa-save"></i> Save Disponibilities
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Coach Availability Card -->
|
||||
{% if user.role == 'coach' %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> My Availability</h3>
|
||||
<p class="text-muted small">Select time slots when you're available for One on One sessions (8am to 10pm).</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="availability-grid" id="availability-grid">
|
||||
<p class="text-muted">Loading availability grid...</p>
|
||||
</div>
|
||||
<div class="form-actions mt-3">
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contracts Card -->
|
||||
{% if user.role == 'player' %}
|
||||
<div class="card">
|
||||
@@ -607,6 +650,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'save-disponibilities': saveDisponibilities,
|
||||
'clear-disponibilities': clearDisponibilities,
|
||||
'clear-availability': clearAllAvailability,
|
||||
});
|
||||
|
||||
@@ -300,6 +300,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function showCreateForm() {
|
||||
document.getElementById('createTeamForm').classList.remove('hidden');
|
||||
|
||||
@@ -67,15 +67,22 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="manager_id">{{ _('Assigned Manager') }}</label>
|
||||
<select id="manager_id" name="manager_id" class="form-select">
|
||||
<option value="">{{ _('-- No manager assigned --') }}</option>
|
||||
<label>{{ _('Assigned Managers') }}</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for manager in managers %}
|
||||
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
|
||||
{{ manager.username }}
|
||||
</option>
|
||||
{% set is_checked = false %}
|
||||
{% if tryout %}
|
||||
{% for m in tryout.get_managers() %}
|
||||
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="manager_ids" value="{{ manager.id }}" {% if is_checked %}checked{% endif %}>
|
||||
<span>{{ manager.username }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
@@ -70,16 +70,22 @@
|
||||
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Manager</span>
|
||||
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
|
||||
<span class="detail-label">Manager(s)</span>
|
||||
<span class="detail-value">
|
||||
{% set mgrs = tryout.get_managers() %}
|
||||
{% if mgrs %}
|
||||
{{ mgrs | map(attribute='username') | join(', ') }}
|
||||
{% else %}
|
||||
Not assigned
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Coaches</span>
|
||||
<span class="detail-value">
|
||||
{% if tryout.coaches %}
|
||||
{{ tryout.coaches | map(attribute='username') | join(', ') }}
|
||||
{% elif tryout.coach %}
|
||||
{{ tryout.coach.username }}
|
||||
{% set cos = tryout.get_coaches() %}
|
||||
{% if cos %}
|
||||
{{ cos | map(attribute='username') | join(', ') }}
|
||||
{% else %}
|
||||
Not assigned
|
||||
{% endif %}
|
||||
@@ -534,6 +540,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.presence-toggle-btn {
|
||||
padding: 2px 7px;
|
||||
|
||||
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
@@ -949,6 +949,7 @@ class TryoutSchema(StripMixin):
|
||||
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
||||
manager_id = fields.Integer(allow_none=True, load_default=None)
|
||||
coach_ids = fields.List(fields.Integer(), load_default=list)
|
||||
manager_ids = fields.List(fields.Integer(), load_default=list)
|
||||
|
||||
@validates_schema
|
||||
def validate_span(self, data, **kwargs):
|
||||
|
||||
@@ -67,6 +67,28 @@ documents every variable. Copying it verbatim gives a configuration that
|
||||
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
|
||||
than one that starts and is wide open (OPS-003).
|
||||
|
||||
### Google Drive contract storage
|
||||
|
||||
New contracts are stored in the owner's personal Google Drive when
|
||||
`DOCUMENT_STORAGE_BACKEND=google_drive`. Enable the Google Drive API in a
|
||||
Google Cloud project, create an OAuth client for the owner, and authorize it
|
||||
once with the `drive.file` scope. Configure the resulting values outside the
|
||||
repository:
|
||||
|
||||
```text
|
||||
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||
GOOGLE_DRIVE_FOLDER_ID=<owner folder id>
|
||||
GOOGLE_DRIVE_CLIENT_ID=<OAuth client id>
|
||||
GOOGLE_DRIVE_CLIENT_SECRET=<OAuth client secret>
|
||||
GOOGLE_DRIVE_REFRESH_TOKEN=<owner refresh token>
|
||||
```
|
||||
|
||||
The refresh token can create, download, and delete contracts created by this
|
||||
application. Keep all four values out of source control. The database stores
|
||||
only `gdrive://<file-id>` references, so existing local-file rows continue to
|
||||
work after the change. Use `DOCUMENT_STORAGE_BACKEND=local` only for tests,
|
||||
local development, or while migrating legacy files.
|
||||
|
||||
### Binding and proxy trust — read this before going live (OPS-002)
|
||||
|
||||
Two variables decide whether the rate limiter, the account lockout and the
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ show_missing = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
target-version = "py313"
|
||||
exclude = [".venv", "venv", "migrations", "docs"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
|
||||
@@ -24,6 +24,8 @@ Flask-SQLAlchemy==3.1.1
|
||||
Flask-WTF==1.3.0
|
||||
frozenlist==1.8.0
|
||||
greenlet==3.5.4
|
||||
google-api-python-client==2.198.0
|
||||
google-auth==2.56.3
|
||||
idna==3.18
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
@@ -80,6 +80,7 @@ def documents_in_a_throwaway_directory(tmp_path, monkeypatch):
|
||||
cannot leave a PDF in someone's working tree.
|
||||
"""
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents'))
|
||||
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'local')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -194,6 +195,11 @@ def as_role(app, client, make_user, login):
|
||||
from app.models import User
|
||||
|
||||
username = _db.session.get(User, user_id).username
|
||||
# The login route short-circuits when the client is already
|
||||
# authenticated, so a previous as_role() call would otherwise leave
|
||||
# the old identity in the session and the switch would silently not
|
||||
# happen (ADMIN-010: coach/manager gate tests ran as admin).
|
||||
client.post('/auth/logout', follow_redirects=False)
|
||||
response = login(username)
|
||||
assert response.status_code in (301, 302), (
|
||||
f'login for {username} did not redirect: {response.status_code}'
|
||||
|
||||
@@ -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,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,49 @@
|
||||
"""Admin route registration — every endpoint must exist and require login.
|
||||
|
||||
ADMIN-003. The admin blueprint registers 10 routes. If one is accidentally
|
||||
removed or renamed, the admin panel breaks silently. These tests walk the
|
||||
URL map to catch that.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ADMIN_ROUTES = [
|
||||
'/admin',
|
||||
'/admin/backup/create',
|
||||
'/admin/toggle-tryouts',
|
||||
'/admin/season/start',
|
||||
'/admin/season/end',
|
||||
'/admin/teams/wipe',
|
||||
]
|
||||
|
||||
|
||||
def _redirected(response):
|
||||
return response.status_code in (301, 302)
|
||||
|
||||
|
||||
class TestAdminRouteRegistration:
|
||||
@pytest.mark.parametrize('route', ADMIN_ROUTES)
|
||||
def test_route_is_registered(self, app, route):
|
||||
"""Every admin endpoint must appear in the URL map."""
|
||||
adapter = app.url_map.bind('localhost')
|
||||
try:
|
||||
adapter.match(route, method='GET')
|
||||
except Exception:
|
||||
# Some routes are POST-only; try POST
|
||||
try:
|
||||
adapter.match(route, method='POST')
|
||||
except Exception as exc:
|
||||
pytest.fail(f'Route {route} is not registered: {exc}')
|
||||
|
||||
@pytest.mark.parametrize('route', ADMIN_ROUTES)
|
||||
def test_route_requires_login(self, client, route):
|
||||
"""Unauthenticated access must redirect to login."""
|
||||
response = client.get(route, follow_redirects=False)
|
||||
# POST-only routes return 405 on GET, which is fine — the point is
|
||||
# they don't return 200 to an anonymous caller.
|
||||
if response.status_code == 405:
|
||||
return
|
||||
assert _redirected(response), (
|
||||
f'{route} answered {response.status_code} to an anonymous caller'
|
||||
)
|
||||
@@ -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
|
||||
+14
-17
@@ -1,17 +1,17 @@
|
||||
"""Which PostgreSQL driver the application actually asks for — QUA-001.
|
||||
|
||||
`postgresql://…` is not "whichever driver is installed". SQLAlchemy reads it
|
||||
as psycopg2 and imports that module when the engine is created.
|
||||
requirements.txt pins psycopg 3 and no psycopg2, so a clean install against
|
||||
the URL Render hands out — the same form docs/database-restore.md documents
|
||||
— fails before the first request:
|
||||
as psycopg2 unless the URL names a driver. requirements.txt declares psycopg
|
||||
3, so create_app() must explicitly select it.
|
||||
|
||||
ModuleNotFoundError: No module named 'psycopg2'
|
||||
|
||||
create_app() now names the driver. These tests pin that down, and the last
|
||||
one proves the failure is real rather than theoretical.
|
||||
The test suite must not assume that psycopg2 is absent: a developer's virtual
|
||||
environment can contain optional packages in addition to the declared
|
||||
dependencies. These tests instead pin the application's selected driver and
|
||||
the dependency contract that production installs use.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
@@ -80,16 +80,13 @@ class TestTheFactory:
|
||||
assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///')
|
||||
|
||||
|
||||
class TestTheFailureIsReal:
|
||||
"""Not a hypothetical: this is what the deployed configuration did."""
|
||||
class TestDriverContract:
|
||||
def test_production_dependencies_select_psycopg_3(self):
|
||||
requirements = Path(__file__).resolve().parents[1] / 'requirements.txt'
|
||||
declared = requirements.read_text(encoding='utf-8')
|
||||
|
||||
def test_psycopg2_is_not_installed(self):
|
||||
with pytest.raises(ImportError):
|
||||
import psycopg2 # noqa: F401
|
||||
|
||||
def test_a_driverless_url_cannot_build_an_engine(self):
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
create_engine('postgresql://u:p@host/db')
|
||||
assert 'psycopg[binary]==' in declared
|
||||
assert 'psycopg2' not in declared
|
||||
|
||||
def test_the_normalised_url_can(self):
|
||||
engine = create_engine(normalise_database_url('postgresql://u:p@host/db'))
|
||||
|
||||
+23
-1
@@ -19,7 +19,13 @@ import os
|
||||
import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.storage import CONTRACTS_DIR, document_path, documents_root
|
||||
from app.storage import (
|
||||
CONTRACTS_DIR,
|
||||
GOOGLE_DRIVE_PATH_PREFIX,
|
||||
document_path,
|
||||
documents_root,
|
||||
store_uploaded_document,
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentsRoot:
|
||||
@@ -87,6 +93,22 @@ class TestDocumentPath:
|
||||
assert first_old == second_old, 'and must not move the ones already filed'
|
||||
|
||||
|
||||
class TestGoogleDriveStorage:
|
||||
def test_new_uploads_store_an_opaque_drive_reference(self, monkeypatch):
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')
|
||||
monkeypatch.setattr(
|
||||
'app.storage.upload_google_drive_file',
|
||||
lambda **_kwargs: 'drive-file-123',
|
||||
)
|
||||
upload = FileStorage(stream=_pdf(), filename='contract.pdf', content_type='application/pdf')
|
||||
|
||||
stored_path = store_uploaded_document(upload, os.path.join(CONTRACTS_DIR, 'new.pdf'))
|
||||
|
||||
assert stored_path == f'{GOOGLE_DRIVE_PATH_PREFIX}drive-file-123'
|
||||
|
||||
|
||||
class TestThroughTheUploadRoute:
|
||||
@pytest.fixture
|
||||
def uploaded(self, app, client, as_role, make_user):
|
||||
|
||||
Reference in New Issue
Block a user