diff --git a/app/app.py b/app/app.py index f15ffa3..ce8a0c2 100644 --- a/app/app.py +++ b/app/app.py @@ -12,9 +12,11 @@ from sqlalchemy import text from werkzeug.exceptions import HTTPException import markupsafe 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): """Convert newlines to HTML line breaks. @@ -55,11 +57,17 @@ def create_app(): app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') if not app.config['SECRET_KEY']: raise RuntimeError('SECRET_KEY environment variable must be set for security') - app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') - if not app.config['SQLALCHEMY_DATABASE_URI']: + database_url = os.getenv('DATABASE_URL') + if not database_url: raise RuntimeError( '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['WTF_CSRF_ENABLED'] = True @@ -111,6 +119,7 @@ def create_app(): from app.routes.teams import teams_bp from app.routes.matches import 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(tryouts_bp) @@ -120,6 +129,7 @@ def create_app(): app.register_blueprint(teams_bp) app.register_blueprint(matches_bp) app.register_blueprint(team_matches_bp) + app.register_blueprint(admin_bp) # Register custom Jinja filters app.jinja_env.filters['nl2br'] = nl2br diff --git a/app/images/UdeS_Esports_logo.png b/app/images/UdeS_Esports_logo.png new file mode 100644 index 0000000..b5756f1 Binary files /dev/null and b/app/images/UdeS_Esports_logo.png differ diff --git a/app/models/__init__.py b/app/models/__init__.py index e82c7e9..06e22ad 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -92,4 +92,7 @@ from app.models.user_gamertag import UserGamertag from app.models.contract import Contract from app.models.team_note import TeamNote from app.models.personal_note import PersonalNote -from app.models.one_on_one_request import OneOnOneRequest \ No newline at end of file +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 diff --git a/app/models/admin_settings.py b/app/models/admin_settings.py new file mode 100644 index 0000000..73bb7b6 --- /dev/null +++ b/app/models/admin_settings.py @@ -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') \ No newline at end of file diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..611ad57 --- /dev/null +++ b/app/models/audit_log.py @@ -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 \ No newline at end of file diff --git a/app/models/backup_record.py b/app/models/backup_record.py new file mode 100644 index 0000000..248d9cf --- /dev/null +++ b/app/models/backup_record.py @@ -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') \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..19a682c --- /dev/null +++ b/app/routes/admin.py @@ -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//download') +@login_required +def download_backup(backup_id): + """Download a backup file.""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + record = BackupRecord.query.get_or_404(backup_id) + if not os.path.exists(record.file_path): + flash('Backup file is missing from disk.', 'danger') + return redirect(url_for('admin.dashboard')) + + _log('backup_downloaded', f'File: {record.filename}') + return send_file(record.file_path, as_attachment=True, download_name=record.filename) + + +@admin_bp.route('/backup//delete', methods=['POST']) +@login_required +def delete_backup(backup_id): + """Delete a backup file and its record.""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + record = BackupRecord.query.get_or_404(backup_id) + if os.path.exists(record.file_path): + try: + os.remove(record.file_path) + except OSError as e: + flash(f'Could not remove backup file: {e}', 'danger') + return redirect(url_for('admin.dashboard')) + + _log('backup_deleted', f'File: {record.filename}') + db.session.delete(record) + db.session.commit() + flash(f'Backup {record.filename} deleted.', 'success') + return redirect(url_for('admin.dashboard')) + + +@admin_bp.route('/backup//restore', methods=['POST']) +@login_required +def restore_backup(backup_id): + """Restore a selected backup. + + A safety backup of the current state is created first, then the + selected dump is restored via psql. + """ + if not require_admin(): + return redirect(url_for('main.dashboard')) + + record = BackupRecord.query.get_or_404(backup_id) + if not os.path.exists(record.file_path): + flash('Backup file is missing from disk.', 'danger') + return redirect(url_for('admin.dashboard')) + + # Create a safety backup of the current state before restoring. + try: + safety = backup_helper.create_backup(backup_type='pre_restore', notes='Pre-restore safety backup') + safety_record = BackupRecord( + filename=safety['filename'], + file_path=safety['file_path'], + size_bytes=safety['size_bytes'], + backup_type='auto', + notes='Automatic safety backup before restoring ' + record.filename, + created_by_id=current_user.id, + ) + db.session.add(safety_record) + db.session.commit() + except RuntimeError as e: + flash(f'Could not create safety backup, restore aborted: {e}', 'danger') + return redirect(url_for('admin.dashboard')) + + try: + backup_helper.restore_backup(record.file_path) + except RuntimeError as e: + flash(f'Restore failed: {e}', 'danger') + _log('backup_restore_failed', f'File: {record.filename}, Error: {e}') + return redirect(url_for('admin.dashboard')) + + _log('backup_restored', f'File: {record.filename}') + flash(f'Backup {record.filename} restored successfully. The database has been rolled back.', 'success') + return redirect(url_for('admin.dashboard')) + + +# --------------------------------------------------------------------------- +# Tryouts open/close toggle +# --------------------------------------------------------------------------- + +@admin_bp.route('/toggle-tryouts', methods=['POST']) +@login_required +def toggle_tryouts(): + """Toggle the global tryout open/close state.""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + current = AppSettings.get_bool('tryouts_open', default=True) + new_value = not current + AppSettings.set_bool('tryouts_open', new_value) + + state = 'opened' if new_value else 'closed' + _log('tryouts_toggled', f'Tryouts {state}') + flash(f'Tryouts are now {state}.', 'success') + return redirect(url_for('admin.dashboard')) + + +# --------------------------------------------------------------------------- +# Season lifecycle +# --------------------------------------------------------------------------- + +@admin_bp.route('/season/start', methods=['POST']) +@login_required +def start_season(): + """Begin a new regular season.""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + if AppSettings.get_bool('season_active', default=False): + flash('A season is already active. End it before starting a new one.', 'danger') + return redirect(url_for('admin.dashboard')) + + name = request.form.get('season_name', '').strip() + start_date = request.form.get('season_start', '').strip() + if not start_date: + flash('A season start date is required.', 'danger') + return redirect(url_for('admin.dashboard')) + + try: + datetime.strptime(start_date, '%Y-%m-%d') + except ValueError: + flash('Invalid season start date format.', 'danger') + return redirect(url_for('admin.dashboard')) + + AppSettings.set('season_name', name or 'Untitled Season') + AppSettings.set('season_start', start_date) + AppSettings.set('season_end', None) + AppSettings.set_bool('season_active', True) + + _log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}') + flash(f'Season "{name or "Untitled Season"}" has begun.', 'success') + return redirect(url_for('admin.dashboard')) + + +@admin_bp.route('/season/end', methods=['POST']) +@login_required +def end_season(): + """End the current regular season.""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + if not AppSettings.get_bool('season_active', default=False): + flash('No season is currently active.', 'danger') + return redirect(url_for('admin.dashboard')) + + end_date = request.form.get('season_end', '').strip() + if not end_date: + flash('A season end date is required.', 'danger') + return redirect(url_for('admin.dashboard')) + + try: + datetime.strptime(end_date, '%Y-%m-%d') + except ValueError: + flash('Invalid season end date format.', 'danger') + return redirect(url_for('admin.dashboard')) + + name = AppSettings.get('season_name') + AppSettings.set('season_end', end_date) + AppSettings.set_bool('season_active', False) + + _log('season_ended', f'Season: {name}, End: {end_date}') + flash(f'Season "{name}" has ended.', 'success') + return redirect(url_for('admin.dashboard')) + + +# --------------------------------------------------------------------------- +# Wipe teams for new season +# --------------------------------------------------------------------------- + +@admin_bp.route('/teams/wipe', methods=['POST']) +@login_required +def wipe_teams(): + """Wipe team rosters and season matches for a new season. + + Players are removed from org teams (TeamPlayer records), regular-season + matches and their participants are deleted. OrgTeam structures, coaches, + and managers are preserved. + """ + if not require_admin(): + return redirect(url_for('main.dashboard')) + + confirm = request.form.get('confirm', '').strip() + if confirm != 'WIPE': + flash("Type 'WIPE' in the confirmation box to proceed.", 'danger') + return redirect(url_for('admin.dashboard')) + + # Safety backup before destructive operation + try: + safety = backup_helper.create_backup(backup_type='pre_wipe', notes='Pre-wipe safety backup') + safety_record = BackupRecord( + filename=safety['filename'], + file_path=safety['file_path'], + size_bytes=safety['size_bytes'], + backup_type='auto', + notes='Automatic safety backup before wiping teams', + created_by_id=current_user.id, + ) + db.session.add(safety_record) + db.session.commit() + except RuntimeError as e: + flash(f'Wipe aborted — could not create safety backup: {e}', 'danger') + return redirect(url_for('admin.dashboard')) + + roster_count = TeamPlayer.query.count() + match_count = TeamMatch.query.count() + + # Delete team match participants first (FK order) + TeamMatchParticipant.query.delete() + TeamMatch.query.delete() + TeamPlayer.query.delete() + db.session.commit() + + _log('teams_wiped', f'Removed {roster_count} roster entries and {match_count} season matches') + flash( + f'Teams wiped for the new season. Removed {roster_count} roster entries ' + f'and {match_count} regular-season matches.', + 'success', + ) + return redirect(url_for('admin.dashboard')) \ No newline at end of file diff --git a/app/routes/matches.py b/app/routes/matches.py index 457cacb..702f4bd 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -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) diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 3d2115f..9dcf370 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -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') diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index df11f97..417dfaf 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -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: diff --git a/app/routes/users.py b/app/routes/users.py index f7619f9..f029da9 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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//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 diff --git a/app/supporting_scripts/backup.py b/app/supporting_scripts/backup.py index e9d1d77..f6b10d4 100644 --- a/app/supporting_scripts/backup.py +++ b/app/supporting_scripts/backup.py @@ -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 -and uploaded contract documents. Designed to be run as a scheduled task -(Windows Task Scheduler) or cron job. +Unlike the original SQLite-based script, this module works against the +PostgreSQL database configured via DATABASE_URL. It shells out to the +standard ``pg_dump`` / ``pg_restore`` / ``psql`` command-line utilities. -Usage: - python backup.py - Configuration via environment variables: BACKUP_DIR: Directory to store backups (default: ./backups) BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30) + DATABASE_URL: PostgreSQL connection string (required for connectivity) """ import os -import shutil -import sqlite3 +import re +import subprocess from datetime import datetime, timedelta +from urllib.parse import urlparse -# Configuration BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups')) 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(): @@ -29,155 +63,113 @@ def create_backup_dir(): os.makedirs(BACKUP_DIR, exist_ok=True) -def backup_database(): - """Backup the SQLite database using sqlite3's built-in backup API. - +def create_backup(backup_type='manual', notes=None): + """Create a PostgreSQL dump backup. + 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): - print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.') - return None - + create_backup_dir() timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - backup_filename = f'db_backup_{timestamp}.db' - backup_path = os.path.join(BACKUP_DIR, backup_filename) + filename = f'db_backup_{backup_type}_{timestamp}.sql' + file_path = os.path.join(BACKUP_DIR, filename) + cmd = ['pg_dump'] + _pg_args() + ['--format', 'plain', '--no-owner', '--file', file_path] try: - source = sqlite3.connect(DATABASE_PATH) - destination = sqlite3.connect(backup_path) - source.backup(destination) - source.close() - destination.close() - print(f'[OK] Database backed up to: {backup_path}') - return backup_path - except Exception as e: - print(f'[ERROR] Database backup failed: {e}') - return None + result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True) + if result.returncode != 0: + _cleanup_failed_file(file_path) + raise RuntimeError(result.stderr.strip() or 'pg_dump failed') + except FileNotFoundError: + _cleanup_failed_file(file_path) + raise RuntimeError('pg_dump executable not found on PATH') + except Exception: + _cleanup_failed_file(file_path) + 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(): - """Backup the uploaded contract documents directory. - - Returns: - str: Path to the created archive, or None if no documents exist. +def _cleanup_failed_file(file_path): + """Remove a partially-written dump file, ignoring errors.""" + if file_path and os.path.exists(file_path): + try: + 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): - print('[INFO] No documents directory found. Skipping document backup.') - 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) + if not file_path or not os.path.exists(file_path): + raise RuntimeError('Backup file does not exist') + # 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: - shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR) - zip_path = f'{archive_path}.zip' - print(f'[OK] Documents backed up to: {zip_path}') - return zip_path - except Exception as e: - print(f'[ERROR] Document backup failed: {e}') - return None + result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or 'psql restore failed') + except FileNotFoundError: + raise RuntimeError('psql executable not found on PATH') def cleanup_old_backups(): """Remove backup files older than BACKUP_RETENTION_DAYS.""" if not os.path.exists(BACKUP_DIR): - return + return 0 cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS) removed_count = 0 - for filename in os.listdir(BACKUP_DIR): file_path = os.path.join(BACKUP_DIR, filename) - if os.path.isfile(file_path): - file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) - if file_time < cutoff: - try: - os.remove(file_path) - removed_count += 1 - print(f'[CLEANUP] Removed old backup: {filename}') - except OSError as e: - print(f'[WARNING] Could not remove {filename}: {e}') - - 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 + if not os.path.isfile(file_path): + continue + file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) + if file_time < cutoff: + try: + os.remove(file_path) + removed_count += 1 + except OSError: + pass + return removed_count def main(): - """Run the full backup process. - - 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 ===') + """Run a manual backup from the CLI (for cron/Task Scheduler).""" + print('=== Team Tryouts PostgreSQL Backup ===') print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') print(f'Backup directory: {BACKUP_DIR}') print(f'Retention period: {BACKUP_RETENTION_DAYS} days') print() - create_backup_dir() + try: + result = create_backup(backup_type='scheduled') + except RuntimeError as e: + print(f'[ERROR] {e}') + return 1 - # 1. Backup database - db_backup_path = backup_database() - success = True + print(f'[OK] Database backed up to: {result["file_path"]}') - # 2. Verify database backup - if db_backup_path: - if not verify_backup(db_backup_path): - success = False - - # 3. Backup documents - backup_documents() - - # 4. Cleanup old backups - cleanup_old_backups() + removed = cleanup_old_backups() + if removed: + print(f'[CLEANUP] Removed {removed} old backup(s).') print() - if success: - print('=== Backup completed successfully ===') - else: - print('=== Backup completed with warnings ===') - - return 0 if success else 1 + print('=== Backup completed successfully ===') + return 0 if __name__ == '__main__': diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index 8961f03..7ec564b 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -75,6 +75,12 @@ Manage Users +
  • + + + Admin Panel + +
  • {% endif %} {% if current_user.role == 'player' %}
  • diff --git a/app/templates/pages/calendar.html b/app/templates/pages/calendar.html index b984ad0..2516e9e 100644 --- a/app/templates/pages/calendar.html +++ b/app/templates/pages/calendar.html @@ -239,54 +239,79 @@ function showEventModal(event) { var date = event.start ? event.start.toDateString() : ''; var content = '
    '; - content += '
    Type'; - content += ''; - content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + ''; - content += '
    '; - content += '
    Title' + title + '
    '; - content += '
    Date' + date + '
    '; - content += '
    Location' + (props.location || 'TBD') + '
    '; - content += '
    Status'; - content += '' + (props.status || 'scheduled') + ''; - content += '
    '; - // Add team separation for matches - if (type === 'match' && props.participants) { - content += '
    Teams'; - if (props.match_type === 'team_vs_team') { - var teams = props.participants.split(' vs '); - if (teams.length >= 2) { - content += '
    '; - content += '
    ' + teams[0] + '
    '; - content += '
    vs
    '; - content += '
    ' + teams[1] + '
    '; - content += '
    '; - } else { - content += props.participants; - } - } else if (props.match_type === 'player_vs_player') { - var parts = props.participants.split(' vs '); - if (parts.length >= 2) { - content += '
    '; - content += '
    Team 1
    • ' + parts[0].split(', ').join('
    • ') + '
    '; - content += '
    vs
    '; - content += '
    Team 2
    • ' + parts[1].split(', ').join('
    • ') + '
    '; - content += '
    '; - } else { - content += props.participants; - } - } else { - content += props.participants; - } + if (type === 'one_on_one') { + // One on One session modal + var startTime = props.start_time ? formatTime(props.start_time) : null; + var endTime = props.end_time ? formatTime(props.end_time) : null; + var timeRange = startTime ? startTime + (endTime ? ' - ' + endTime : '') : 'TBD'; + + content += '
    Type'; + content += 'One on One Session'; content += '
    '; + content += '
    Title' + title + '
    '; + content += '
    Date' + date + '
    '; + content += '
    Time' + timeRange + '
    '; + content += '
    Location' + (props.location || 'TBD') + '
    '; + content += '
    Participants' + (props.participants || '') + '
    '; + content += '
    Status'; + content += 'Approved'; + content += '
    '; + if (props.description) { + content += '
    Discussion Points' + props.description + '
    '; + } + } else { + // Match modal (existing behavior) + content += '
    Type'; + content += ''; + content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + ''; + content += '
    '; + content += '
    Title' + title + '
    '; + content += '
    Date' + date + '
    '; + content += '
    Location' + (props.location || 'TBD') + '
    '; + content += '
    Status'; + content += '' + (props.status || 'scheduled') + ''; + content += '
    '; + + // Add team separation for matches + if (props.participants) { + content += '
    Teams'; + if (props.match_type === 'team_vs_team') { + var teams = props.participants.split(' vs '); + if (teams.length >= 2) { + content += '
    '; + content += '
    ' + teams[0] + '
    '; + content += '
    vs
    '; + content += '
    ' + teams[1] + '
    '; + content += '
    '; + } else { + content += props.participants; + } + } else if (props.match_type === 'player_vs_player') { + var parts = props.participants.split(' vs '); + if (parts.length >= 2) { + content += '
    '; + content += '
    Team 1
    • ' + parts[0].split(', ').join('
    • ') + '
    '; + content += '
    vs
    '; + content += '
    Team 2
    • ' + parts[1].split(', ').join('
    • ') + '
    '; + content += '
    '; + } else { + content += props.participants; + } + } else { + content += props.participants; + } + content += '
    '; + } + + if (props.description) { + content += '
    Description' + props.description + '
    '; + } } - if (props.description) { - content += '
    Description' + props.description + '
    '; - } content += '
    '; - 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; // Reset buttons @@ -294,6 +319,14 @@ function showEventModal(event) { document.getElementById('editMatchBtn').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) if (type === 'match' && canScheduleMatches) { document.getElementById('modalActions').style.display = 'flex'; @@ -329,6 +362,17 @@ function showEventModal(event) { 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) { fetch('/matches/' + matchId + '/delete', { method: 'POST', diff --git a/app/templates/pages/one_on_one.html b/app/templates/pages/one_on_one.html index 177c3ba..08180b1 100644 --- a/app/templates/pages/one_on_one.html +++ b/app/templates/pages/one_on_one.html @@ -4,60 +4,8 @@ {% block breadcrumb %}Home / One on One{% endblock %} {% block content %} -
    - -
    -
    -

    Team Notes

    - {% if org_team %} - {{ org_team.name }} - {% endif %} -
    -
    - {% if team_notes %} - {% for note in team_notes %} -
    -
    - Coach: {{ note.coach.username if note.coach else 'Unknown Coach' }} - {{ note.content | nl2br }} -
    -
    -

    Updated: {{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') }}

    - {% endfor %} - {% else %} -

    No team notes have been added yet. Your coach will post improvement suggestions here.

    - {% endif %} -
    -
    - - -
    -
    -

    Personal Notes

    - {% if coach %} - From: {{ coach.username }} - {% endif %} -
    -
    - {% if personal_notes %} - {% for note in personal_notes %} -
    -
    - Note from {{ note.coach.username if note.coach else 'Unknown Coach' }} - {{ note.content | nl2br }} -
    -
    -

    Added: {{ note.created_at.strftime('%B %d, %Y at %I:%M %p') }}

    - {% endfor %} - {% else %} -

    No personal notes have been added yet. Your coach may provide individual feedback here.

    - {% endif %} -
    -
    -
    - -
    +

    My One on One Requests

    @@ -67,6 +15,8 @@ + + @@ -77,6 +27,8 @@ {% for req in my_requests %} + + @@ -113,71 +65,78 @@ -
    - -
    -
    -

    Request One on One Session

    - {% if coach %} - Coach: {{ coach.username }} - {% endif %} -
    -
    - {% if coach %} -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    + +
    +
    +

    Request One on One Session

    +
    +
    + {% if coach_options %} + + +
    +
    + + +
    +
    + + +
    +
    + +
    - - + +
    +
    + + +
    +
    + + +
    +
    -
    - -
    - - {% else %} -

    You need to be assigned to a team with a coach to request a One on One session.

    - {% endif %} -
    +
    + + +
    + +
    + +
    + + {% else %} +

    You need to be assigned to a team with a coach to request a One on One session.

    + {% endif %}
    - -{% if coach %} - - -{% endif %} {% endblock %} {% block scripts %} {% endblock %} \ No newline at end of file
    CoachTeam Date Time Discussion Points
    {{ req.coach.full_name if req.coach else 'Unknown Coach' }}{{ req.team.name if req.team else '—' }} {{ req.date.strftime('%b %d, %Y') }} {{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }} {{ req.points or 'N/A' }}