ajout d'un paneau admin
This commit is contained in:
@@ -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'))
|
||||
+14
-2
@@ -104,7 +104,7 @@ def api_events():
|
||||
one_on_ones = []
|
||||
|
||||
for ooo in one_on_ones:
|
||||
events.append({
|
||||
event = {
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
@@ -117,8 +117,20 @@ def api_events():
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
'player_name': ooo.player.full_name,
|
||||
'coach_name': ooo.coach.full_name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
# Provide real start/end datetimes so FullCalendar places the event
|
||||
# in the correct time slot (Week/Day views) instead of all-day.
|
||||
if ooo.start_time and ooo.end_time:
|
||||
start_dt = datetime.combine(ooo.date, ooo.start_time)
|
||||
end_dt = datetime.combine(ooo.date, ooo.end_time)
|
||||
event['start'] = start_dt.isoformat()
|
||||
event['end'] = end_dt.isoformat()
|
||||
|
||||
events.append(event)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||
AppSettings,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
@@ -30,6 +31,17 @@ def can_manage_team_match(team):
|
||||
return False
|
||||
|
||||
|
||||
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():
|
||||
@@ -96,6 +108,10 @@ def create_match(team_id):
|
||||
flash('You do not have permission to schedule matches for this team.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if season_locked():
|
||||
flash('The regular season is not active. An admin must begin a season before scheduling matches.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
|
||||
prefill_date = request.args.get('date', '')
|
||||
is_practice = request.args.get('type') == 'practice'
|
||||
@@ -208,6 +224,10 @@ def edit_match(match_id):
|
||||
flash('You do not have permission to edit this match.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if season_locked():
|
||||
flash('The regular season is not active. An admin must begin a season before editing matches.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if request.method == 'POST':
|
||||
team_match.title = request.form.get('title', team_match.title)
|
||||
team_match.description = request.form.get('description', '') or None
|
||||
@@ -257,6 +277,9 @@ def delete_match(match_id):
|
||||
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'))
|
||||
if season_locked():
|
||||
flash('The regular season is not active. An admin must begin a season before deleting matches.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
db.session.delete(team_match)
|
||||
db.session.commit()
|
||||
flash('Match deleted successfully.', 'success')
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models import (
|
||||
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
|
||||
OrgTeam, Match, MatchParticipant,
|
||||
ESPORT_GAMES, GAME_POSITIONS,
|
||||
AppSettings,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
@@ -23,6 +24,17 @@ 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)
|
||||
|
||||
|
||||
@tryouts_bp.route('')
|
||||
@login_required
|
||||
def list_tryouts():
|
||||
@@ -42,6 +54,10 @@ def create_tryout():
|
||||
flash('You do not have permission to create tryouts.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
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'))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
@@ -113,6 +129,10 @@ def edit_tryout(tryout_id):
|
||||
flash('You do not have permission to edit this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
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 tryout.is_ended:
|
||||
flash('This tryout has ended and can no longer be modified.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
@@ -339,6 +359,9 @@ def update_status(tryout_id):
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
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))
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
||||
tryout.status = new_status
|
||||
@@ -356,6 +379,10 @@ def update_registration_status(tryout_id, player_id):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
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))
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first_or_404()
|
||||
new_status = request.form.get('status')
|
||||
@@ -374,6 +401,9 @@ def register_player(tryout_id):
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
if 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))
|
||||
player_id = request.form.get('player_id')
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'danger')
|
||||
@@ -412,6 +442,10 @@ def remove_player(tryout_id, player_id):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
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))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
@@ -447,6 +481,10 @@ def create_team(tryout_id):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=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))
|
||||
|
||||
team_name = request.form.get('team_name')
|
||||
if team_name:
|
||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
||||
@@ -466,6 +504,10 @@ def add_to_team(tryout_id, team_id):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=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))
|
||||
|
||||
player_id = request.form.get('player_id')
|
||||
position = request.form.get('position', '')
|
||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||
@@ -488,6 +530,10 @@ def delete_tryout(tryout_id):
|
||||
flash('You do not have permission to delete this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
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))
|
||||
|
||||
# Delete match participants for all matches in this tryout
|
||||
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||
if match_ids:
|
||||
|
||||
+95
-51
@@ -708,6 +708,70 @@ def send_discord_notification(player_name, points, date_str, start_time_str, end
|
||||
logger.warning(f"Failed to send Discord notification: {e}")
|
||||
|
||||
|
||||
def _get_player_coach_options(player):
|
||||
"""Build a deduplicated list of coaches (with their teams) for a player.
|
||||
|
||||
Returns a list of dicts:
|
||||
[{'coach_id': int, 'coach_name': str, 'teams': [{'team_id': int, 'team_name': str}]}]
|
||||
"""
|
||||
coach_map = {}
|
||||
for org_team in player.get_org_teams():
|
||||
for coach in org_team.get_coaches():
|
||||
entry = coach_map.setdefault(coach.id, {
|
||||
'coach_id': coach.id,
|
||||
'coach_name': coach.full_name or coach.username,
|
||||
'teams': [],
|
||||
})
|
||||
entry['teams'].append({
|
||||
'team_id': org_team.id,
|
||||
'team_name': org_team.name,
|
||||
})
|
||||
return list(coach_map.values())
|
||||
|
||||
|
||||
def _get_player_coach_ids(player):
|
||||
"""Return the set of coach ids the player is allowed to request."""
|
||||
return {c['coach_id'] for c in _get_player_coach_options(player)}
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one/coaches')
|
||||
@login_required
|
||||
def one_on_one_coaches():
|
||||
"""API: list coaches (and their teams) available to the current player."""
|
||||
if not isinstance(current_user, Player):
|
||||
return jsonify({'error': 'Only players can request One on One sessions.'}), 403
|
||||
|
||||
coaches = _get_player_coach_options(current_user)
|
||||
return jsonify({'coaches': coaches})
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one/coaches/<int:coach_id>/availability')
|
||||
@login_required
|
||||
def one_on_one_coach_availability(coach_id):
|
||||
"""API: return a single coach's availability for the current player."""
|
||||
if not isinstance(current_user, Player):
|
||||
return jsonify({'error': 'Only players can request One on One sessions.'}), 403
|
||||
|
||||
if coach_id not in _get_player_coach_ids(current_user):
|
||||
return jsonify({'error': 'Coach is not assigned to any of your teams.'}), 403
|
||||
|
||||
coach = User.query.get_or_404(coach_id)
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach_id).all()
|
||||
availability = [
|
||||
{
|
||||
'day_of_week': av.day_of_week,
|
||||
'start_time': av.start_time.strftime('%H:%M'),
|
||||
'end_time': av.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for av in availabilities
|
||||
]
|
||||
return jsonify({
|
||||
'coach_id': coach.id,
|
||||
'coach_name': coach.full_name or coach.username,
|
||||
'availability': availability,
|
||||
})
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def one_on_one():
|
||||
@@ -716,41 +780,37 @@ def one_on_one():
|
||||
flash('Only players can request One on One sessions.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_teams = current_user.get_org_teams()
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None
|
||||
|
||||
if not coach:
|
||||
flash('You do not have a coach assigned to your team.', 'info')
|
||||
|
||||
team_notes = []
|
||||
if org_team:
|
||||
team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all()
|
||||
|
||||
personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all()
|
||||
|
||||
coach_availability = []
|
||||
if coach:
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
|
||||
coach_availability = [
|
||||
{
|
||||
'day_of_week': av.day_of_week,
|
||||
'start_time': av.start_time.strftime('%H:%M'),
|
||||
'end_time': av.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for av in availabilities
|
||||
]
|
||||
coach_options = _get_player_coach_options(current_user)
|
||||
|
||||
if request.method == 'POST':
|
||||
coach_id = request.form.get('coach_id', type=int)
|
||||
org_team_id = request.form.get('org_team_id', type=int)
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
points = request.form.get('points', '').strip()
|
||||
|
||||
if not coach:
|
||||
if not coach_options:
|
||||
flash('Cannot request One on One - no coach assigned.', 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
if coach_id not in _get_player_coach_ids(current_user):
|
||||
flash('Selected coach is not assigned to any of your teams.', 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
coach = User.query.get_or_404(coach_id)
|
||||
|
||||
# Validate org_team_id belongs to the player and includes this coach
|
||||
org_team = None
|
||||
if org_team_id:
|
||||
org_team = OrgTeam.query.get(org_team_id)
|
||||
if not org_team or org_team not in current_user.get_org_teams():
|
||||
flash('Invalid team selection.', 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
if coach not in org_team.get_coaches():
|
||||
flash('Selected coach is not part of the selected team.', 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
@@ -759,14 +819,14 @@ def one_on_one():
|
||||
flash('Invalid date or time format.', 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
check_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
day_of_week = check_date.weekday()
|
||||
day_of_week = date_obj.weekday()
|
||||
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
|
||||
is_available = any(
|
||||
av['day_of_week'] == day_of_week
|
||||
and av['start_time'] <= start_time_str
|
||||
and av['end_time'] >= end_time_str
|
||||
for av in coach_availability
|
||||
av.day_of_week == day_of_week
|
||||
and av.start_time <= start_time
|
||||
and av.end_time >= end_time
|
||||
for av in availabilities
|
||||
)
|
||||
|
||||
if not is_available:
|
||||
@@ -796,30 +856,13 @@ def one_on_one():
|
||||
flash('Your One on One request has been submitted!', 'success')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
# Build list of upcoming dates that have coach availability
|
||||
from datetime import date as date_cls, timedelta as td
|
||||
today = date_cls.today()
|
||||
available_days = {av['day_of_week'] for av in coach_availability}
|
||||
dates = []
|
||||
for i in range(14): # Next 14 days
|
||||
d = today + td(days=i)
|
||||
if d.weekday() in available_days:
|
||||
dates.append({
|
||||
'value': d.strftime('%Y-%m-%d'),
|
||||
'day_of_week': d.weekday(),
|
||||
'display': d.strftime('%B %d, %Y (%A)'),
|
||||
})
|
||||
|
||||
# Player's own One on One request history
|
||||
my_requests = OneOnOneRequest.query.filter_by(
|
||||
player_id=current_user.id
|
||||
).order_by(OneOnOneRequest.created_at.desc()).all()
|
||||
|
||||
return render_template('pages/one_on_one.html',
|
||||
org_team=org_team, coach=coach,
|
||||
team_notes=team_notes, personal_notes=personal_notes,
|
||||
coach_availability=coach_availability,
|
||||
dates=dates,
|
||||
coach_options=coach_options,
|
||||
my_requests=my_requests)
|
||||
|
||||
|
||||
@@ -1031,12 +1074,13 @@ def notes_dashboard():
|
||||
coach_id=current_user.id,
|
||||
).order_by(PersonalNote.created_at.desc()).all()
|
||||
|
||||
# One on One requests from team players
|
||||
# One on One requests addressed to this coach from their team players
|
||||
one_on_one_requests = []
|
||||
if org_team and players:
|
||||
player_ids_list = [p.id for p in players]
|
||||
one_on_one_requests = OneOnOneRequest.query.filter(
|
||||
OneOnOneRequest.player_id.in_(player_ids_list)
|
||||
OneOnOneRequest.player_id.in_(player_ids_list),
|
||||
OneOnOneRequest.coach_id == current_user.id,
|
||||
).order_by(OneOnOneRequest.created_at.desc()).all()
|
||||
|
||||
# For context selectors in the form
|
||||
|
||||
Reference in New Issue
Block a user