"""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 import backup as backup_helper admin_bp = Blueprint('admin', __name__, url_prefix='/admin') def require_admin(): """Return True if current user is an Admin, else flash and redirect. This returns False for non-admins so the caller can stop processing, but the redirect/abort should be handled by the caller. """ 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 # --------------------------------------------------------------------------- @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: result = backup_helper.create_backup(backup_type='manual', notes=notes) except RuntimeError as e: flash(f'Backup failed: {e}', 'danger') _log('backup_failed', f'Error: {e}') return redirect(url_for('admin.dashboard')) record = BackupRecord( filename=result['filename'], file_path=result['file_path'], size_bytes=result['size_bytes'], backup_type='manual', notes=notes, created_by_id=current_user.id, ) db.session.add(record) db.session.commit() _log('backup_created', f'File: {result["filename"]} ({result["size_bytes"]} bytes)') flash(f'Backup created successfully: {result["filename"]}', 'success') return redirect(url_for('admin.dashboard')) @admin_bp.route('/backup//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//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//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 psql. """ 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 = backup_helper.create_backup(backup_type='pre_restore', notes='Pre-restore safety backup') safety_record = BackupRecord( filename=safety['filename'], file_path=safety['file_path'], size_bytes=safety['size_bytes'], backup_type='auto', notes='Automatic safety backup before restoring ' + record.filename, created_by_id=current_user.id, ) db.session.add(safety_record) db.session.commit() except RuntimeError as e: flash(f'Could not create safety backup, restore aborted: {e}', 'danger') return redirect(url_for('admin.dashboard')) try: backup_helper.restore_backup(record.file_path) except 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: safety = backup_helper.create_backup(backup_type='pre_wipe', notes='Pre-wipe safety backup') safety_record = BackupRecord( filename=safety['filename'], file_path=safety['file_path'], size_bytes=safety['size_bytes'], backup_type='auto', notes='Automatic safety backup before wiping teams', created_by_id=current_user.id, ) db.session.add(safety_record) db.session.commit() except RuntimeError 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'))