ajout d'un paneau admin

This commit is contained in:
cedrick2711
2026-08-25 13:29:22 -04:00
parent 9fc4ba0b98
commit 6232b77094
15 changed files with 1053 additions and 387 deletions
+355
View File
@@ -0,0 +1,355 @@
"""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/<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 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'))