5 Commits
Author SHA1 Message Date
cedrick2711 1549fbaef3 fix merge conflict
CI - Security, Lint & Tests / validate (push) Failing after 54s
2026-08-25 14:03:24 -04:00
cedrick2711 c72ed9b0b1 Merge branch 'dev' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-25 14:03:07 -04:00
cedrick2711 6232b77094 ajout d'un paneau admin 2026-08-25 13:29:22 -04:00
cedrick2711 9fc4ba0b98 Merge branch 'main' of https://git.immortal.host/clubesportsudes/team-tryouts into dev 2026-08-08 10:18:51 -04:00
cedrick2711 53e390672e changer ou modifier les dispo 2026-08-05 21:37:15 -04:00
13 changed files with 975 additions and 60 deletions
+3 -1
View File
@@ -411,6 +411,7 @@ def create_app(config=None):
from app.routes.teams import teams_bp
from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp
from app.routes.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -420,6 +421,7 @@ def create_app(config=None):
app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
@@ -723,4 +725,4 @@ def create_app(config=None):
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
# module defines the factory; run.py starts it for development, wsgi.py for
# production (ARCH-007).
# production (ARCH-007).
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+3
View File
@@ -94,3 +94,6 @@ from app.models.contract import Contract
from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
+55
View File
@@ -0,0 +1,55 @@
"""Global application settings stored as key-value pairs in the database."""
from app.extensions import db
class AppSettings(db.Model):
"""Key-value store for global application settings.
Stores toggles and configuration that admins control through the
admin panel, such as whether tryouts are open, season state, etc.
"""
__tablename__ = 'app_settings'
key = db.Column(db.String(100), primary_key=True)
value = db.Column(db.Text, nullable=True)
# ------------------------------------------------------------------
# Well-known keys (documented here for discoverability)
# ------------------------------------------------------------------
# tryouts_open – "true" / "false" (default: "true")
# season_active – "true" / "false" (default: "false")
# season_name – e.g. "Fall 2026"
# season_start – ISO date string
# season_end – ISO date string
@staticmethod
def get(key, default=None):
"""Return the value for *key*, or *default* if not set."""
row = db.session.get(AppSettings, key)
return row.value if row is not None else default
@staticmethod
def set(key, value):
"""Upsert a setting."""
row = db.session.get(AppSettings, key)
if row is None:
row = AppSettings(key=key, value=str(value) if value is not None else None)
db.session.add(row)
else:
row.value = str(value) if value is not None else None
db.session.commit()
@staticmethod
def get_bool(key, default=False):
"""Return a boolean setting."""
val = AppSettings.get(key)
if val is None:
return default
return val.lower() in ('true', '1', 'yes', 'on')
@staticmethod
def set_bool(key, value):
"""Store a boolean setting as 'true' / 'false'."""
AppSettings.set(key, 'true' if value else 'false')
+32
View File
@@ -0,0 +1,32 @@
"""Audit log for tracking sensitive administrative actions."""
from app.extensions import db
from datetime import datetime
class AuditLog(db.Model):
"""Append-only log of critical admin actions for accountability."""
__tablename__ = 'audit_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
action = db.Column(db.String(100), nullable=False)
details = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
@staticmethod
def record(user_id, action, details=None, ip_address=None):
"""Create a new audit log entry."""
entry = AuditLog(
user_id=user_id,
action=action,
details=details,
ip_address=ip_address,
)
db.session.add(entry)
db.session.commit()
return entry
+21
View File
@@ -0,0 +1,21 @@
"""Records of database backups created through the admin panel."""
from app.extensions import db
from datetime import datetime
class BackupRecord(db.Model):
"""Metadata for a database backup stored on disk."""
__tablename__ = 'backup_records'
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
size_bytes = db.Column(db.BigInteger, nullable=True)
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
notes = db.Column(db.Text, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
+377
View File
@@ -0,0 +1,377 @@
"""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.backup import (
BACKUP_DIR, BackupError, backup_database, backup_documents,
create_backup_dir, parse_database_url, verify_backup,
)
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def require_admin():
"""Return True if current user is an Admin, else flash and redirect."""
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
# ---------------------------------------------------------------------------
def _create_backup_record(backup_type='manual', notes=None):
"""Run a database backup and return a BackupRecord, or raise BackupError."""
create_backup_dir()
conn = parse_database_url(os.getenv('DATABASE_URL'))
file_path = backup_database(conn)
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
filename = os.path.basename(file_path)
record = BackupRecord(
filename=filename,
file_path=file_path,
size_bytes=size,
backup_type=backup_type,
notes=notes,
created_by_id=current_user.id,
)
db.session.add(record)
db.session.commit()
return record
@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:
record = _create_backup_record(backup_type='manual', notes=notes)
except BackupError as e:
flash(f'Backup failed: {e}', 'danger')
_log('backup_failed', f'Error: {e}')
return redirect(url_for('admin.dashboard'))
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
flash(f'Backup created successfully: {record.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 pg_restore.
"""
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 = _create_backup_record(
backup_type='pre_restore',
notes='Automatic safety backup before restoring ' + record.filename,
)
except BackupError as e:
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
# Restore using pg_restore
import subprocess
from app.supporting_scripts.backup import (
PG_RESTORE, dump_environment, parse_database_url,
)
conn = parse_database_url(os.getenv('DATABASE_URL'))
cmd = [
PG_RESTORE,
'--host', conn['host'],
'--port', conn['port'],
'--username', conn['user'],
'--dbname', conn['dbname'],
'--clean', '--if-exists', '--no-owner',
record.file_path,
]
try:
result = subprocess.run(
cmd,
env=dump_environment(conn),
capture_output=True,
text=True,
timeout=900,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or 'pg_restore failed')
except (subprocess.TimeoutExpired, FileNotFoundError, 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:
_create_backup_record(
backup_type='pre_wipe',
notes='Automatic safety backup before wiping teams',
)
except BackupError 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'))
+12
View File
@@ -20,6 +20,7 @@ from app.models import (
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings,
)
from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
@@ -40,6 +41,17 @@ def can_manage_team_match(team):
return can_manage_org_team(current_user, team)
def season_locked():
"""Return True when the regular season is inactive for non-admins.
Admins bypass the lock. Coaches and managers may only schedule regular
season matches while the season is active.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('season_active', default=False)
@team_matches_bp.route('')
@login_required
def list_matches():
+48 -2
View File
@@ -30,6 +30,7 @@ from app.models import (
Tryout,
TryoutRegistration,
User,
AppSettings,
)
from app.time_utils import utc_now_naive
from app.validators import (
@@ -49,6 +50,17 @@ def can_manage():
return isinstance(current_user, (Admin, Manager))
def tryouts_locked():
"""Return True when tryouts are globally closed to coaches/managers.
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open.
"""
if isinstance(current_user, Admin):
return False
return not AppSettings.get_bool('tryouts_open', default=True)
def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
@@ -131,6 +143,10 @@ def create_tryout():
flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
@@ -191,6 +207,10 @@ def edit_tryout(tryout_id):
flash(_('You do not have permission to edit this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if tryout.is_ended:
flash(_('This tryout has ended and can no longer be modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
@@ -231,7 +251,7 @@ def edit_tryout(tryout_id):
# Only update staff lists when the form explicitly sends them.
# An absent checkbox group (all unchecked or JS failed) means
# \"don't change\", not \"remove everyone\".
# "don't change", not "remove everyone".
if 'coach_ids' in request.form:
tryout.coaches = coaches_from_ids(data['coach_ids'])
if 'manager_ids' in request.form:
@@ -482,6 +502,9 @@ def update_status(tryout_id):
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try:
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
@@ -503,6 +526,10 @@ def update_registration_status(tryout_id, player_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first_or_404()
@@ -526,6 +553,9 @@ def register_player(tryout_id):
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try:
data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err:
@@ -574,6 +604,10 @@ def remove_player(tryout_id, player_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = db.get_or_404(User, player_id)
registration = TryoutRegistration.query.filter_by(
@@ -610,6 +644,10 @@ def create_team(tryout_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
@@ -633,6 +671,10 @@ def add_to_team(tryout_id, team_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to
# tryout B, since only the tryout was authorised.
@@ -675,6 +717,10 @@ def delete_tryout(tryout_id):
flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
@@ -709,4 +755,4 @@ def delete_tryout(tryout_id):
db.session.delete(tryout)
db.session.commit()
flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts'))
return redirect(url_for('tryouts.list_tryouts'))
+98 -57
View File
@@ -254,6 +254,7 @@ def view_user(user_id):
def profile():
"""View the current user's profile."""
contracts = None
coach_availability = []
if isinstance(current_user, Player):
contracts = Contract.query.filter_by(
player_id=current_user.id,
@@ -712,6 +713,70 @@ def send_discord_notification(player_name, points, date_str, start_time_str, end
logger.warning(f"Failed to send Discord notification: {e}")
def _get_player_coach_options(player):
"""Build a deduplicated list of coaches (with their teams) for a player.
Returns a list of dicts:
[{'coach_id': int, 'coach_name': str, 'teams': [{'team_id': int, 'team_name': str}]}]
"""
coach_map = {}
for org_team in player.get_org_teams():
for coach in org_team.get_coaches():
entry = coach_map.setdefault(coach.id, {
'coach_id': coach.id,
'coach_name': coach.full_name or coach.username,
'teams': [],
})
entry['teams'].append({
'team_id': org_team.id,
'team_name': org_team.name,
})
return list(coach_map.values())
def _get_player_coach_ids(player):
"""Return the set of coach ids the player is allowed to request."""
return {c['coach_id'] for c in _get_player_coach_options(player)}
@users_bp.route('/one-on-one/coaches')
@login_required
def one_on_one_coaches():
"""API: list coaches (and their teams) available to the current player."""
if not isinstance(current_user, Player):
return jsonify({'error': 'Only players can request One on One sessions.'}), 403
coaches = _get_player_coach_options(current_user)
return jsonify({'coaches': coaches})
@users_bp.route('/one-on-one/coaches/<int:coach_id>/availability')
@login_required
def one_on_one_coach_availability(coach_id):
"""API: return a single coach's availability for the current player."""
if not isinstance(current_user, Player):
return jsonify({'error': 'Only players can request One on One sessions.'}), 403
if coach_id not in _get_player_coach_ids(current_user):
return jsonify({'error': 'Coach is not assigned to any of your teams.'}), 403
coach = User.query.get_or_404(coach_id)
availabilities = CoachAvailability.query.filter_by(coach_id=coach_id).all()
availability = [
{
'day_of_week': av.day_of_week,
'start_time': av.start_time.strftime('%H:%M'),
'end_time': av.end_time.strftime('%H:%M'),
}
for av in availabilities
]
return jsonify({
'coach_id': coach.id,
'coach_name': coach.full_name or coach.username,
'availability': availability,
})
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@login_required
def one_on_one():
@@ -720,41 +785,37 @@ def one_on_one():
flash('Only players can request One on One sessions.', 'danger')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None
if not coach:
flash('You do not have a coach assigned to your team.', 'info')
team_notes = []
if org_team:
team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all()
personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all()
coach_availability = []
if coach:
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
coach_availability = [
{
'day_of_week': av.day_of_week,
'start_time': av.start_time.strftime('%H:%M'),
'end_time': av.end_time.strftime('%H:%M'),
}
for av in availabilities
]
coach_options = _get_player_coach_options(current_user)
if request.method == 'POST':
coach_id = request.form.get('coach_id', type=int)
org_team_id = request.form.get('org_team_id', type=int)
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
points = request.form.get('points', '').strip()
if not coach:
if not coach_options:
flash('Cannot request One on One - no coach assigned.', 'danger')
return redirect(url_for('users.one_on_one'))
if coach_id not in _get_player_coach_ids(current_user):
flash('Selected coach is not assigned to any of your teams.', 'danger')
return redirect(url_for('users.one_on_one'))
coach = User.query.get_or_404(coach_id)
# Validate org_team_id belongs to the player and includes this coach
org_team = None
if org_team_id:
org_team = OrgTeam.query.get(org_team_id)
if not org_team or org_team not in current_user.get_org_teams():
flash('Invalid team selection.', 'danger')
return redirect(url_for('users.one_on_one'))
if coach not in org_team.get_coaches():
flash('Selected coach is not part of the selected team.', 'danger')
return redirect(url_for('users.one_on_one'))
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
start_time = datetime.strptime(start_time_str, '%H:%M').time()
@@ -763,14 +824,14 @@ def one_on_one():
flash('Invalid date or time format.', 'danger')
return redirect(url_for('users.one_on_one'))
check_date = datetime.strptime(date_str, '%Y-%m-%d')
day_of_week = check_date.weekday()
day_of_week = date_obj.weekday()
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
is_available = any(
av['day_of_week'] == day_of_week
and av['start_time'] <= start_time_str
and av['end_time'] >= end_time_str
for av in coach_availability
av.day_of_week == day_of_week
and av.start_time <= start_time
and av.end_time >= end_time
for av in availabilities
)
if not is_available:
@@ -800,30 +861,13 @@ def one_on_one():
flash('Your One on One request has been submitted!', 'success')
return redirect(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
from datetime import date as date_cls, timedelta as td
today = date_cls.today()
available_days = {av['day_of_week'] for av in coach_availability}
dates = []
for i in range(14): # Next 14 days
d = today + td(days=i)
if d.weekday() in available_days:
dates.append({
'value': d.strftime('%Y-%m-%d'),
'day_of_week': d.weekday(),
'display': d.strftime('%B %d, %Y (%A)'),
})
# Player's own One on One request history
my_requests = OneOnOneRequest.query.filter_by(
player_id=current_user.id
).order_by(OneOnOneRequest.created_at.desc()).all()
return render_template('pages/one_on_one.html',
org_team=org_team, coach=coach,
team_notes=team_notes, personal_notes=personal_notes,
coach_availability=coach_availability,
dates=dates,
coach_options=coach_options,
my_requests=my_requests)
@@ -987,12 +1031,8 @@ def manage_coach_availability():
db.session.commit()
return jsonify({'success': True})
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id,
).all()
return render_template('pages/coach_availability.html',
existing_availability=existing_availability)
# GET requests redirect to profile page where availability is now managed
return redirect(url_for('users.profile'))
@users_bp.route('/coach-availability/clear', methods=['POST'])
@@ -1039,12 +1079,13 @@ def notes_dashboard():
coach_id=current_user.id,
).order_by(PersonalNote.created_at.desc()).all()
# One on One requests from team players
# One on One requests addressed to this coach from their team players
one_on_one_requests = []
if org_team and players:
player_ids_list = [p.id for p in players]
one_on_one_requests = OneOnOneRequest.query.filter(
OneOnOneRequest.player_id.in_(player_ids_list)
OneOnOneRequest.player_id.in_(player_ids_list),
OneOnOneRequest.coach_id == current_user.id,
).order_by(OneOnOneRequest.created_at.desc()).all()
# For context selectors in the form
+6
View File
@@ -90,6 +90,12 @@
<span>{{ _('Manage Users') }}</span>
</a>
</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 %}
{% if current_user.role == 'player' %}
<li>
+277
View File
@@ -0,0 +1,277 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Panel{% endblock %}
{% block page_title %}Admin Panel{% endblock %}
{% block content %}
<!-- Stats Bar -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-primary">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_users }}</h3>
<p>Total Users</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-success">
<i class="fas fa-user"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_players }}</h3>
<p>Players</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-warning">
<i class="fas fa-shield-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_org_teams }}</h3>
<p>Org Teams</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-info">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.active_tryouts }}</h3>
<p>Active Tryouts</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-secondary">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3>{{ stats.upcoming_tryouts }}</h3>
<p>Upcoming</p>
</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Left column -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-toggle-{% if settings.tryouts_open %}on{% else %}off{% endif %}"></i> Tryouts Access</h3>
</div>
<div class="card-body">
<p>
Status:
<span class="badge badge-{% if settings.tryouts_open %}success{% else %}danger{% endif %}">
{% if settings.tryouts_open %}OPEN{% else %}CLOSED{% endif %}
</span>
</p>
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
{% if settings.tryouts_open %}
Coaches and managers can create and modify tryouts.
{% else %}
Only admins can create or modify tryouts. Coaches and managers are locked out.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> Season Management</h3>
</div>
<div class="card-body">
<p>
Season:
<span class="badge badge-{% if settings.season_active %}success{% else %}info{% endif %}">
{% if settings.season_active %}ACTIVE{% else %}INACTIVE{% endif %}
</span>
</p>
<p><strong>Name:</strong> {{ settings.season_name }}</p>
<p><strong>Start:</strong> {{ settings.season_start }}</p>
<p><strong>End:</strong> {{ settings.season_end }}</p>
{% if not settings.season_active %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.start_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Season Name</label>
<input type="text" name="season_name" class="form-input" placeholder="e.g. Fall 2026" required>
</div>
<div class="form-group">
<label>Start Date</label>
<input type="date" name="season_start" class="form-input" required>
</div>
<button type="submit" class="btn btn-success">Begin Season</button>
</form>
{% else %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.end_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>End Date</label>
<input type="date" name="season_end" class="form-input" required>
</div>
<button type="submit" class="btn btn-warning">End Season</button>
</form>
{% endif %}
</div>
</div>
<!-- Wipe Teams -->
<div class="card" style="border-left: 4px solid var(--danger);">
<div class="card-header">
<h3><i class="fas fa-trash-alt" style="color: var(--danger);"></i> Wipe Teams for New Season</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
This removes all players from organization teams and deletes all regular-season matches.
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
</p>
<form method="POST" action="{{ url_for('admin.wipe_teams') }}" onsubmit="return confirmWipe()">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Type <strong>WIPE</strong> to confirm:</label>
<input type="text" name="confirm" class="form-input" placeholder="WIPE" autocomplete="off" required>
</div>
<button type="submit" class="btn btn-danger">Wipe Team Rosters</button>
</form>
</div>
</div>
<!-- Manual Backup -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-database"></i> Manual Backup</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
Creates a full PostgreSQL database dump (.sql file). Stored on the server.
</p>
<form method="POST" action="{{ url_for('admin.create_backup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Notes (optional)</label>
<input type="text" name="notes" class="form-input" placeholder="What's this backup for?">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Backup Now
</button>
</form>
</div>
</div>
<!-- Backup History -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-history"></i> Backup History & Restore</h3>
</div>
<div class="card-body">
{% if backups %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Filename</th>
<th>Size</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in backups %}
<tr>
<td>{{ b.created_at.strftime('%Y-%m-%d %H:%M') if b.created_at else '—' }}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ b.filename }}">{{ b.filename }}</td>
<td>{{ (b.size_bytes / 1024)|round(1) }} KB</td>
<td><span class="badge badge-info">{{ b.backup_type }}</span></td>
<td style="white-space: nowrap;">
<a href="{{ url_for('admin.download_backup', backup_id=b.id) }}" class="btn btn-sm btn-primary" title="Download">
<i class="fas fa-download"></i>
</a>
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
onsubmit="return confirm('Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
<i class="fas fa-undo"></i>
</button>
</form>
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
onsubmit="return confirm('Delete backup {{ b.filename }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<!-- Audit Log -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
</div>
<div class="card-body">
{% if audit_logs %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Action</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{% for entry in audit_logs %}
<tr>
<td>{{ entry.created_at.strftime('%Y-%m-%d %H:%M') if entry.created_at else '—' }}</td>
<td>{{ entry.user.username if entry.user else 'System' }}</td>
<td><span class="badge badge-info">{{ entry.action }}</span></td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
<td>{{ entry.ip_address or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</p>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function confirmWipe() {
var input = document.querySelector('input[name="confirm"]');
if (input.value.trim() !== 'WIPE') {
alert('You must type WIPE to confirm.');
return false;
}
return confirm('This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?');
}
</script>
{% endblock %}
+43
View File
@@ -145,6 +145,49 @@
</div>
</div>
<!-- Player Disponibilities Card -->
{% if user.role == 'player' %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Disponibilities</h3>
<p class="text-muted small">Your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
</div>
<div class="card-body">
<div id="disponibilities-grid">
<p class="text-muted">Loading...</p>
</div>
<div class="form-actions mt-3">
<button type="button" class="btn btn-primary" onclick="saveDisponibilities()">
<i class="fas fa-save"></i> Save Disponibilities
</button>
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
<!-- Coach Availability Card -->
{% if user.role == 'coach' %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Availability</h3>
<p class="text-muted small">Select time slots when you're available for One on One sessions (8am to 10pm).</p>
</div>
<div class="card-body">
<div class="availability-grid" id="availability-grid">
<p class="text-muted">Loading availability grid...</p>
</div>
<div class="form-actions mt-3">
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
<!-- Contracts Card -->
{% if user.role == 'player' %}
<div class="card">