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
+14 -4
View File
@@ -12,9 +12,11 @@ from sqlalchemy import text
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
import markupsafe import markupsafe
from dotenv import load_dotenv from dotenv import load_dotenv
import os as _os
load_dotenv() # Load .env from the app directory (independent of the process working directory)
_env_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), '.env')
load_dotenv(_env_path)
def nl2br(value): def nl2br(value):
"""Convert newlines to HTML line breaks. """Convert newlines to HTML line breaks.
@@ -55,11 +57,17 @@ def create_app():
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
if not app.config['SECRET_KEY']: if not app.config['SECRET_KEY']:
raise RuntimeError('SECRET_KEY environment variable must be set for security') raise RuntimeError('SECRET_KEY environment variable must be set for security')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') database_url = os.getenv('DATABASE_URL')
if not app.config['SQLALCHEMY_DATABASE_URI']: if not database_url:
raise RuntimeError( raise RuntimeError(
'DATABASE_URL environment variable must be set to a PostgreSQL connection string' 'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
) )
# Use psycopg2 driver for PostgreSQL
if database_url.startswith('postgresql://'):
database_url = database_url.replace('postgresql://', 'postgresql+psycopg2://', 1)
elif database_url.startswith('postgres://'):
database_url = database_url.replace('postgres://', 'postgresql+psycopg2://', 1)
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['WTF_CSRF_ENABLED'] = True app.config['WTF_CSRF_ENABLED'] = True
@@ -111,6 +119,7 @@ def create_app():
from app.routes.teams import teams_bp from app.routes.teams import teams_bp
from app.routes.matches import matches_bp from app.routes.matches import matches_bp
from app.routes.team_matches import team_matches_bp from app.routes.team_matches import team_matches_bp
from app.routes.admin import admin_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp) app.register_blueprint(tryouts_bp)
@@ -120,6 +129,7 @@ def create_app():
app.register_blueprint(teams_bp) app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp) app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp) app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
# Register custom Jinja filters # Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br app.jinja_env.filters['nl2br'] = nl2br
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+4 -1
View File
@@ -92,4 +92,7 @@ from app.models.user_gamertag import UserGamertag
from app.models.contract import Contract from app.models.contract import Contract
from app.models.team_note import TeamNote from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
+55
View File
@@ -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')
+32
View File
@@ -0,0 +1,32 @@
"""Audit log for tracking sensitive administrative actions."""
from app.extensions import db
from datetime import datetime
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=datetime.utcnow)
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
+21
View File
@@ -0,0 +1,21 @@
"""Records of database backups created through the admin panel."""
from app.extensions import db
from datetime import datetime
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=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
+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'))
+14 -2
View File
@@ -104,7 +104,7 @@ def api_events():
one_on_ones = [] one_on_ones = []
for ooo in one_on_ones: for ooo in one_on_ones:
events.append({ event = {
'id': f'one_on_one_{ooo.id}', 'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'), '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, '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, '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}", '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) return jsonify(events)
+23
View File
@@ -9,6 +9,7 @@ from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Admin, Manager, Coach, Player,
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer, OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
AppSettings,
) )
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.discord_bot import send_schedule_notification from app.discord_bot import send_schedule_notification
@@ -30,6 +31,17 @@ def can_manage_team_match(team):
return False 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('') @team_matches_bp.route('')
@login_required @login_required
def list_matches(): 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') flash('You do not have permission to schedule matches for this team.', 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
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()] team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
is_practice = request.args.get('type') == 'practice' 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') flash('You do not have permission to edit this match.', 'danger')
return redirect(url_for('team_matches.list_matches')) 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': if request.method == 'POST':
team_match.title = request.form.get('title', team_match.title) team_match.title = request.form.get('title', team_match.title)
team_match.description = request.form.get('description', '') or None 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): if not can_manage_team_match(team):
flash('You do not have permission to delete this match.', 'danger') flash('You do not have permission to delete this match.', 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
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.delete(team_match)
db.session.commit() db.session.commit()
flash('Match deleted successfully.', 'success') flash('Match deleted successfully.', 'success')
+46
View File
@@ -12,6 +12,7 @@ from app.models import (
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, Match, MatchParticipant, OrgTeam, Match, MatchParticipant,
ESPORT_GAMES, GAME_POSITIONS, ESPORT_GAMES, GAME_POSITIONS,
AppSettings,
) )
from datetime import datetime from datetime import datetime
@@ -23,6 +24,17 @@ def can_manage():
return isinstance(current_user, (Admin, Manager)) return isinstance(current_user, (Admin, Manager))
def tryouts_locked():
"""Return True when tryouts are globally closed to coaches/managers.
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('tryouts_open', default=True)
@tryouts_bp.route('') @tryouts_bp.route('')
@login_required @login_required
def list_tryouts(): def list_tryouts():
@@ -42,6 +54,10 @@ def create_tryout():
flash('You do not have permission to create tryouts.', 'danger') flash('You do not have permission to create tryouts.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
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() 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() 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() 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') flash('You do not have permission to edit this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
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: if tryout.is_ended:
flash('This tryout has ended and can no longer be modified.', 'danger') flash('This tryout has ended and can no longer be modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) 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): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
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') new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']: if new_status in ['upcoming', 'in_progress', 'completed']:
tryout.status = new_status tryout.status = new_status
@@ -356,6 +379,10 @@ def update_registration_status(tryout_id, player_id):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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( registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first_or_404() tryout_id=tryout_id, player_id=player_id).first_or_404()
new_status = request.form.get('status') new_status = request.form.get('status')
@@ -374,6 +401,9 @@ def register_player(tryout_id):
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
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') player_id = request.form.get('player_id')
if not player_id: if not player_id:
flash('Please select a player.', 'danger') flash('Please select a player.', 'danger')
@@ -412,6 +442,10 @@ def remove_player(tryout_id, player_id):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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) player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by( registration = TryoutRegistration.query.filter_by(
@@ -447,6 +481,10 @@ def create_team(tryout_id):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
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') team_name = request.form.get('team_name')
if team_name: if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id) 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') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
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') player_id = request.form.get('player_id')
position = request.form.get('position', '') position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() 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') flash('You do not have permission to delete this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
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 # 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()] match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids: if match_ids:
+95 -51
View File
@@ -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}") 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']) @users_bp.route('/one-on-one', methods=['GET', 'POST'])
@login_required @login_required
def one_on_one(): def one_on_one():
@@ -716,41 +780,37 @@ def one_on_one():
flash('Only players can request One on One sessions.', 'danger') flash('Only players can request One on One sessions.', 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams() coach_options = _get_player_coach_options(current_user)
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
]
if request.method == 'POST': 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') date_str = request.form.get('date')
start_time_str = request.form.get('start_time') start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time') end_time_str = request.form.get('end_time')
points = request.form.get('points', '').strip() points = request.form.get('points', '').strip()
if not coach: if not coach_options:
flash('Cannot request One on One - no coach assigned.', 'danger') flash('Cannot request One on One - no coach assigned.', 'danger')
return redirect(url_for('users.one_on_one')) 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: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
start_time = datetime.strptime(start_time_str, '%H:%M').time() 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') flash('Invalid date or time format.', 'danger')
return redirect(url_for('users.one_on_one')) return redirect(url_for('users.one_on_one'))
check_date = datetime.strptime(date_str, '%Y-%m-%d') day_of_week = date_obj.weekday()
day_of_week = check_date.weekday()
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
is_available = any( is_available = any(
av['day_of_week'] == day_of_week av.day_of_week == day_of_week
and av['start_time'] <= start_time_str and av.start_time <= start_time
and av['end_time'] >= end_time_str and av.end_time >= end_time
for av in coach_availability for av in availabilities
) )
if not is_available: if not is_available:
@@ -796,30 +856,13 @@ def one_on_one():
flash('Your One on One request has been submitted!', 'success') flash('Your One on One request has been submitted!', 'success')
return redirect(url_for('users.one_on_one')) 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 # Player's own One on One request history
my_requests = OneOnOneRequest.query.filter_by( my_requests = OneOnOneRequest.query.filter_by(
player_id=current_user.id player_id=current_user.id
).order_by(OneOnOneRequest.created_at.desc()).all() ).order_by(OneOnOneRequest.created_at.desc()).all()
return render_template('pages/one_on_one.html', return render_template('pages/one_on_one.html',
org_team=org_team, coach=coach, coach_options=coach_options,
team_notes=team_notes, personal_notes=personal_notes,
coach_availability=coach_availability,
dates=dates,
my_requests=my_requests) my_requests=my_requests)
@@ -1031,12 +1074,13 @@ def notes_dashboard():
coach_id=current_user.id, coach_id=current_user.id,
).order_by(PersonalNote.created_at.desc()).all() ).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 = [] one_on_one_requests = []
if org_team and players: if org_team and players:
player_ids_list = [p.id for p in players] player_ids_list = [p.id for p in players]
one_on_one_requests = OneOnOneRequest.query.filter( 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() ).order_by(OneOnOneRequest.created_at.desc()).all()
# For context selectors in the form # For context selectors in the form
+121 -129
View File
@@ -1,27 +1,61 @@
"""Database backup script for the Team Tryouts application. """PostgreSQL database backup and restore helpers for the admin panel.
This module provides a simple backup mechanism for the SQLite database Unlike the original SQLite-based script, this module works against the
and uploaded contract documents. Designed to be run as a scheduled task PostgreSQL database configured via DATABASE_URL. It shells out to the
(Windows Task Scheduler) or cron job. standard ``pg_dump`` / ``pg_restore`` / ``psql`` command-line utilities.
Usage:
python backup.py
Configuration via environment variables: Configuration via environment variables:
BACKUP_DIR: Directory to store backups (default: ./backups) BACKUP_DIR: Directory to store backups (default: ./backups)
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30) BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
DATABASE_URL: PostgreSQL connection string (required for connectivity)
""" """
import os import os
import shutil import re
import sqlite3 import subprocess
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import urlparse
# Configuration
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups')) BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30)) BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db'))
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
def _database_url():
"""Return the DATABASE_URL, normalizing to the psycopg2 SQLAlchemy form."""
url = os.getenv('DATABASE_URL')
if not url:
raise RuntimeError('DATABASE_URL environment variable must be set')
# SQLAlchemy may prefix driver; strip it for command-line tools.
return re.sub(r'^postgresql\+[^:]+://', 'postgresql://', url)
def _pg_env():
"""Return the dict of PG* variables required by pg_dump / psql."""
parsed = urlparse(_database_url())
env = os.environ.copy()
# PGPASSWORD avoids interactive password prompts.
if parsed.password is not None:
env['PGPASSWORD'] = parsed.password
env.pop('PGHOST', None)
env.pop('PGPORT', None)
env.pop('PGUSER', None)
env.pop('PGDATABASE', None)
return env
def _pg_args():
"""Return [host, port, user, dbname] positional args for pg tools."""
parsed = urlparse(_database_url())
args = []
if parsed.hostname:
args += ['--host', parsed.hostname]
if parsed.port:
args += ['--port', str(parsed.port)]
if parsed.username:
args += ['--username', parsed.username]
if parsed.path and parsed.path.lstrip('/'):
args += ['--dbname', parsed.path.lstrip('/')]
return args
def create_backup_dir(): def create_backup_dir():
@@ -29,155 +63,113 @@ def create_backup_dir():
os.makedirs(BACKUP_DIR, exist_ok=True) os.makedirs(BACKUP_DIR, exist_ok=True)
def backup_database(): def create_backup(backup_type='manual', notes=None):
"""Backup the SQLite database using sqlite3's built-in backup API. """Create a PostgreSQL dump backup.
Returns: Returns:
str: Path to the created backup file, or None if failed. dict: {'filename', 'file_path', 'size_bytes'} or None on failure.
""" """
if not os.path.exists(DATABASE_PATH): create_backup_dir()
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f'db_backup_{timestamp}.db' filename = f'db_backup_{backup_type}_{timestamp}.sql'
backup_path = os.path.join(BACKUP_DIR, backup_filename) file_path = os.path.join(BACKUP_DIR, filename)
cmd = ['pg_dump'] + _pg_args() + ['--format', 'plain', '--no-owner', '--file', file_path]
try: try:
source = sqlite3.connect(DATABASE_PATH) result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
destination = sqlite3.connect(backup_path) if result.returncode != 0:
source.backup(destination) _cleanup_failed_file(file_path)
source.close() raise RuntimeError(result.stderr.strip() or 'pg_dump failed')
destination.close() except FileNotFoundError:
print(f'[OK] Database backed up to: {backup_path}') _cleanup_failed_file(file_path)
return backup_path raise RuntimeError('pg_dump executable not found on PATH')
except Exception as e: except Exception:
print(f'[ERROR] Database backup failed: {e}') _cleanup_failed_file(file_path)
return None raise
if not os.path.exists(file_path):
raise RuntimeError('Backup file was not created')
size = os.path.getsize(file_path)
return {'filename': filename, 'file_path': file_path, 'size_bytes': size}
def backup_documents(): def _cleanup_failed_file(file_path):
"""Backup the uploaded contract documents directory. """Remove a partially-written dump file, ignoring errors."""
if file_path and os.path.exists(file_path):
Returns: try:
str: Path to the created archive, or None if no documents exist. os.remove(file_path)
except OSError:
pass
def restore_backup(file_path):
"""Restore a PostgreSQL dump file using psql.
Args:
file_path: Path to a plain-text .sql dump produced by pg_dump.
Raises:
RuntimeError: If psql fails or is unavailable.
""" """
if not os.path.exists(DOCUMENTS_DIR): if not file_path or not os.path.exists(file_path):
print('[INFO] No documents directory found. Skipping document backup.') raise RuntimeError('Backup file does not exist')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = f'documents_backup_{timestamp}'
archive_path = os.path.join(BACKUP_DIR, archive_basename)
# The dump was produced with --no-owner + plain format, so we can
# pipe it into psql. Use --set ON_ERROR_STOP=1 to fail fast.
cmd = ['psql'] + _pg_args() + ['--set', 'ON_ERROR_STOP=1', '--file', file_path]
try: try:
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR) result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
zip_path = f'{archive_path}.zip' if result.returncode != 0:
print(f'[OK] Documents backed up to: {zip_path}') raise RuntimeError(result.stderr.strip() or 'psql restore failed')
return zip_path except FileNotFoundError:
except Exception as e: raise RuntimeError('psql executable not found on PATH')
print(f'[ERROR] Document backup failed: {e}')
return None
def cleanup_old_backups(): def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS.""" """Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR): if not os.path.exists(BACKUP_DIR):
return return 0
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS) cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
removed_count = 0 removed_count = 0
for filename in os.listdir(BACKUP_DIR): for filename in os.listdir(BACKUP_DIR):
file_path = os.path.join(BACKUP_DIR, filename) file_path = os.path.join(BACKUP_DIR, filename)
if os.path.isfile(file_path): if not os.path.isfile(file_path):
file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) continue
if file_time < cutoff: file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
try: if file_time < cutoff:
os.remove(file_path) try:
removed_count += 1 os.remove(file_path)
print(f'[CLEANUP] Removed old backup: {filename}') removed_count += 1
except OSError as e: except OSError:
print(f'[WARNING] Could not remove {filename}: {e}') pass
return removed_count
if removed_count > 0:
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
else:
print('[CLEANUP] No old backups to remove.')
def verify_backup(backup_path):
"""Verify a database backup by running a quick integrity check.
Args:
backup_path: Path to the backup file to verify.
Returns:
bool: True if backup is valid, False otherwise.
"""
if not backup_path or not os.path.exists(backup_path):
return False
try:
conn = sqlite3.connect(backup_path)
cursor = conn.cursor()
cursor.execute('PRAGMA integrity_check')
result = cursor.fetchone()
conn.close()
is_valid = result[0] == 'ok'
if is_valid:
print(f'[OK] Backup integrity verified: {backup_path}')
else:
print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}')
return is_valid
except Exception as e:
print(f'[ERROR] Backup verification failed: {e}')
return False
def main(): def main():
"""Run the full backup process. """Run a manual backup from the CLI (for cron/Task Scheduler)."""
print('=== Team Tryouts PostgreSQL Backup ===')
Steps:
1. Create backup directory
2. Backup database
3. Backup documents (if any)
4. Verify database backup
5. Clean up old backups
Returns:
int: 0 on success, 1 on failure.
"""
print(f'=== Team Tryouts Backup ===')
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print(f'Backup directory: {BACKUP_DIR}') print(f'Backup directory: {BACKUP_DIR}')
print(f'Retention period: {BACKUP_RETENTION_DAYS} days') print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
print() print()
create_backup_dir() try:
result = create_backup(backup_type='scheduled')
except RuntimeError as e:
print(f'[ERROR] {e}')
return 1
# 1. Backup database print(f'[OK] Database backed up to: {result["file_path"]}')
db_backup_path = backup_database()
success = True
# 2. Verify database backup removed = cleanup_old_backups()
if db_backup_path: if removed:
if not verify_backup(db_backup_path): print(f'[CLEANUP] Removed {removed} old backup(s).')
success = False
# 3. Backup documents
backup_documents()
# 4. Cleanup old backups
cleanup_old_backups()
print() print()
if success: print('=== Backup completed successfully ===')
print('=== Backup completed successfully ===') return 0
else:
print('=== Backup completed with warnings ===')
return 0 if success else 1
if __name__ == '__main__': if __name__ == '__main__':
+6
View File
@@ -75,6 +75,12 @@
<span>Manage Users</span> <span>Manage Users</span>
</a> </a>
</li> </li>
<li>
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}">
<i class="fas fa-cogs"></i>
<span>Admin Panel</span>
</a>
</li>
{% endif %} {% endif %}
{% if current_user.role == 'player' %} {% if current_user.role == 'player' %}
<li> <li>
+86 -42
View File
@@ -239,54 +239,79 @@ function showEventModal(event) {
var date = event.start ? event.start.toDateString() : ''; var date = event.start ? event.start.toDateString() : '';
var content = '<div class="detail-grid">'; var content = '<div class="detail-grid">';
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning') + '">';
content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + '</span>';
content += '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.status || 'scheduled') + '">' + (props.status || 'scheduled') + '</span>';
content += '</span></div>';
// Add team separation for matches if (type === 'one_on_one') {
if (type === 'match' && props.participants) { // One on One session modal
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">'; var startTime = props.start_time ? formatTime(props.start_time) : null;
if (props.match_type === 'team_vs_team') { var endTime = props.end_time ? formatTime(props.end_time) : null;
var teams = props.participants.split(' vs '); var timeRange = startTime ? startTime + (endTime ? ' - ' + endTime : '') : 'TBD';
if (teams.length >= 2) {
content += '<div class="match-teams">'; content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
content += '<div class="match-team"><span class="team-name">' + teams[0] + '</span></div>'; content += '<span class="badge badge-info">One on One Session</span>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">' + teams[1] + '</span></div>';
content += '</div>';
} else {
content += props.participants;
}
} else if (props.match_type === 'player_vs_player') {
var parts = props.participants.split(' vs ');
if (parts.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">Team 1</span><ul class="team-players-list"><li>' + parts[0].split(', ').join('</li><li>') + '</li></ul></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">Team 2</span><ul class="team-players-list"><li>' + parts[1].split(', ').join('</li><li>') + '</li></ul></div>';
content += '</div>';
} else {
content += props.participants;
}
} else {
content += props.participants;
}
content += '</span></div>'; content += '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Time</span><span class="detail-value">' + timeRange + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Participants</span><span class="detail-value">' + (props.participants || '') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
content += '<span class="badge badge-success">Approved</span>';
content += '</span></div>';
if (props.description) {
content += '<div class="detail-item full-width"><span class="detail-label">Discussion Points</span><span class="detail-value">' + props.description + '</span></div>';
}
} else {
// Match modal (existing behavior)
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning') + '">';
content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + '</span>';
content += '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.status || 'scheduled') + '">' + (props.status || 'scheduled') + '</span>';
content += '</span></div>';
// Add team separation for matches
if (props.participants) {
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">';
if (props.match_type === 'team_vs_team') {
var teams = props.participants.split(' vs ');
if (teams.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">' + teams[0] + '</span></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">' + teams[1] + '</span></div>';
content += '</div>';
} else {
content += props.participants;
}
} else if (props.match_type === 'player_vs_player') {
var parts = props.participants.split(' vs ');
if (parts.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">Team 1</span><ul class="team-players-list"><li>' + parts[0].split(', ').join('</li><li>') + '</li></ul></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">Team 2</span><ul class="team-players-list"><li>' + parts[1].split(', ').join('</li><li>') + '</li></ul></div>';
content += '</div>';
} else {
content += props.participants;
}
} else {
content += props.participants;
}
content += '</span></div>';
}
if (props.description) {
content += '<div class="detail-item full-width"><span class="detail-label">Description</span><span class="detail-value">' + props.description + '</span></div>';
}
} }
if (props.description) {
content += '<div class="detail-item full-width"><span class="detail-label">Description</span><span class="detail-value">' + props.description + '</span></div>';
}
content += '</div>'; content += '</div>';
document.getElementById('modalTitle').textContent = 'Match Details'; document.getElementById('modalTitle').textContent = (type === 'one_on_one' ? 'One on One Session' : 'Match Details');
document.getElementById('modalContent').innerHTML = content; document.getElementById('modalContent').innerHTML = content;
// Reset buttons // Reset buttons
@@ -294,6 +319,14 @@ function showEventModal(event) {
document.getElementById('editMatchBtn').style.display = 'none'; document.getElementById('editMatchBtn').style.display = 'none';
document.getElementById('viewTryoutBtn').style.display = 'none'; document.getElementById('viewTryoutBtn').style.display = 'none';
// Hide match actions and presence toggle for one-on-one events
if (type === 'one_on_one') {
document.getElementById('modalActions').style.display = 'none';
document.getElementById('modalPresenceToggle').style.display = 'none';
document.getElementById('eventModal').classList.remove('hidden');
return;
}
// Show action buttons for matches (coaches and above) // Show action buttons for matches (coaches and above)
if (type === 'match' && canScheduleMatches) { if (type === 'match' && canScheduleMatches) {
document.getElementById('modalActions').style.display = 'flex'; document.getElementById('modalActions').style.display = 'flex';
@@ -329,6 +362,17 @@ function showEventModal(event) {
document.getElementById('eventModal').classList.remove('hidden'); document.getElementById('eventModal').classList.remove('hidden');
} }
function formatTime(timeStr) {
if (!timeStr) return null;
var parts = timeStr.split(':');
var h = parseInt(parts[0]);
var m = parts[1];
var ampm = h >= 12 ? 'PM' : 'AM';
var displayHour = h % 12;
if (displayHour === 0) displayHour = 12;
return displayHour + ':' + m + ' ' + ampm;
}
function deleteCalendarMatch(matchId) { function deleteCalendarMatch(matchId) {
fetch('/matches/' + matchId + '/delete', { fetch('/matches/' + matchId + '/delete', {
method: 'POST', method: 'POST',
+181 -158
View File
@@ -4,60 +4,8 @@
{% block breadcrumb %}<span class="breadcrumb">Home / One on One</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / One on One</span>{% endblock %}
{% block content %} {% block content %}
<div class="dashboard-grid">
<!-- Team Notes Section -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users"></i> Team Notes</h3>
{% if org_team %}
<span class="badge badge-esport">{{ org_team.name }}</span>
{% endif %}
</div>
<div class="card-body">
{% if team_notes %}
{% for note in team_notes %}
<div class="detail-grid mt-4">
<div class="detail-item full-width">
<span class="detail-label"><i class="fas fa-user"></i> Coach: {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
<span class="detail-value">{{ note.content | nl2br }}</span>
</div>
</div>
<p class="text-muted small mt-2">Updated: {{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
{% endfor %}
{% else %}
<p class="text-muted">No team notes have been added yet. Your coach will post improvement suggestions here.</p>
{% endif %}
</div>
</div>
<!-- Personal Notes Section -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-user"></i> Personal Notes</h3>
{% if coach %}
<span class="badge badge-coach">From: {{ coach.username }}</span>
{% endif %}
</div>
<div class="card-body">
{% if personal_notes %}
{% for note in personal_notes %}
<div class="detail-grid mt-4">
<div class="detail-item full-width">
<span class="detail-label"><i class="fas fa-sticky-note"></i> Note from {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
<span class="detail-value">{{ note.content | nl2br }}</span>
</div>
</div>
<p class="text-muted small mt-2">Added: {{ note.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
{% endfor %}
{% else %}
<p class="text-muted">No personal notes have been added yet. Your coach may provide individual feedback here.</p>
{% endif %}
</div>
</div>
</div>
<!-- My One on One Requests Tracker --> <!-- My One on One Requests Tracker -->
<div class="card mt-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-list-check"></i> My One on One Requests</h3> <h3><i class="fas fa-list-check"></i> My One on One Requests</h3>
</div> </div>
@@ -67,6 +15,8 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Coach</th>
<th>Team</th>
<th>Date</th> <th>Date</th>
<th>Time</th> <th>Time</th>
<th>Discussion Points</th> <th>Discussion Points</th>
@@ -77,6 +27,8 @@
<tbody> <tbody>
{% for req in my_requests %} {% for req in my_requests %}
<tr> <tr>
<td>{{ req.coach.full_name if req.coach else 'Unknown Coach' }}</td>
<td>{{ req.team.name if req.team else '—' }}</td>
<td>{{ req.date.strftime('%b %d, %Y') }}</td> <td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td> <td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td> <td>{{ req.points or 'N/A' }}</td>
@@ -113,71 +65,78 @@
</div> </div>
</div> </div>
<div class="dashboard-grid mt-4"> <!-- One on One Request Section -->
<!-- One on One Request Section --> <div class="card">
<div class="card" style="grid-column: 1 / -1;"> <div class="card-header">
<div class="card-header"> <h3><i class="fas fa-calendar-check"></i> Request One on One Session</h3>
<h3><i class="fas fa-calendar-check"></i> Request One on One Session</h3> </div>
{% if coach %} <div class="card-body">
<span class="badge badge-info">Coach: {{ coach.username }}</span> {% if coach_options %}
{% endif %} <form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
</div> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="card-body">
{% if coach %}
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
<div class="form-group">
<label for="date">Select Date</label>
<select name="date" id="date" class="form-select" onchange="updateTimeSlots()" required>
{% for d in dates %}
<option value="{{ d.value }}" data-day="{{ d.day_of_week }}">{{ d.display }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="start_time">Start Time</label>
<select name="start_time" id="start_time" class="form-select" onchange="updateEndTimeOptions()" required>
<option value="">-- Select Date First --</option>
</select>
</div>
<div class="form-group">
<label for="end_time">End Time</label>
<select name="end_time" id="end_time" class="form-select" required>
<option value="">-- Select Start Time First --</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group col-6">
<label for="coach_id">Select Coach</label>
<select name="coach_id" id="coach_id" class="form-select" onchange="onCoachChange()" required>
<option value="">-- Select Coach --</option>
{% for c in coach_options %}
<option value="{{ c.coach_id }}">{{ c.coach_name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="org_team_id">Select Team <span class="text-muted">(optional)</span></label>
<select name="org_team_id" id="org_team_id" class="form-select">
<option value="">-- Any Team --</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="points">Discussion Points <span class="text-muted">(What would you like to discuss?)</span></label> <label for="date">Select Date</label>
<textarea name="points" id="points" class="form-textarea" placeholder="Enter topics you'd like to cover in your One on One session..." rows="4"></textarea> <select name="date" id="date" class="form-select" onchange="updateTimeSlots()" required>
<option value="">-- Select Coach First --</option>
</select>
</div> </div>
<div class="form-group">
<label for="start_time">Start Time</label>
<select name="start_time" id="start_time" class="form-select" onchange="updateEndTimeOptions()" required>
<option value="">-- Select Date First --</option>
</select>
</div>
<div class="form-group">
<label for="end_time">End Time</label>
<select name="end_time" id="end_time" class="form-select" required>
<option value="">-- Select Start Time First --</option>
</select>
</div>
</div>
<div class="form-actions"> <div class="form-group">
<button type="submit" class="btn btn-primary"> <label for="points">Discussion Points <span class="text-muted">(What would you like to discuss?)</span></label>
<i class="fas fa-paper-plane"></i> Send Request <textarea name="points" id="points" class="form-textarea" placeholder="Enter topics you'd like to cover in your One on One session..." rows="4"></textarea>
</button> </div>
</div>
</form> <div class="form-actions">
{% else %} <button type="submit" class="btn btn-primary">
<p class="text-muted">You need to be assigned to a team with a coach to request a One on One session.</p> <i class="fas fa-paper-plane"></i> Send Request
{% endif %} </button>
</div> </div>
</form>
{% else %}
<p class="text-muted">You need to be assigned to a team with a coach to request a One on One session.</p>
{% endif %}
</div> </div>
</div> </div>
{% if coach %}
<!-- Hidden data for JavaScript -->
<script id="coach-availability-data" type="application/json">
{{ coach_availability | tojson }}
</script>
{% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
// Coach options injected server-side
const COACH_OPTIONS = {{ coach_options | tojson }};
// Time slots from 8:00 AM to 10:00 PM // Time slots from 8:00 AM to 10:00 PM
const TIME_SLOTS = []; const TIME_SLOTS = [];
for (let h = 8; h <= 22; h++) { for (let h = 8; h <= 22; h++) {
@@ -190,62 +149,122 @@ for (let h = 8; h <= 22; h++) {
} }
} }
// Coach availability data const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
let coachAvailability = []; let coachAvailability = [];
// Initialize // Helper to convert "HH:MM" to minutes since midnight
document.addEventListener('DOMContentLoaded', function() { function toMinutes(timeStr) {
loadCoachAvailability(); const parts = timeStr.split(':');
updateTimeSlots(); return parseInt(parts[0]) * 60 + parseInt(parts[1]);
}); }
function loadCoachAvailability() { // Build date dropdown for the next 14 days filtered to days the coach has availability
const dataEl = document.getElementById('coach-availability-data'); function buildDateOptions() {
if (!dataEl) return; const dateSelect = document.getElementById('date');
dateSelect.innerHTML = '<option value="">-- Select Date --</option>';
try {
coachAvailability = JSON.parse(dataEl.textContent); const availableDays = new Set(coachAvailability.map(av => av.day_of_week));
} catch (e) { if (availableDays.size === 0) {
coachAvailability = []; return;
} }
const today = new Date();
for (let i = 0; i < 14; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
if (availableDays.has(d.getDay())) {
const value = d.toISOString().split('T')[0];
const display = d.toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
const option = document.createElement('option');
option.value = value;
option.setAttribute('data-day', d.getDay());
option.textContent = display;
dateSelect.appendChild(option);
}
}
// Reset dependent dropdowns
document.getElementById('start_time').innerHTML = '<option value="">-- Select Date First --</option>';
document.getElementById('end_time').innerHTML = '<option value="">-- Select Start Time First --</option>';
}
// Populate team dropdown for the selected coach
function updateTeamOptions(coachId) {
const teamSelect = document.getElementById('org_team_id');
teamSelect.innerHTML = '<option value="">-- Any Team --</option>';
const coach = COACH_OPTIONS.find(c => c.coach_id === coachId);
if (!coach) return;
coach.teams.forEach(team => {
const option = document.createElement('option');
option.value = team.team_id;
option.textContent = team.team_name;
teamSelect.appendChild(option);
});
}
// Fetch availability for the selected coach
function fetchCoachAvailability(coachId) {
coachAvailability = [];
if (!coachId) {
buildDateOptions();
return;
}
fetch(`/users/one-on-one/coaches/${coachId}/availability`)
.then(response => response.json())
.then(data => {
coachAvailability = data.availability || [];
buildDateOptions();
})
.catch(error => {
console.error('Error loading coach availability:', error);
coachAvailability = [];
buildDateOptions();
});
}
function onCoachChange() {
const coachId = parseInt(document.getElementById('coach_id').value);
updateTeamOptions(coachId);
fetchCoachAvailability(coachId);
} }
function updateTimeSlots() { function updateTimeSlots() {
const dateSelect = document.getElementById('date'); const dateSelect = document.getElementById('date');
const startTimeSelect = document.getElementById('start_time'); const startTimeSelect = document.getElementById('start_time');
const selectedOption = dateSelect.options[dateSelect.selectedIndex]; const selectedOption = dateSelect.options[dateSelect.selectedIndex];
if (!selectedOption || !selectedOption.value) {
startTimeSelect.innerHTML = '<option value="">-- Select Date First --</option>';
document.getElementById('end_time').innerHTML = '<option value="">-- Select Start Time First --</option>';
return;
}
const dayOfWeek = parseInt(selectedOption.getAttribute('data-day')); const dayOfWeek = parseInt(selectedOption.getAttribute('data-day'));
// Get available time slots for this day
const dayAvailability = coachAvailability.filter(av => av.day_of_week === dayOfWeek); const dayAvailability = coachAvailability.filter(av => av.day_of_week === dayOfWeek);
// Build available slots - collect all available minutes then sort
const availableSlots = [];
const availableMinutes = []; const availableMinutes = [];
dayAvailability.forEach(av => { dayAvailability.forEach(av => {
const startMinutes = av.start_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0); const startMinutes = toMinutes(av.start_time);
const endMinutes = av.end_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0); const endMinutes = toMinutes(av.end_time);
// Add 30-minute slots
for (let m = startMinutes; m < endMinutes; m += 30) { for (let m = startMinutes; m < endMinutes; m += 30) {
availableMinutes.push(m); availableMinutes.push(m);
} }
}); });
// Sort minutes and convert to time strings
availableMinutes.sort((a, b) => a - b); availableMinutes.sort((a, b) => a - b);
startTimeSelect.innerHTML = '<option value="">-- Select Start Time --</option>';
availableMinutes.forEach(m => { availableMinutes.forEach(m => {
const hour = Math.floor(m / 60); const hour = Math.floor(m / 60);
const minute = m % 60; const minute = m % 60;
const timeStr = (hour < 10 ? '0' : '') + hour + ':' + (minute < 10 ? '0' : '') + minute; const timeStr = (hour < 10 ? '0' : '') + hour + ':' + (minute < 10 ? '0' : '') + minute;
availableSlots.push(timeStr); const slotData = TIME_SLOTS.find(s => s.time === timeStr);
});
// Update start time options (sorted)
startTimeSelect.innerHTML = '<option value="">-- Select Start Time --</option>';
availableSlots.forEach(slot => {
const slotData = TIME_SLOTS.find(s => s.time === slot);
if (slotData) { if (slotData) {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = slotData.time; option.value = slotData.time;
@@ -253,8 +272,7 @@ function updateTimeSlots() {
startTimeSelect.appendChild(option); startTimeSelect.appendChild(option);
} }
}); });
// Reset end time options
updateEndTimeOptions(); updateEndTimeOptions();
} }
@@ -262,35 +280,34 @@ function updateEndTimeOptions() {
const dateSelect = document.getElementById('date'); const dateSelect = document.getElementById('date');
const startTimeSelect = document.getElementById('start_time'); const startTimeSelect = document.getElementById('start_time');
const endTimeSelect = document.getElementById('end_time'); const endTimeSelect = document.getElementById('end_time');
const selectedOption = dateSelect.options[dateSelect.selectedIndex]; const selectedOption = dateSelect.options[dateSelect.selectedIndex];
if (!selectedOption || !selectedOption.value) {
endTimeSelect.innerHTML = '<option value="">-- Select Start Time First --</option>';
return;
}
const dayOfWeek = parseInt(selectedOption.getAttribute('data-day')); const dayOfWeek = parseInt(selectedOption.getAttribute('data-day'));
const selectedStart = startTimeSelect.value; const selectedStart = startTimeSelect.value;
if (!selectedStart) { if (!selectedStart) {
endTimeSelect.innerHTML = '<option value="">-- Select Start Time First --</option>'; endTimeSelect.innerHTML = '<option value="">-- Select Start Time First --</option>';
return; return;
} }
// Get available minutes for this day
const dayAvailability = coachAvailability.filter(av => av.day_of_week === dayOfWeek); const dayAvailability = coachAvailability.filter(av => av.day_of_week === dayOfWeek);
const availableMinutes = []; const availableMinutes = [];
dayAvailability.forEach(av => { dayAvailability.forEach(av => {
const startMinutes = av.start_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0); const startMinutes = toMinutes(av.start_time);
const endMinutes = av.end_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0); const endMinutes = toMinutes(av.end_time);
for (let m = startMinutes; m < endMinutes; m += 30) { for (let m = startMinutes; m < endMinutes; m += 30) {
availableMinutes.push(m); availableMinutes.push(m);
} }
}); });
// Convert selected start to minutes const startMinutesVal = toMinutes(selectedStart);
const startMinutesVal = selectedStart.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0); const validEndTimes = availableMinutes.filter(m => m > startMinutesVal).sort((a, b) => a - b);
// Filter end times that are after start time
const validEndTimes = availableMinutes.filter(m => m > startMinutesVal);
validEndTimes.sort((a, b) => a - b);
// Update end time options
endTimeSelect.innerHTML = '<option value="">-- Select End Time --</option>'; endTimeSelect.innerHTML = '<option value="">-- Select End Time --</option>';
validEndTimes.forEach(m => { validEndTimes.forEach(m => {
const hour = Math.floor(m / 60); const hour = Math.floor(m / 60);
@@ -305,5 +322,11 @@ function updateEndTimeOptions() {
} }
}); });
} }
document.addEventListener('DOMContentLoaded', function() {
if (COACH_OPTIONS.length > 0) {
onCoachChange();
}
});
</script> </script>
{% endblock %} {% endblock %}