Compare commits
9
Commits
e15b3c1293
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1549fbaef3 | ||
|
|
c72ed9b0b1 | ||
|
|
6232b77094 | ||
|
|
a616c79663 | ||
|
|
48ca62cdd0 | ||
|
|
1bef716a12 | ||
|
|
a0e31d1a2f | ||
|
|
9fc4ba0b98 | ||
|
|
53e390672e |
@@ -411,6 +411,7 @@ def create_app(config=None):
|
|||||||
from app.routes.teams import teams_bp
|
from app.routes.teams import teams_bp
|
||||||
from app.routes.tryouts import tryouts_bp
|
from app.routes.tryouts import tryouts_bp
|
||||||
from app.routes.users import users_bp
|
from app.routes.users import users_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)
|
||||||
@@ -420,6 +421,7 @@ def create_app(config=None):
|
|||||||
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 |
@@ -20,6 +20,7 @@ from app.models._constants import (
|
|||||||
GAME_PLATFORMS,
|
GAME_PLATFORMS,
|
||||||
PLATFORM_CODES,
|
PLATFORM_CODES,
|
||||||
TRN_URLS,
|
TRN_URLS,
|
||||||
|
EVALUATION_CRITERIA,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -93,3 +94,6 @@ 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
|
||||||
@@ -37,3 +37,13 @@ tryout_coaches = db.Table(
|
|||||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tryout_managers = db.Table(
|
||||||
|
'tryout_managers',
|
||||||
|
db.Column(
|
||||||
|
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
|
db.Column(
|
||||||
|
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
|
|||||||
|
|
||||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||||
|
|
||||||
|
# Ordered list of (field_name, human_label) pairs for the player evaluation
|
||||||
|
# score criteria. Kept in a single place so the evaluation forms, batch
|
||||||
|
# evaluation page, and any future reporting all stay in sync.
|
||||||
|
EVALUATION_CRITERIA = [
|
||||||
|
('mecanics_score', 'Mecanics'),
|
||||||
|
('cohesion_score', 'Cohesion'),
|
||||||
|
('communication_score', 'Communication'),
|
||||||
|
('gamesense_score', 'Gamesense'),
|
||||||
|
('versatility_score', 'Versatility'),
|
||||||
|
('discipline_score', 'Discipline'),
|
||||||
|
('analysis_score', 'Analysis'),
|
||||||
|
('sport_ethics_score', 'Sport Ethics'),
|
||||||
|
('mental_score', 'Mental'),
|
||||||
|
]
|
||||||
|
|
||||||
ESPORT_GAMES = [
|
ESPORT_GAMES = [
|
||||||
'Valorant',
|
'Valorant',
|
||||||
'League of Legends',
|
'League of Legends',
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tryout event for player evaluations and team formation."""
|
"""Tryout event for player evaluations and team formation."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models._associations import tryout_coaches
|
from app.models._associations import tryout_coaches, tryout_managers
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
@@ -20,16 +20,17 @@ class Tryout(db.Model):
|
|||||||
max_players = db.Column(db.Integer, nullable=True)
|
max_players = db.Column(db.Integer, nullable=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
||||||
coach_id = db.Column(
|
coach_id = db.Column(
|
||||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||||
) # deprecated, kept for migration
|
) # deprecated, kept for migration
|
||||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||||
|
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||||
@@ -47,3 +48,17 @@ class Tryout(db.Model):
|
|||||||
if self.end_date is not None:
|
if self.end_date is not None:
|
||||||
return self.end_date < today
|
return self.end_date < today
|
||||||
return self.date < today
|
return self.date < today
|
||||||
|
|
||||||
|
def get_managers(self):
|
||||||
|
"""Managers attached to this tryout, both legacy and many-to-many."""
|
||||||
|
manager_list = list(self.managers)
|
||||||
|
if not manager_list and self.manager:
|
||||||
|
return [self.manager]
|
||||||
|
return manager_list
|
||||||
|
|
||||||
|
def get_coaches(self):
|
||||||
|
"""Coaches attached to this tryout, both legacy and many-to-many."""
|
||||||
|
coach_list = list(self.coaches)
|
||||||
|
if not coach_list and self.coach:
|
||||||
|
return [self.coach]
|
||||||
|
return coach_list
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ class Manager(User):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_manage_this_tryout(self, tryout):
|
def can_manage_this_tryout(self, tryout):
|
||||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
return (
|
||||||
|
tryout.created_by == self.id
|
||||||
|
or tryout.manager_id == self.id
|
||||||
|
or any(m.id == self.id for m in tryout.managers)
|
||||||
|
)
|
||||||
|
|
||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return True
|
return True
|
||||||
@@ -32,7 +36,13 @@ class Manager(User):
|
|||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
|
Tryout.query.filter(
|
||||||
|
or_(
|
||||||
|
Tryout.created_by == self.id,
|
||||||
|
Tryout.manager_id == self.id,
|
||||||
|
Tryout.managers.any(id=self.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(Tryout.date)
|
.order_by(Tryout.date)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import (
|
||||||
|
Admin, Coach, Manager, Player,
|
||||||
|
User, Tryout, Evaluation, TryoutRegistration,
|
||||||
|
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
|
||||||
|
)
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
@@ -245,3 +253,94 @@ def players_to_evaluate(tryout_id):
|
|||||||
]
|
]
|
||||||
|
|
||||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||||
|
|
||||||
|
|
||||||
|
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def batch_evaluate(tryout_id):
|
||||||
|
"""Evaluate multiple players at once in a tryout.
|
||||||
|
|
||||||
|
GET renders a single form listing every selected player with their
|
||||||
|
evaluation criteria. POST saves (creates or updates) all of them.
|
||||||
|
"""
|
||||||
|
if not current_user.can_evaluate():
|
||||||
|
flash('You do not have permission to evaluate players.', 'danger')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
tryout = Tryout.query.get_or_404(tryout_id)
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||||
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
|
# Resolve selected player ids (query string on GET, hidden fields on POST).
|
||||||
|
player_ids = []
|
||||||
|
for raw in request.values.getlist('player_ids'):
|
||||||
|
try:
|
||||||
|
pid = int(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if pid not in player_ids:
|
||||||
|
player_ids.append(pid)
|
||||||
|
|
||||||
|
if not player_ids:
|
||||||
|
flash('Please select at least one player to evaluate.', 'warning')
|
||||||
|
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
players = []
|
||||||
|
for pid in player_ids:
|
||||||
|
player = User.query.get(pid)
|
||||||
|
if not player or not isinstance(player, Player):
|
||||||
|
continue
|
||||||
|
is_registered = TryoutRegistration.query.filter_by(
|
||||||
|
tryout_id=tryout_id, player_id=pid,
|
||||||
|
).first() is not None
|
||||||
|
if not is_registered:
|
||||||
|
continue
|
||||||
|
existing = Evaluation.query.filter_by(
|
||||||
|
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
|
||||||
|
).first()
|
||||||
|
existing_scores = {
|
||||||
|
field_name: getattr(existing, field_name) if existing else None
|
||||||
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
|
}
|
||||||
|
players.append({
|
||||||
|
'player': player,
|
||||||
|
'existing': existing,
|
||||||
|
'existing_scores': existing_scores,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not players:
|
||||||
|
flash('No valid players selected for evaluation.', 'danger')
|
||||||
|
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
saved = 0
|
||||||
|
for entry in players:
|
||||||
|
pid = entry['player'].id
|
||||||
|
scores = {
|
||||||
|
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||||
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
|
}
|
||||||
|
comments = request.form.get(f'comments_{pid}')
|
||||||
|
position = request.form.get(f'position_recommendation_{pid}')
|
||||||
|
|
||||||
|
existing = entry['existing']
|
||||||
|
if existing:
|
||||||
|
_apply_evaluation(existing, scores, comments, position)
|
||||||
|
else:
|
||||||
|
evaluation = Evaluation(
|
||||||
|
tryout_id=tryout_id, player_id=pid,
|
||||||
|
evaluator_id=current_user.id,
|
||||||
|
)
|
||||||
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
|
db.session.add(evaluation)
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
return render_template('pages/batch_evaluate.html',
|
||||||
|
tryout=tryout, players=players,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
game_positions=GAME_POSITIONS)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.models import (
|
|||||||
TeamMatch,
|
TeamMatch,
|
||||||
TeamMatchParticipant,
|
TeamMatchParticipant,
|
||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
|
AppSettings,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import paginate
|
||||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
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)
|
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('')
|
@team_matches_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_matches():
|
def list_matches():
|
||||||
|
|||||||
+64
-5
@@ -30,6 +30,7 @@ from app.models import (
|
|||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
User,
|
||||||
|
AppSettings,
|
||||||
)
|
)
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import (
|
from app.validators import (
|
||||||
@@ -49,9 +50,20 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
def tryout_form_payload():
|
def tryout_form_payload():
|
||||||
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
"""The tryout form, shaped for marshmallow (ARCH-005)."""
|
||||||
return form_payload(list_fields=('coach_ids',), optional_blank=())
|
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
|
||||||
|
|
||||||
|
|
||||||
def coaches_from_ids(coach_ids):
|
def coaches_from_ids(coach_ids):
|
||||||
@@ -67,6 +79,13 @@ def coaches_from_ids(coach_ids):
|
|||||||
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
|
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
|
||||||
|
|
||||||
|
|
||||||
|
def managers_from_ids(manager_ids):
|
||||||
|
"""The manager accounts behind these ids, filtered by role."""
|
||||||
|
if not manager_ids:
|
||||||
|
return []
|
||||||
|
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
|
||||||
|
|
||||||
|
|
||||||
def _users_by_id(user_ids):
|
def _users_by_id(user_ids):
|
||||||
"""Load these users in one query, keyed by id.
|
"""Load these users in one query, keyed by id.
|
||||||
|
|
||||||
@@ -124,6 +143,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 = (
|
managers = (
|
||||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||||
@@ -160,12 +183,12 @@ def create_tryout():
|
|||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
status='upcoming',
|
status='upcoming',
|
||||||
target_org_team_id=data['target_org_team_id'],
|
target_org_team_id=data['target_org_team_id'],
|
||||||
manager_id=data['manager_id'],
|
|
||||||
)
|
)
|
||||||
db.session.add(tryout)
|
db.session.add(tryout)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||||
|
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Tryout created successfully!'), 'success')
|
flash(_('Tryout created successfully!'), 'success')
|
||||||
@@ -184,6 +207,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))
|
||||||
@@ -221,8 +248,14 @@ def edit_tryout(tryout_id):
|
|||||||
tryout.location = data['location']
|
tryout.location = data['location']
|
||||||
tryout.max_players = data['max_players']
|
tryout.max_players = data['max_players']
|
||||||
tryout.target_org_team_id = data['target_org_team_id']
|
tryout.target_org_team_id = data['target_org_team_id']
|
||||||
tryout.manager_id = data['manager_id']
|
|
||||||
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
# 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".
|
||||||
|
if 'coach_ids' in request.form:
|
||||||
|
tryout.coaches = coaches_from_ids(data['coach_ids'])
|
||||||
|
if 'manager_ids' in request.form:
|
||||||
|
tryout.managers = managers_from_ids(data['manager_ids'])
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Tryout updated successfully!'), 'success')
|
flash(_('Tryout updated successfully!'), 'success')
|
||||||
@@ -241,7 +274,7 @@ def view_tryout(tryout_id):
|
|||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Admin):
|
||||||
can_view = True
|
can_view = True
|
||||||
elif isinstance(current_user, Manager):
|
elif isinstance(current_user, Manager):
|
||||||
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
elif isinstance(current_user, Coach):
|
elif isinstance(current_user, Coach):
|
||||||
can_view = current_user.can_manage_this_tryout(tryout)
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
elif isinstance(current_user, Player):
|
elif isinstance(current_user, Player):
|
||||||
@@ -469,6 +502,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))
|
||||||
try:
|
try:
|
||||||
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
@@ -490,6 +526,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
|
tryout_id=tryout_id, player_id=player_id
|
||||||
).first_or_404()
|
).first_or_404()
|
||||||
@@ -513,6 +553,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))
|
||||||
try:
|
try:
|
||||||
data = PlayerSelectionSchema().load(form_payload())
|
data = PlayerSelectionSchema().load(form_payload())
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
@@ -561,6 +604,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 = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
|
|
||||||
registration = TryoutRegistration.query.filter_by(
|
registration = TryoutRegistration.query.filter_by(
|
||||||
@@ -597,6 +644,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))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
@@ -620,6 +671,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))
|
||||||
|
|
||||||
# The two ids arrive independently in the URL. Without this check, being
|
# 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
|
# allowed to manage tryout A was enough to modify a team belonging to
|
||||||
# tryout B, since only the tryout was authorised.
|
# tryout B, since only the tryout was authorised.
|
||||||
@@ -662,6 +717,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))
|
||||||
|
|
||||||
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()]
|
||||||
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
||||||
|
|
||||||
|
|||||||
+1311
File diff suppressed because it is too large
Load Diff
@@ -2037,3 +2037,30 @@ a:hover { color: var(--primary-dark); }
|
|||||||
.honeypot {
|
.honeypot {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Batch Evaluation - 2 cards wide layout */
|
||||||
|
.batch-eval-form {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-eval-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-eval-grid .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
|
||||||
|
.batch-eval-grid .form-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.batch-eval-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
|
||||||
|
{% block page_title %}Evaluate Players{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
{% for entry in players %}
|
||||||
|
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set positions = game_positions.get(tryout.game, []) %}
|
||||||
|
|
||||||
|
<div class="batch-eval-grid">
|
||||||
|
{% for entry in players %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-user"></i> {{ entry.player.username }}
|
||||||
|
{% if entry.existing %}
|
||||||
|
<span class="badge badge-success">Already Evaluated</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">Not Evaluated</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="eval-player-info mb-4">
|
||||||
|
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
|
||||||
|
<div>
|
||||||
|
<h3>{{ entry.player.username }}</h3>
|
||||||
|
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set pid = entry.player.id %}
|
||||||
|
{% set existing = entry.existing %}
|
||||||
|
{% set existing_scores = entry.existing_scores %}
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
{% for field_name, label in evaluation_criteria %}
|
||||||
|
<div class="form-group col-4">
|
||||||
|
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
|
||||||
|
<div class="score-input">
|
||||||
|
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" data-mirror>
|
||||||
|
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-12">
|
||||||
|
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
|
||||||
|
{% if positions %}
|
||||||
|
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
|
||||||
|
<option value="">-- Select Position --</option>
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="comments_{{ pid }}">Comments</label>
|
||||||
|
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Save All Evaluations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,54 +6,84 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
|
<h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-container">
|
<p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
|
||||||
<table class="table">
|
<form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
|
||||||
<thead>
|
<div class="table-container">
|
||||||
<tr>
|
<table class="table">
|
||||||
<th>{{ _('Player') }}</th>
|
<thead>
|
||||||
<th>{{ _('Contact') }}</th>
|
<tr>
|
||||||
<th>{{ _('Attendance') }}</th>
|
<th><input type="checkbox" id="select-all" data-action="toggle-all"></th>
|
||||||
<th>{{ _('Status') }}</th>
|
<th>Player</th>
|
||||||
<th>{{ _('Actions') }}</th>
|
<th>Contact</th>
|
||||||
</tr>
|
<th>Attendance</th>
|
||||||
</thead>
|
<th>Status</th>
|
||||||
<tbody>
|
<th>Actions</th>
|
||||||
{% for entry in players %}
|
</tr>
|
||||||
<tr>
|
</thead>
|
||||||
<td>
|
<tbody>
|
||||||
<div class="user-mini">
|
{% for entry in players %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||||
<span>{{ entry.player.username }}</span>
|
<span>{{ entry.player.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ entry.player.email }}</td>
|
<td>{{ entry.player.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
|
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.evaluated %}
|
{% if entry.evaluated %}
|
||||||
<span class="badge badge-success">Evaluated</span>
|
<span class="badge badge-success">Evaluated</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-warning">Not Evaluated</span>
|
<span class="badge badge-warning">Not Evaluated</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
|
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
|
||||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
|
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td>
|
<td colspan="6" class="text-center">No players registered for this tryout.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-clipboard-check"></i> Evaluate Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
function toggleAll() {
|
||||||
|
var boxes = document.querySelectorAll('.player-check');
|
||||||
|
var master = document.getElementById('select-all');
|
||||||
|
for (var i = 0; i < boxes.length; i++) {
|
||||||
|
boxes[i].checked = master.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerActions({
|
||||||
|
'toggle-all': toggleAll,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -145,6 +145,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Contracts Card -->
|
||||||
{% if user.role == 'player' %}
|
{% if user.role == 'player' %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -300,6 +300,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<script nonce="{{ csp_nonce }}">
|
<script nonce="{{ csp_nonce }}">
|
||||||
function showCreateForm() {
|
function showCreateForm() {
|
||||||
document.getElementById('createTeamForm').classList.remove('hidden');
|
document.getElementById('createTeamForm').classList.remove('hidden');
|
||||||
|
|||||||
@@ -67,15 +67,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-6">
|
<div class="form-group col-6">
|
||||||
<label for="manager_id">{{ _('Assigned Manager') }}</label>
|
<label>{{ _('Assigned Managers') }}</label>
|
||||||
<select id="manager_id" name="manager_id" class="form-select">
|
<div class="checkbox-grid">
|
||||||
<option value="">{{ _('-- No manager assigned --') }}</option>
|
|
||||||
{% for manager in managers %}
|
{% for manager in managers %}
|
||||||
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
|
{% set is_checked = false %}
|
||||||
{{ manager.username }}
|
{% if tryout %}
|
||||||
</option>
|
{% for m in tryout.get_managers() %}
|
||||||
|
{% if m.id == manager.id %}{% set is_checked = true %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="manager_ids" value="{{ manager.id }}" {% if is_checked %}checked{% endif %}>
|
||||||
|
<span>{{ manager.username }}</span>
|
||||||
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</div>
|
||||||
|
<small class="text-muted">{{ _('Select one or more managers for this tryout.') }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -70,16 +70,22 @@
|
|||||||
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Manager</span>
|
<span class="detail-label">Manager(s)</span>
|
||||||
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
|
<span class="detail-value">
|
||||||
|
{% set mgrs = tryout.get_managers() %}
|
||||||
|
{% if mgrs %}
|
||||||
|
{{ mgrs | map(attribute='username') | join(', ') }}
|
||||||
|
{% else %}
|
||||||
|
Not assigned
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Coaches</span>
|
<span class="detail-label">Coaches</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
{% if tryout.coaches %}
|
{% set cos = tryout.get_coaches() %}
|
||||||
{{ tryout.coaches | map(attribute='username') | join(', ') }}
|
{% if cos %}
|
||||||
{% elif tryout.coach %}
|
{{ cos | map(attribute='username') | join(', ') }}
|
||||||
{{ tryout.coach.username }}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
Not assigned
|
Not assigned
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -534,6 +540,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<style>
|
<style>
|
||||||
.presence-toggle-btn {
|
.presence-toggle-btn {
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
|
|||||||
@@ -949,6 +949,7 @@ class TryoutSchema(StripMixin):
|
|||||||
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
|
||||||
manager_id = fields.Integer(allow_none=True, load_default=None)
|
manager_id = fields.Integer(allow_none=True, load_default=None)
|
||||||
coach_ids = fields.List(fields.Integer(), load_default=list)
|
coach_ids = fields.List(fields.Integer(), load_default=list)
|
||||||
|
manager_ids = fields.List(fields.Integer(), load_default=list)
|
||||||
|
|
||||||
@validates_schema
|
@validates_schema
|
||||||
def validate_span(self, data, **kwargs):
|
def validate_span(self, data, **kwargs):
|
||||||
|
|||||||
Reference in New Issue
Block a user