ajout d'un paneau admin
This commit is contained in:
+14
-4
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Admin panel routes for system-level management.
|
||||||
|
|
||||||
|
Provides backup/restore, tryout open/close toggling, season lifecycle
|
||||||
|
management, team wiping, and audit log viewing. All routes are restricted
|
||||||
|
to administrators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for, flash, request,
|
||||||
|
send_file,
|
||||||
|
)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import (
|
||||||
|
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
|
||||||
|
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
|
||||||
|
)
|
||||||
|
from app.supporting_scripts import backup as backup_helper
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin():
|
||||||
|
"""Return True if current user is an Admin, else flash and redirect.
|
||||||
|
|
||||||
|
This returns False for non-admins so the caller can stop processing,
|
||||||
|
but the redirect/abort should be handled by the caller.
|
||||||
|
"""
|
||||||
|
if isinstance(current_user, Admin):
|
||||||
|
return True
|
||||||
|
flash('Only the president can access the admin panel.', 'danger')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip():
|
||||||
|
"""Best-effort client IP for audit logging."""
|
||||||
|
if request.headers.get('X-Forwarded-For'):
|
||||||
|
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||||
|
return request.remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def _log(action, details=None):
|
||||||
|
"""Record an audit log entry for the current user."""
|
||||||
|
AuditLog.record(
|
||||||
|
user_id=current_user.id,
|
||||||
|
action=action,
|
||||||
|
details=details,
|
||||||
|
ip_address=_client_ip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dashboard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
"""Render the admin panel dashboard."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_users': User.query.count(),
|
||||||
|
'total_players': User.query.filter_by(role='player').count(),
|
||||||
|
'total_org_teams': OrgTeam.query.count(),
|
||||||
|
'active_tryouts': Tryout.query.filter_by(status='in_progress').count(),
|
||||||
|
'upcoming_tryouts': Tryout.query.filter_by(status='upcoming').count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
'tryouts_open': AppSettings.get_bool('tryouts_open', default=True),
|
||||||
|
'season_active': AppSettings.get_bool('season_active', default=False),
|
||||||
|
'season_name': AppSettings.get('season_name') or 'Not set',
|
||||||
|
'season_start': AppSettings.get('season_start') or 'Not set',
|
||||||
|
'season_end': AppSettings.get('season_end') or 'Not set',
|
||||||
|
}
|
||||||
|
|
||||||
|
backups = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(20).all()
|
||||||
|
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'pages/admin.html',
|
||||||
|
stats=stats,
|
||||||
|
settings=settings,
|
||||||
|
backups=backups,
|
||||||
|
audit_logs=audit_logs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/backup/create', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_backup():
|
||||||
|
"""Create a manual database backup."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
notes = request.form.get('notes', '').strip() or None
|
||||||
|
try:
|
||||||
|
result = backup_helper.create_backup(backup_type='manual', notes=notes)
|
||||||
|
except RuntimeError as e:
|
||||||
|
flash(f'Backup failed: {e}', 'danger')
|
||||||
|
_log('backup_failed', f'Error: {e}')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
record = BackupRecord(
|
||||||
|
filename=result['filename'],
|
||||||
|
file_path=result['file_path'],
|
||||||
|
size_bytes=result['size_bytes'],
|
||||||
|
backup_type='manual',
|
||||||
|
notes=notes,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_log('backup_created', f'File: {result["filename"]} ({result["size_bytes"]} bytes)')
|
||||||
|
flash(f'Backup created successfully: {result["filename"]}', 'success')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/backup/<int:backup_id>/download')
|
||||||
|
@login_required
|
||||||
|
def download_backup(backup_id):
|
||||||
|
"""Download a backup file."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
record = BackupRecord.query.get_or_404(backup_id)
|
||||||
|
if not os.path.exists(record.file_path):
|
||||||
|
flash('Backup file is missing from disk.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
_log('backup_downloaded', f'File: {record.filename}')
|
||||||
|
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/backup/<int:backup_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_backup(backup_id):
|
||||||
|
"""Delete a backup file and its record."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
record = BackupRecord.query.get_or_404(backup_id)
|
||||||
|
if os.path.exists(record.file_path):
|
||||||
|
try:
|
||||||
|
os.remove(record.file_path)
|
||||||
|
except OSError as e:
|
||||||
|
flash(f'Could not remove backup file: {e}', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
_log('backup_deleted', f'File: {record.filename}')
|
||||||
|
db.session.delete(record)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Backup {record.filename} deleted.', 'success')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def restore_backup(backup_id):
|
||||||
|
"""Restore a selected backup.
|
||||||
|
|
||||||
|
A safety backup of the current state is created first, then the
|
||||||
|
selected dump is restored via psql.
|
||||||
|
"""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
record = BackupRecord.query.get_or_404(backup_id)
|
||||||
|
if not os.path.exists(record.file_path):
|
||||||
|
flash('Backup file is missing from disk.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
# Create a safety backup of the current state before restoring.
|
||||||
|
try:
|
||||||
|
safety = backup_helper.create_backup(backup_type='pre_restore', notes='Pre-restore safety backup')
|
||||||
|
safety_record = BackupRecord(
|
||||||
|
filename=safety['filename'],
|
||||||
|
file_path=safety['file_path'],
|
||||||
|
size_bytes=safety['size_bytes'],
|
||||||
|
backup_type='auto',
|
||||||
|
notes='Automatic safety backup before restoring ' + record.filename,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(safety_record)
|
||||||
|
db.session.commit()
|
||||||
|
except RuntimeError as e:
|
||||||
|
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup_helper.restore_backup(record.file_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
flash(f'Restore failed: {e}', 'danger')
|
||||||
|
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
_log('backup_restored', f'File: {record.filename}')
|
||||||
|
flash(f'Backup {record.filename} restored successfully. The database has been rolled back.', 'success')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tryouts open/close toggle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/toggle-tryouts', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def toggle_tryouts():
|
||||||
|
"""Toggle the global tryout open/close state."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
current = AppSettings.get_bool('tryouts_open', default=True)
|
||||||
|
new_value = not current
|
||||||
|
AppSettings.set_bool('tryouts_open', new_value)
|
||||||
|
|
||||||
|
state = 'opened' if new_value else 'closed'
|
||||||
|
_log('tryouts_toggled', f'Tryouts {state}')
|
||||||
|
flash(f'Tryouts are now {state}.', 'success')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Season lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/season/start', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def start_season():
|
||||||
|
"""Begin a new regular season."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
if AppSettings.get_bool('season_active', default=False):
|
||||||
|
flash('A season is already active. End it before starting a new one.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
name = request.form.get('season_name', '').strip()
|
||||||
|
start_date = request.form.get('season_start', '').strip()
|
||||||
|
if not start_date:
|
||||||
|
flash('A season start date is required.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid season start date format.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
AppSettings.set('season_name', name or 'Untitled Season')
|
||||||
|
AppSettings.set('season_start', start_date)
|
||||||
|
AppSettings.set('season_end', None)
|
||||||
|
AppSettings.set_bool('season_active', True)
|
||||||
|
|
||||||
|
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
|
||||||
|
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/season/end', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def end_season():
|
||||||
|
"""End the current regular season."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
if not AppSettings.get_bool('season_active', default=False):
|
||||||
|
flash('No season is currently active.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
end_date = request.form.get('season_end', '').strip()
|
||||||
|
if not end_date:
|
||||||
|
flash('A season end date is required.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid season end date format.', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
name = AppSettings.get('season_name')
|
||||||
|
AppSettings.set('season_end', end_date)
|
||||||
|
AppSettings.set_bool('season_active', False)
|
||||||
|
|
||||||
|
_log('season_ended', f'Season: {name}, End: {end_date}')
|
||||||
|
flash(f'Season "{name}" has ended.', 'success')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wipe teams for new season
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/teams/wipe', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def wipe_teams():
|
||||||
|
"""Wipe team rosters and season matches for a new season.
|
||||||
|
|
||||||
|
Players are removed from org teams (TeamPlayer records), regular-season
|
||||||
|
matches and their participants are deleted. OrgTeam structures, coaches,
|
||||||
|
and managers are preserved.
|
||||||
|
"""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
confirm = request.form.get('confirm', '').strip()
|
||||||
|
if confirm != 'WIPE':
|
||||||
|
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
# Safety backup before destructive operation
|
||||||
|
try:
|
||||||
|
safety = backup_helper.create_backup(backup_type='pre_wipe', notes='Pre-wipe safety backup')
|
||||||
|
safety_record = BackupRecord(
|
||||||
|
filename=safety['filename'],
|
||||||
|
file_path=safety['file_path'],
|
||||||
|
size_bytes=safety['size_bytes'],
|
||||||
|
backup_type='auto',
|
||||||
|
notes='Automatic safety backup before wiping teams',
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(safety_record)
|
||||||
|
db.session.commit()
|
||||||
|
except RuntimeError as e:
|
||||||
|
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
roster_count = TeamPlayer.query.count()
|
||||||
|
match_count = TeamMatch.query.count()
|
||||||
|
|
||||||
|
# Delete team match participants first (FK order)
|
||||||
|
TeamMatchParticipant.query.delete()
|
||||||
|
TeamMatch.query.delete()
|
||||||
|
TeamPlayer.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_log('teams_wiped', f'Removed {roster_count} roster entries and {match_count} season matches')
|
||||||
|
flash(
|
||||||
|
f'Teams wiped for the new season. Removed {roster_count} roster entries '
|
||||||
|
f'and {match_count} regular-season matches.',
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
+14
-2
@@ -104,7 +104,7 @@ def api_events():
|
|||||||
one_on_ones = []
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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__':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
Reference in New Issue
Block a user