Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts
This commit is contained in:
+373
@@ -0,0 +1,373 @@
|
||||
"""Team Tryouts Application - Flask Application Factory.
|
||||
|
||||
This module provides the application factory for creating and configuring
|
||||
the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||
from flask_cors import CORS
|
||||
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
import markupsafe
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def nl2br(value):
|
||||
"""Convert newlines to HTML line breaks.
|
||||
|
||||
Args:
|
||||
value: String value to convert.
|
||||
|
||||
Returns:
|
||||
Markup: HTML-safe string with line breaks.
|
||||
"""
|
||||
if value:
|
||||
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
|
||||
return ''
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application.
|
||||
|
||||
Initializes Flask with:
|
||||
- Secret key for session security
|
||||
- Database configuration
|
||||
- CSRF protection
|
||||
- CORS with restricted origins
|
||||
- Login manager
|
||||
- Rate limiting
|
||||
- All route blueprints
|
||||
- Security headers and HTTPS redirects
|
||||
- Custom error handlers
|
||||
- Health check endpoint
|
||||
- Structured logging
|
||||
|
||||
Handles database initialization and seeding with sample data if empty.
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||
if not app.config['SECRET_KEY']:
|
||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
|
||||
# File upload size limit (16 MB)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||
|
||||
# Secure session cookie settings
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||
|
||||
# Configure CORS - restrict to specific origins in production
|
||||
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
||||
|
||||
if allowed_origins:
|
||||
CORS(
|
||||
app,
|
||||
origins=allowed_origins,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600, # Cache preflight for 1 hour
|
||||
)
|
||||
else:
|
||||
# When no origins specified, allow all (development) or none (production)
|
||||
# In production with a reverse proxy, CORS is handled at the Nginx level
|
||||
CORS(
|
||||
app,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600,
|
||||
)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
# Configure structured logging
|
||||
from app.logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.evaluations import evaluations_bp
|
||||
from app.routes.users import users_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.matches import matches_bp
|
||||
from app.routes.team_matches import team_matches_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
app.register_blueprint(evaluations_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
app.register_blueprint(team_matches_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
|
||||
# =========================================================================
|
||||
# Security Headers
|
||||
# =========================================================================
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses.
|
||||
|
||||
Implements defense-in-depth with comprehensive HTTP security headers.
|
||||
These complement the headers set by Nginx in production.
|
||||
|
||||
HSTS is only sent in production (non-debug) to avoid breaking
|
||||
local development over plain HTTP.
|
||||
"""
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), '
|
||||
'interest-cohort=(), payment=(), usb=()'
|
||||
)
|
||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' https://cdnjs.cloudflare.com; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
# Only enable HSTS when HTTPS is actually being used
|
||||
# (either direct TLS or behind a proxy that terminates TLS)
|
||||
is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
if is_https:
|
||||
response.headers['Strict-Transport-Security'] = (
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS Redirect (Production only)
|
||||
# =========================================================================
|
||||
@app.before_request
|
||||
def force_https():
|
||||
"""Redirect all HTTP requests to HTTPS in production.
|
||||
|
||||
Respects the X-Forwarded-Proto header from reverse proxies.
|
||||
Can be disabled via FORCE_HTTPS environment variable.
|
||||
"""
|
||||
if not app.debug:
|
||||
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
|
||||
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true':
|
||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||
|
||||
# =========================================================================
|
||||
# Health Check Endpoint
|
||||
# =========================================================================
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring and load balancers.
|
||||
|
||||
Verifies database connectivity and application health.
|
||||
Returns 200 with basic status info or 503 if unhealthy.
|
||||
|
||||
Returns:
|
||||
Response: JSON health status.
|
||||
"""
|
||||
health_data = {
|
||||
'status': 'healthy',
|
||||
'app': 'team-tryouts',
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
health_data['database'] = 'connected'
|
||||
except Exception as e:
|
||||
health_data['status'] = 'unhealthy'
|
||||
health_data['database'] = f'error: {str(e)}'
|
||||
return jsonify(health_data), 503
|
||||
|
||||
return jsonify(health_data), 200
|
||||
|
||||
# =========================================================================
|
||||
# Custom Error Handlers
|
||||
# =========================================================================
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
"""Handle 400 Bad Request errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/coach-availability') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
"""Handle 401 Unauthorized errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login for pages, JSON for API.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
from flask import flash as _flash
|
||||
_flash('Please log in to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
"""Handle 403 Forbidden errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 Not Found errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@app.errorhandler(429)
|
||||
def too_many_requests(error):
|
||||
"""Handle 429 Too Many Requests errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API or rendered page.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Too many requests',
|
||||
'message': 'Please try again later.'
|
||||
}), 429
|
||||
return render_template('errors/429.html', error=error), 429
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 Internal Server Error.
|
||||
|
||||
Never exposes stack traces to users. Logs the full error internally.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Generic error page or JSON.
|
||||
"""
|
||||
# Log the full error for debugging
|
||||
app.logger.error('Internal Server Error: %s', str(error), exc_info=True)
|
||||
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.'
|
||||
}), 500
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
"""Catch-all handler for any unhandled HTTP exceptions.
|
||||
|
||||
Args:
|
||||
error: The HTTPException object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API, re-raises for others.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': error.name,
|
||||
'message': error.description,
|
||||
'code': error.code
|
||||
}), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
# Database Initialization
|
||||
# =========================================================================
|
||||
with app.app_context():
|
||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
||||
from app.models import User
|
||||
db.create_all()
|
||||
|
||||
# Seed database if empty
|
||||
if User.query.count() == 0:
|
||||
from app.supporting_scrits.seed import seed_database
|
||||
seed_database()
|
||||
|
||||
# Start the Discord bot for notifications
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
start_bot()
|
||||
except Exception as e:
|
||||
app.logger.warning('Could not start Discord bot: %s', e)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only used for development - production uses wsgi.py (Waitress)
|
||||
app = create_app()
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
if debug_mode:
|
||||
app.logger.warning(
|
||||
'Running in DEBUG mode with Flask built-in server. '
|
||||
'This is NOT suitable for production. Use wsgi.py instead.'
|
||||
)
|
||||
app.run(debug=debug_mode, host='0.0.0.0', port=10000)
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Unified Discord bot for Team Tryouts notifications.
|
||||
|
||||
This module provides a persistent bot that handles:
|
||||
- One on One request approvals/rejections via reactions
|
||||
- Match/tryout schedule addition notifications with attendance confirmation
|
||||
- Daily reminders at 18:00 EDT for upcoming events
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from queue import Queue, Empty
|
||||
from discord import Forbidden, HTTPException, NotFound, Intents
|
||||
from discord.ext import commands
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||
print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN')
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Emoji constants
|
||||
CHECK_EMOJI = '✅' # Green checkmark
|
||||
CROSS_EMOJI = '❌' # Red X
|
||||
|
||||
|
||||
class TeamTryoutsBot(commands.Bot):
|
||||
"""Unified Discord bot for Team Tryouts notifications.
|
||||
|
||||
Handles One on One requests, schedule additions, and daily reminders.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
intents.dm_messages = True
|
||||
intents.dm_reactions = True
|
||||
intents.reactions = True
|
||||
intents.guilds = True
|
||||
|
||||
super().__init__(command_prefix='!', intents=intents)
|
||||
self.pending_requests = {} # Maps message_id to {type, id} for reaction handling
|
||||
self.message_queue = Queue() # Thread-safe queue for messages from Flask
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
|
||||
|
||||
async def setup_hook(self):
|
||||
"""Called when the bot is ready."""
|
||||
logger.info(f'TeamTryoutsBot logged in as {self.user}')
|
||||
|
||||
async def on_ready(self):
|
||||
"""Log when the bot is ready and start background tasks."""
|
||||
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
|
||||
|
||||
# Start the queue processing task
|
||||
self.loop.create_task(self.process_queue())
|
||||
|
||||
# Start the daily reminder scheduler
|
||||
self.loop.create_task(self.start_scheduler())
|
||||
|
||||
async def start_scheduler(self):
|
||||
"""Start the APScheduler for daily reminders."""
|
||||
try:
|
||||
self.scheduler.add_job(
|
||||
self.send_daily_reminders,
|
||||
trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone),
|
||||
id='daily_reminders',
|
||||
replace_existing=True
|
||||
)
|
||||
self.scheduler.start()
|
||||
logger.info('Daily reminder scheduler started (18:00 EDT)')
|
||||
except Exception as e:
|
||||
logger.error(f'Error starting scheduler: {e}')
|
||||
|
||||
async def process_queue(self):
|
||||
"""Process messages from the queue (runs continuously)."""
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
item = self.message_queue.get_nowait()
|
||||
except Empty:
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
|
||||
if item.get('type') == 'one_on_one_request':
|
||||
await self._send_one_on_one_dm(**item['data'])
|
||||
elif item.get('type') == 'schedule_addition':
|
||||
await self._send_schedule_notification(**item['data'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing queue: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
def is_dm_channel(self, channel) -> bool:
|
||||
"""Check if a channel is a DM channel."""
|
||||
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
|
||||
|
||||
async def on_reaction_add(self, reaction, user):
|
||||
"""Handle when a reaction is added to a message."""
|
||||
if user.bot:
|
||||
return
|
||||
|
||||
if not self.is_dm_channel(reaction.message.channel):
|
||||
return
|
||||
|
||||
message_id = reaction.message.id
|
||||
|
||||
if message_id not in self.pending_requests:
|
||||
return
|
||||
|
||||
request_info = self.pending_requests[message_id]
|
||||
emoji_str = str(reaction.emoji)
|
||||
|
||||
handler_type = request_info.get('type')
|
||||
request_id = request_info.get('id')
|
||||
|
||||
if emoji_str == CHECK_EMOJI:
|
||||
if handler_type == 'one_on_one':
|
||||
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
|
||||
elif handler_type == 'schedule_addition':
|
||||
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
|
||||
elif emoji_str == CROSS_EMOJI:
|
||||
if handler_type == 'one_on_one':
|
||||
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
|
||||
elif handler_type == 'schedule_addition':
|
||||
await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
|
||||
|
||||
async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str,
|
||||
team_name: str, date_str: str, start_time: str, end_time: str,
|
||||
points: str, request_id: int) -> int:
|
||||
"""Send a One on One request DM to a coach with reactions."""
|
||||
try:
|
||||
user_id = int(coach_discord_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'")
|
||||
return None
|
||||
|
||||
try:
|
||||
user = await self.fetch_user(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
message = (
|
||||
"📅 **One on One Request**\n\n"
|
||||
f"**Player:** {player_name}\n"
|
||||
f"**Team:** {team_name or 'Unknown Team'}\n"
|
||||
f"**Date:** {date_str}\n"
|
||||
f"**Time:** {start_time} - {end_time}\n"
|
||||
f"**Discussion Points:** {points or 'No specific points provided'}\n\n"
|
||||
"Please respond by clicking a reaction below:\n"
|
||||
f"{CHECK_EMOJI} - Confirm the meeting\n"
|
||||
f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)"
|
||||
)
|
||||
|
||||
msg = await user.send(message)
|
||||
await msg.add_reaction(CHECK_EMOJI)
|
||||
await msg.add_reaction(CROSS_EMOJI)
|
||||
|
||||
# Track this pending request
|
||||
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
|
||||
|
||||
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
|
||||
return msg.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending One on One DM: {e}")
|
||||
return None
|
||||
|
||||
async def _send_schedule_notification(self, user_id: int, event_type: str,
|
||||
event_title: str, event_date: str,
|
||||
event_time: str, reference_id: int) -> int:
|
||||
"""Send a schedule addition notification to a player.
|
||||
|
||||
Args:
|
||||
user_id: Database primary key of the User (NOT Discord user ID).
|
||||
event_type: 'match' or 'tryout'.
|
||||
event_title: Title of the event.
|
||||
event_date: Date string.
|
||||
event_time: Time string.
|
||||
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
||||
"""
|
||||
try:
|
||||
# Look up the DB user to get their Discord user ID
|
||||
from app.models.models import User as DBUser
|
||||
db_user = DBUser.query.get(user_id)
|
||||
if not db_user:
|
||||
logger.warning(f"DB user {user_id} not found for schedule notification")
|
||||
return None
|
||||
|
||||
if not db_user.discord_user_id:
|
||||
logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM")
|
||||
return None
|
||||
|
||||
discord_uid = int(db_user.discord_user_id)
|
||||
user = await self.fetch_user(discord_uid)
|
||||
if not user:
|
||||
logger.warning(f"Could not fetch Discord user {discord_uid}")
|
||||
return None
|
||||
|
||||
event_name = "Match" if event_type == 'match' else "Tryout"
|
||||
|
||||
message = (
|
||||
f"📅 **{event_name} Scheduled**\n\n"
|
||||
f"You have been added to the following {event_type}:\n"
|
||||
f"**{event_title}**\n"
|
||||
f"**Date:** {event_date}\n"
|
||||
f"**Time:** {event_time}\n\n"
|
||||
"Please confirm your attendance:\n"
|
||||
f"{CHECK_EMOJI} - Confirm attendance\n"
|
||||
f"{CROSS_EMOJI} - Decline"
|
||||
)
|
||||
|
||||
msg = await user.send(message)
|
||||
await msg.add_reaction(CHECK_EMOJI)
|
||||
await msg.add_reaction(CROSS_EMOJI)
|
||||
|
||||
# Track this pending request
|
||||
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
|
||||
|
||||
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
|
||||
return msg.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending schedule notification: {e}")
|
||||
return None
|
||||
|
||||
async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message):
|
||||
"""Handle coach approving a One on One request."""
|
||||
try:
|
||||
from app.models.models import OneOnOneRequest, db
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
request = OneOnOneRequest.query.options(
|
||||
joinedload(OneOnOneRequest.player),
|
||||
joinedload(OneOnOneRequest.coach)
|
||||
).get(request_id)
|
||||
if not request:
|
||||
return
|
||||
|
||||
if request.coach.discord_user_id != str(coach.id):
|
||||
await original_message.channel.send("⚠️ You are not the intended recipient.")
|
||||
return
|
||||
|
||||
# Capture data before commit (to avoid expired session issues)
|
||||
player = request.player
|
||||
coach_obj = request.coach
|
||||
player_full_name = player.full_name
|
||||
player_discord_id = player.discord_user_id
|
||||
|
||||
request.status = 'approved'
|
||||
request.responded_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
await original_message.channel.send(
|
||||
f"✅ You have **approved** the One on One session with {player_full_name}."
|
||||
)
|
||||
|
||||
# Pass the pre-fetched data to avoid session expiration issues
|
||||
await self.notify_player_about_one_on_one_direct(
|
||||
player_discord_id=player_discord_id,
|
||||
player_full_name=player_full_name,
|
||||
coach_full_name=coach_obj.full_name,
|
||||
request=request,
|
||||
approved=True
|
||||
)
|
||||
del self.pending_requests[message_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling approval: {e}")
|
||||
|
||||
async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message):
|
||||
"""Handle coach rejecting a One on One request."""
|
||||
try:
|
||||
from app.models.models import OneOnOneRequest, db
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
request = OneOnOneRequest.query.options(
|
||||
joinedload(OneOnOneRequest.player),
|
||||
joinedload(OneOnOneRequest.coach)
|
||||
).get(request_id)
|
||||
if not request:
|
||||
return
|
||||
|
||||
if request.coach.discord_user_id != str(coach.id):
|
||||
await original_message.channel.send("⚠️ You are not the intended recipient.")
|
||||
return
|
||||
|
||||
# Capture data before commit (to avoid expired session issues)
|
||||
player = request.player
|
||||
coach_obj = request.coach
|
||||
player_full_name = player.full_name
|
||||
player_discord_id = player.discord_user_id
|
||||
|
||||
refusal_note = None
|
||||
try:
|
||||
async for reply in original_message.channel.history(limit=20):
|
||||
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
|
||||
refusal_note = reply.content
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check for reply message: {e}")
|
||||
|
||||
request.status = 'rejected'
|
||||
request.responded_at = datetime.utcnow()
|
||||
if refusal_note:
|
||||
request.coach_rejection_message = refusal_note
|
||||
db.session.commit()
|
||||
|
||||
rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}."
|
||||
if refusal_note:
|
||||
rejection_msg += f"\n**Reason:** {refusal_note}"
|
||||
else:
|
||||
rejection_msg += "\n\nℹ️ The player has been notified that you are not available."
|
||||
|
||||
await original_message.channel.send(rejection_msg)
|
||||
await self.notify_player_about_one_on_one_direct(
|
||||
player_discord_id=player_discord_id,
|
||||
player_full_name=player_full_name,
|
||||
coach_full_name=coach_obj.full_name,
|
||||
request=request,
|
||||
approved=False,
|
||||
refusal_note=refusal_note
|
||||
)
|
||||
del self.pending_requests[message_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling rejection: {e}")
|
||||
|
||||
async def handle_attendance_confirm(self, player, message_id, reference_id, original_message):
|
||||
"""Handle player confirming attendance for a match/tryout."""
|
||||
try:
|
||||
from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db
|
||||
|
||||
request_info = self.pending_requests[message_id]
|
||||
event_type = request_info.get('event_type')
|
||||
|
||||
if event_type == 'match':
|
||||
participant = MatchParticipant.query.get(reference_id)
|
||||
if participant:
|
||||
participant.attendance_confirmed = True
|
||||
elif event_type == 'tryout':
|
||||
registration = TryoutRegistration.query.get(reference_id)
|
||||
if registration:
|
||||
registration.attendance_confirmed = True
|
||||
|
||||
db.session.commit()
|
||||
|
||||
await original_message.channel.send("✅ Your attendance has been confirmed!")
|
||||
del self.pending_requests[message_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling attendance confirmation: {e}")
|
||||
|
||||
async def handle_attendance_decline(self, player, message_id, reference_id, original_message):
|
||||
"""Handle player declining attendance for a match/tryout."""
|
||||
try:
|
||||
from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db
|
||||
|
||||
request_info = self.pending_requests[message_id]
|
||||
event_type = request_info.get('event_type')
|
||||
|
||||
if event_type == 'match':
|
||||
participant = MatchParticipant.query.get(reference_id)
|
||||
if participant:
|
||||
db.session.delete(participant)
|
||||
elif event_type == 'tryout':
|
||||
registration = TryoutRegistration.query.get(reference_id)
|
||||
if registration:
|
||||
registration.status = 'no_show'
|
||||
|
||||
db.session.commit()
|
||||
|
||||
await original_message.channel.send("❌ Your attendance has been declined.")
|
||||
del self.pending_requests[message_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling attendance decline: {e}")
|
||||
|
||||
async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None):
|
||||
"""Send confirmation to player about One on One response.
|
||||
|
||||
This is the legacy method kept for backward compatibility with any
|
||||
callers that pass a fully-loaded request object.
|
||||
"""
|
||||
try:
|
||||
player = request.player
|
||||
coach = request.coach
|
||||
|
||||
if not player or not player.discord_user_id:
|
||||
logger.warning(f"Player has no Discord user ID for request {request.id}")
|
||||
return
|
||||
|
||||
if not coach:
|
||||
logger.warning(f"Coach not found for request {request.id}")
|
||||
return
|
||||
|
||||
await self.notify_player_about_one_on_one_direct(
|
||||
player_discord_id=player.discord_user_id,
|
||||
player_full_name=player.full_name,
|
||||
coach_full_name=coach.full_name,
|
||||
request=request,
|
||||
approved=approved,
|
||||
refusal_note=refusal_note
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying player about One on One: {e}")
|
||||
|
||||
async def notify_player_about_one_on_one_direct(self, player_discord_id, player_full_name,
|
||||
coach_full_name, request,
|
||||
approved=True, refusal_note=None):
|
||||
"""Send confirmation to player about One on One response using pre-fetched data.
|
||||
|
||||
This method avoids session expiration issues by using data captured before
|
||||
the database commit.
|
||||
|
||||
Args:
|
||||
player_discord_id: The player's Discord user ID string.
|
||||
player_full_name: The player's full name.
|
||||
coach_full_name: The coach's full name.
|
||||
request: The OneOnOneRequest object (for date/time/points data only).
|
||||
approved: Whether the session was approved.
|
||||
refusal_note: Optional coach refusal reason.
|
||||
"""
|
||||
try:
|
||||
if not player_discord_id:
|
||||
logger.warning(f"Player has no Discord user ID for request {request.id}")
|
||||
return
|
||||
|
||||
player_user = await self.fetch_user(int(player_discord_id))
|
||||
if not player_user:
|
||||
logger.warning(f"Could not fetch Discord user {player_discord_id}")
|
||||
return
|
||||
|
||||
if approved:
|
||||
message = (
|
||||
"🎉 **One on One Session Confirmed!**\n\n"
|
||||
f"Your coach **{coach_full_name}** has approved your request:\n"
|
||||
f"**Date:** {request.date.strftime('%A, %B %d, %Y')}\n"
|
||||
f"**Time:** {request.start_time.strftime('%I:%M %p')} - {request.end_time.strftime('%I:%M %p')}\n"
|
||||
f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n"
|
||||
"Please prepare for your session!"
|
||||
)
|
||||
else:
|
||||
if refusal_note:
|
||||
message = (
|
||||
"😞 **One on One Session Rejected**\n\n"
|
||||
f"Your coach **{coach_full_name}** has declined:\n"
|
||||
f"**Reason:** {refusal_note}\n\n"
|
||||
"Please try selecting a different time slot."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"😞 **One on One Session Unavailable**\n\n"
|
||||
f"Your coach **{coach_full_name}** is not available.\n\n"
|
||||
"Please try selecting a different time slot."
|
||||
)
|
||||
|
||||
await player_user.send(message)
|
||||
logger.info(f"Sent One on One notification to player {player_full_name} (request {request.id})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in direct One on One notification: {e}")
|
||||
|
||||
async def send_daily_reminders(self):
|
||||
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
|
||||
try:
|
||||
from app.models.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
now = datetime.now(self.timezone)
|
||||
tomorrow = now.date() + timedelta(days=1)
|
||||
|
||||
# Find matches for tomorrow
|
||||
matches = Match.query.filter(Match.date == tomorrow).all()
|
||||
for match in matches:
|
||||
participants = MatchParticipant.query.filter_by(match_id=match.id).all()
|
||||
for participant in participants:
|
||||
if participant.player.discord_user_id:
|
||||
await self.send_match_reminder(participant.player, match)
|
||||
|
||||
# Find tryouts for tomorrow
|
||||
tryouts = Tryout.query.filter(Tryout.date == tomorrow).all()
|
||||
for tryout in tryouts:
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
|
||||
for reg in registrations:
|
||||
if reg.player.discord_user_id:
|
||||
await self.send_tryout_reminder(reg.player, tryout)
|
||||
|
||||
# Find One on One sessions for tomorrow (only approved ones)
|
||||
one_on_ones = OneOnOneRequest.query.options(
|
||||
joinedload(OneOnOneRequest.player),
|
||||
joinedload(OneOnOneRequest.coach)
|
||||
).filter(
|
||||
OneOnOneRequest.date == tomorrow,
|
||||
OneOnOneRequest.status == 'approved'
|
||||
).all()
|
||||
for session in one_on_ones:
|
||||
if session.player and session.player.discord_user_id:
|
||||
await self.send_one_on_one_reminder(session.player, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending daily reminders: {e}")
|
||||
|
||||
async def send_match_reminder(self, player, match):
|
||||
"""Send match reminder to player."""
|
||||
try:
|
||||
player_user = await self.fetch_user(int(player.discord_user_id))
|
||||
message = (
|
||||
"🔔 **Match Reminder**\n\n"
|
||||
f"Your match **{match.title}** is scheduled for tomorrow:\n"
|
||||
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
|
||||
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
|
||||
f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
|
||||
f"**Location:** {match.location or 'TBD'}\n\n"
|
||||
"Please confirm your attendance in the app."
|
||||
)
|
||||
await player_user.send(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending match reminder: {e}")
|
||||
|
||||
async def send_tryout_reminder(self, player, tryout):
|
||||
"""Send tryout reminder to player."""
|
||||
try:
|
||||
player_user = await self.fetch_user(int(player.discord_user_id))
|
||||
message = (
|
||||
"🔔 **Tryout Reminder**\n\n"
|
||||
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
|
||||
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
|
||||
f"**Location:** {tryout.location or 'TBD'}\n\n"
|
||||
"Please confirm your attendance in the app."
|
||||
)
|
||||
await player_user.send(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending tryout reminder: {e}")
|
||||
|
||||
async def send_one_on_one_reminder(self, player, session):
|
||||
"""Send One on One reminder to player."""
|
||||
try:
|
||||
player_user = await self.fetch_user(int(player.discord_user_id))
|
||||
message = (
|
||||
"🔔 **One on One Reminder**\n\n"
|
||||
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
|
||||
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
|
||||
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
|
||||
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
|
||||
"Please prepare for your session!"
|
||||
)
|
||||
await player_user.send(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending One on One reminder: {e}")
|
||||
|
||||
|
||||
# Global bot instance
|
||||
bot_instance = None
|
||||
bot_thread = None
|
||||
|
||||
|
||||
def get_bot():
|
||||
"""Get or create the bot instance."""
|
||||
global bot_instance
|
||||
if bot_instance is None:
|
||||
bot_instance = TeamTryoutsBot()
|
||||
return bot_instance
|
||||
|
||||
|
||||
def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str,
|
||||
team_name: str, date_str: str, start_time: str, end_time: str,
|
||||
points: str, request_id: int) -> bool:
|
||||
"""Queue a One on One request DM to be sent by the bot."""
|
||||
bot = get_bot()
|
||||
try:
|
||||
bot.message_queue.put({
|
||||
'type': 'one_on_one_request',
|
||||
'data': {
|
||||
'coach_name': coach_name,
|
||||
'coach_discord_id': coach_discord_id,
|
||||
'player_name': player_name,
|
||||
'team_name': team_name,
|
||||
'date_str': date_str,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'points': points,
|
||||
'request_id': request_id
|
||||
}
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing One on One DM: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_schedule_notification(user_id: int, event_type: str, event_title: str,
|
||||
event_date: str, event_time: str, reference_id: int) -> bool:
|
||||
"""Queue a schedule addition notification to be sent by the bot."""
|
||||
bot = get_bot()
|
||||
try:
|
||||
bot.message_queue.put({
|
||||
'type': 'schedule_addition',
|
||||
'data': {
|
||||
'user_id': user_id,
|
||||
'event_type': event_type,
|
||||
'event_title': event_title,
|
||||
'event_date': event_date,
|
||||
'event_time': event_time,
|
||||
'reference_id': reference_id
|
||||
}
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing schedule notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def start_bot():
|
||||
"""Start the Discord bot in the background."""
|
||||
global bot_thread
|
||||
|
||||
bot = get_bot()
|
||||
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||||
def run_bot():
|
||||
try:
|
||||
bot.run(DISCORD_BOT_TOKEN)
|
||||
except Exception as e:
|
||||
logger.error(f"Bot error: {e}")
|
||||
|
||||
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||||
bot_thread.start()
|
||||
logger.info("TeamTryoutsBot started in background thread")
|
||||
elif not DISCORD_BOT_TOKEN:
|
||||
logger.warning("DISCORD_BOT_TOKEN not set, bot not started")
|
||||
@@ -0,0 +1,46 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
# Database and extension initialization
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
csrf = CSRFProtect()
|
||||
|
||||
# Rate limiter for brute-force protection
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""
|
||||
Hash a plain text password using werkzeug's security functions.
|
||||
|
||||
Args:
|
||||
password (str): The plain text password to hash.
|
||||
|
||||
Returns:
|
||||
str: The hashed password string.
|
||||
"""
|
||||
return generate_password_hash(password)
|
||||
|
||||
|
||||
def check_password(password_hash, password):
|
||||
"""
|
||||
Verify a password against its hash.
|
||||
|
||||
Args:
|
||||
password_hash (str): The stored password hash.
|
||||
password (str): The plain text password to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if the password matches the hash, False otherwise.
|
||||
"""
|
||||
return check_password_hash(password_hash, password)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Structured logging configuration for the Team Tryouts application.
|
||||
|
||||
This module configures rotating file handlers for application logs,
|
||||
with separate files for errors, authentication events, and general logs.
|
||||
Sensitive data (passwords, tokens) is automatically filtered out.
|
||||
|
||||
Usage:
|
||||
from logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import re
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""Logging filter that redacts sensitive information from log messages.
|
||||
|
||||
Filters out: passwords, API keys, session tokens, and other secrets
|
||||
that might accidentally be logged.
|
||||
"""
|
||||
|
||||
# Patterns to redact
|
||||
SENSITIVE_PATTERNS = [
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'),
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'),
|
||||
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
||||
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
||||
]
|
||||
|
||||
def filter(self, record):
|
||||
"""Apply redaction to the log record's message.
|
||||
|
||||
Args:
|
||||
record: The log record to filter.
|
||||
|
||||
Returns:
|
||||
bool: Always True (never drops records, only redacts).
|
||||
"""
|
||||
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
||||
msg = record.msg
|
||||
for pattern, replacement in self.SENSITIVE_PATTERNS:
|
||||
if callable(replacement):
|
||||
msg = pattern.sub(replacement, msg)
|
||||
else:
|
||||
msg = pattern.sub(replacement, msg)
|
||||
record.msg = msg
|
||||
return True
|
||||
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure structured logging for the Flask application.
|
||||
|
||||
Sets up three rotating file handlers:
|
||||
- errors.log: ERROR and CRITICAL level messages
|
||||
- auth.log: Authentication-related events (INFO and above)
|
||||
- app.log: All application logs (DEBUG and above, configurable)
|
||||
|
||||
Also configures console output for development.
|
||||
|
||||
Args:
|
||||
app: The Flask application instance to configure logging for.
|
||||
"""
|
||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Remove default Flask handlers to avoid duplicate logging
|
||||
app.logger.handlers.clear()
|
||||
|
||||
# Set base log level from environment (default: INFO)
|
||||
log_level_name = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
app.logger.setLevel(log_level)
|
||||
|
||||
# Create the sensitive data filter
|
||||
sensitive_filter = SensitiveDataFilter()
|
||||
|
||||
# Formatter with timestamp, level, module, and message
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. Error Log Handler
|
||||
# -------------------------------------------------------------------------
|
||||
error_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
error_handler.addFilter(sensitive_filter)
|
||||
app.logger.addHandler(error_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. Authentication Log Handler
|
||||
# -------------------------------------------------------------------------
|
||||
auth_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'auth.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5
|
||||
)
|
||||
auth_handler.setLevel(logging.INFO)
|
||||
auth_handler.setFormatter(formatter)
|
||||
auth_handler.addFilter(sensitive_filter)
|
||||
|
||||
# Create a named logger specifically for auth events
|
||||
auth_logger = logging.getLogger('team_tryouts.auth')
|
||||
auth_logger.setLevel(logging.INFO)
|
||||
auth_logger.addHandler(auth_handler)
|
||||
auth_logger.propagate = False # Don't double-log to root
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. Application Log Handler (general)
|
||||
# -------------------------------------------------------------------------
|
||||
app_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
)
|
||||
app_handler.setLevel(log_level)
|
||||
app_handler.setFormatter(formatter)
|
||||
app_handler.addFilter(sensitive_filter)
|
||||
app.logger.addHandler(app_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 4. Console Handler (for development)
|
||||
# -------------------------------------------------------------------------
|
||||
if os.getenv('FLASK_DEBUG', 'false').lower() == 'true':
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(sensitive_filter)
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
# Log startup information
|
||||
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
||||
app.logger.info('Application startup')
|
||||
|
||||
return app.logger
|
||||
|
||||
|
||||
# Module-level auth logger factory
|
||||
def get_auth_logger():
|
||||
"""Get the authentication event logger.
|
||||
|
||||
Returns:
|
||||
logging.Logger: Logger for authentication events.
|
||||
"""
|
||||
return logging.getLogger('team_tryouts.auth')
|
||||
@@ -0,0 +1,95 @@
|
||||
"""All models — split into individual files for maintainability.
|
||||
|
||||
Import this module to register all models with SQLAlchemy and expose every
|
||||
class, constant, and helper for use throughout the application.
|
||||
|
||||
Usage::
|
||||
|
||||
from app.models import User, Admin, Evaluation, ESPORT_GAMES, ...
|
||||
|
||||
Backward-compatible — no consumer changes needed.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Layer 0: constants (no app deps)
|
||||
# =========================================================================
|
||||
from app.models._constants import (
|
||||
USER_TYPES,
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
GAME_PLATFORMS,
|
||||
PLATFORM_CODES,
|
||||
TRN_URLS,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Layer 1: loaders & associations
|
||||
# =========================================================================
|
||||
from app.models._loaders import load_user # noqa: F401 — registers Flask-Login callback
|
||||
|
||||
# =========================================================================
|
||||
# Layer 2: abstract base classes
|
||||
# =========================================================================
|
||||
from app.models.availability.base import BaseAvailability
|
||||
from app.models.match_model.base import BaseMatch
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
# =========================================================================
|
||||
# Layer 3: user hierarchy (polymorphic)
|
||||
# =========================================================================
|
||||
from app.models.user_model.user import User
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.player import Player
|
||||
from app.models.user_model.scout import Scout
|
||||
|
||||
# =========================================================================
|
||||
# Layer 4: org_team + junction
|
||||
# =========================================================================
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
# =========================================================================
|
||||
# Layer 5: concrete availability models
|
||||
# =========================================================================
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
from app.models.availability.coach_availability import CoachAvailability
|
||||
|
||||
# =========================================================================
|
||||
# Layer 6: tryout + registration
|
||||
# =========================================================================
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
# =========================================================================
|
||||
# Layer 7: evaluation
|
||||
# =========================================================================
|
||||
from app.models.evaluation import Evaluation
|
||||
|
||||
# =========================================================================
|
||||
# Layer 8: tryout-specific teams
|
||||
# =========================================================================
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
# =========================================================================
|
||||
# Layer 9: matches (tryout-scoped + regular-season)
|
||||
# =========================================================================
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.match_model.team_match import TeamMatch
|
||||
|
||||
# =========================================================================
|
||||
# Layer 10: participants
|
||||
# =========================================================================
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||
|
||||
# =========================================================================
|
||||
# Layer 11: remaining standalone models
|
||||
# =========================================================================
|
||||
from app.models.user_gamertag import UserGamertag
|
||||
from app.models.contract import Contract
|
||||
from app.models.team_note import TeamNote
|
||||
from app.models.personal_note import PersonalNote
|
||||
from app.models.one_on_one_request import OneOnOneRequest
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Many-to-many association tables for OrgTeam ↔ User relationships."""
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
org_team_coaches = db.Table('org_team_coaches',
|
||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
)
|
||||
|
||||
org_team_managers = db.Table('org_team_managers',
|
||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Global constants shared by all model files.
|
||||
|
||||
Contains game lists, position mappings, platform codes, and TRN URL templates.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
|
||||
|
||||
ESPORT_GAMES = [
|
||||
'Valorant',
|
||||
'League of Legends',
|
||||
'Counter-Strike 2',
|
||||
'Apex Legends',
|
||||
'Overwatch 2',
|
||||
'Rainbow Six Siege',
|
||||
'Rocket League',
|
||||
'Super Smash Bros.',
|
||||
]
|
||||
|
||||
GAME_POSITIONS = {
|
||||
'League of Legends': ['Top Lane', 'Jungle', 'Mid Lane', 'ADC', 'Support'],
|
||||
'Valorant': ['Controller', 'Initiator', 'Duelist', 'Sentinel', 'Flex'],
|
||||
'Counter-Strike 2': ['AWPer', 'Entry Fragger', 'Lurker', 'In-Game Leader', 'Support'],
|
||||
'Rainbow Six Siege': ['Entry', 'Support', 'Breacher', 'Anchor', 'Flex'],
|
||||
'Overwatch 2': ['Tank', 'Damage', 'Support'],
|
||||
'Apex Legends': [],
|
||||
'Rocket League': [],
|
||||
'Super Smash Bros.': [],
|
||||
}
|
||||
|
||||
GAME_PLATFORMS = {
|
||||
'Valorant': [],
|
||||
'League of Legends': [],
|
||||
'Counter-Strike 2': [],
|
||||
'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'],
|
||||
'Overwatch 2': [],
|
||||
'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'],
|
||||
'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'],
|
||||
'Super Smash Bros.': ['Nintendo Switch'],
|
||||
}
|
||||
|
||||
PLATFORM_CODES = {
|
||||
'Ubisoft': 'ubi',
|
||||
'PlayStation': 'psn',
|
||||
'Xbox': 'xbl',
|
||||
'Nintendo Switch': 'switch',
|
||||
'PC': 'pc',
|
||||
'Steam': 'steam',
|
||||
'Epic': 'epic',
|
||||
}
|
||||
|
||||
TRN_URLS = {
|
||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
|
||||
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
|
||||
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
||||
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
||||
'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Flask-Login user loader — registered with login_manager in models.py."""
|
||||
|
||||
from app.extensions import login_manager
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load a user by ID for Flask-Login session management.
|
||||
|
||||
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
|
||||
automatically because SQLAlchemy resolves the identity column.
|
||||
"""
|
||||
from app.models.user_model.user import User
|
||||
return User.query.get(int(user_id))
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Availability models — BaseAvailability and its concrete subclasses."""
|
||||
|
||||
from app.models.availability.base import BaseAvailability
|
||||
from app.models.availability.player_disponibility import PlayerDisponibility
|
||||
from app.models.availability.coach_availability import CoachAvailability
|
||||
|
||||
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseAvailability(db.Model):
|
||||
"""Shared schema for player disponibilities and coach availabilities."""
|
||||
__abstract__ = True
|
||||
|
||||
day_of_week = db.Column(db.Integer, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Coach availability in 30-minute time blocks for One on One sessions."""
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class CoachAvailability(BaseAvailability):
|
||||
"""Coach availability in 30-minute blocks for One on One sessions."""
|
||||
__tablename__ = 'coach_availabilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
coach = db.relationship('User', backref='coach_availabilities')
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Player availability in 30-minute time blocks."""
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class PlayerDisponibility(BaseAvailability):
|
||||
"""Player availability in 30-minute blocks."""
|
||||
__tablename__ = 'player_disponibilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
player = db.relationship('User', backref='disponibilities')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Contract documents for players to sign."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Contract(db.Model):
|
||||
"""Contract documents for players to sign."""
|
||||
__tablename__ = 'contracts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
uploaded_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
original_filename = db.Column(db.String(255), nullable=False)
|
||||
stored_filename = db.Column(db.String(255), nullable=False)
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
|
||||
signed_filename = db.Column(db.String(255), nullable=True)
|
||||
signed_file_path = db.Column(db.String(500), nullable=True)
|
||||
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
signed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
|
||||
team = db.relationship('OrgTeam', foreign_keys=[team_id])
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
|
||||
|
||||
def can_view(self, user):
|
||||
if user.id == self.player_id:
|
||||
return True
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.user import User
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
player = User.query.get(self.player_id)
|
||||
if player and player.get_org_teams():
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
if org_team and (not self.team_id or self.team_id == org_team.id):
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_upload_signed(self, user):
|
||||
return user.id == self.player_id
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Player evaluation record."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Player evaluation record."""
|
||||
__tablename__ = 'evaluations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
evaluator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
mecanics_score = db.Column(db.Integer, nullable=True)
|
||||
cohesion_score = db.Column(db.Integer, nullable=True)
|
||||
communication_score = db.Column(db.Integer, nullable=True)
|
||||
gamesense_score = db.Column(db.Integer, nullable=True)
|
||||
versatility_score = db.Column(db.Integer, nullable=True)
|
||||
discipline_score = db.Column(db.Integer, nullable=True)
|
||||
analysis_score = db.Column(db.Integer, nullable=True)
|
||||
sport_ethics_score = db.Column(db.Integer, nullable=True)
|
||||
mental_score = db.Column(db.Integer, nullable=True)
|
||||
overall_score = db.Column(db.Float, nullable=True)
|
||||
comments = db.Column(db.Text, nullable=True)
|
||||
position_recommendation = db.Column(db.String(50), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Match models — BaseMatch and its concrete subclasses."""
|
||||
from app.models.match_model.base import BaseMatch
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.match_model.team_match import TeamMatch
|
||||
|
||||
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseMatch(db.Model):
|
||||
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
||||
__abstract__ = True
|
||||
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=True)
|
||||
end_time = db.Column(db.Time, nullable=True)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='scheduled')
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Match / scrimmage within a tryout."""
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class Match(BaseMatch):
|
||||
"""Match / scrimmage within a tryout."""
|
||||
__tablename__ = 'matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
match_type = db.Column(db.String(20), nullable=False)
|
||||
team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
|
||||
creator = db.relationship('User', backref='created_matches')
|
||||
tryout = db.relationship('Tryout', backref='matches')
|
||||
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
|
||||
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
|
||||
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
|
||||
|
||||
def get_participating_players(self):
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class TeamMatch(BaseMatch):
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
__tablename__ = 'team_matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
opponent = db.Column(db.String(200), nullable=True)
|
||||
|
||||
org_team = db.relationship('OrgTeam', backref='team_matches')
|
||||
creator = db.relationship('User', backref='created_team_matches')
|
||||
participants = db.relationship(
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def get_confirmed_count(self):
|
||||
all_p = self.participants.all()
|
||||
confirmed = sum(1 for p in all_p if p.is_confirmed)
|
||||
return confirmed, len(all_p)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Request from player to coach for a One on One session."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class OneOnOneRequest(db.Model):
|
||||
"""Request from player to coach for a One on One session."""
|
||||
__tablename__ = 'one_on_one_requests'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
points = db.Column(db.Text, nullable=True)
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
responded_at = db.Column(db.DateTime, nullable=True)
|
||||
discord_message_id = db.Column(db.BigInteger, nullable=True)
|
||||
coach_rejection_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Organisation team models."""
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
__all__ = ['OrgTeam', 'TeamPlayer']
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
from app.extensions import db
|
||||
from app.models._associations import org_team_coaches, org_team_managers
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class OrgTeam(db.Model):
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
__tablename__ = 'org_teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
coaches = db.relationship(
|
||||
'User', secondary=org_team_coaches, lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'))
|
||||
managers = db.relationship(
|
||||
'User', secondary=org_team_managers, lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'))
|
||||
|
||||
coach = db.relationship(
|
||||
'User', foreign_keys=[coach_id],
|
||||
backref=db.backref('coached_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
manager = db.relationship(
|
||||
'User', foreign_keys=[manager_id],
|
||||
backref=db.backref('managed_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
|
||||
def get_coaches(self):
|
||||
coach_list = self.coaches.all()
|
||||
if not coach_list and self.coach:
|
||||
return [self.coach]
|
||||
return coach_list
|
||||
|
||||
def get_managers(self):
|
||||
manager_list = self.managers.all()
|
||||
if not manager_list and self.manager:
|
||||
return [self.manager]
|
||||
return manager_list
|
||||
|
||||
@property
|
||||
def players(self):
|
||||
return [tp.player for tp in self.team_players]
|
||||
|
||||
def get_players_with_status(self):
|
||||
return [{'player': tp.player, 'status': tp.status,
|
||||
'position': tp.position} for tp in self.team_players]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Many-to-many junction: player to org-team."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TeamPlayer(db.Model):
|
||||
"""Many-to-many: player to org-team."""
|
||||
__tablename__ = 'team_players'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
|
||||
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Participant models — BaseParticipant and its concrete subclasses."""
|
||||
from app.models.participant.base import BaseParticipant
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||
|
||||
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Abstract base class for match participant models."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseParticipant(db.Model):
|
||||
"""Shared schema for match participants."""
|
||||
__abstract__ = True
|
||||
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Participant in a tryout-scoped match."""
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class MatchParticipant(BaseParticipant):
|
||||
"""Participant in a tryout-scoped match."""
|
||||
__tablename__ = 'match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
||||
team_side = db.Column(db.Integer, nullable=True)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
attendance_confirmed = db.Column(db.Boolean, default=False)
|
||||
|
||||
player = db.relationship('User')
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Participant in a regular-season team match."""
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class TeamMatchParticipant(BaseParticipant):
|
||||
"""Participant in a regular-season team match."""
|
||||
__tablename__ = 'team_match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
|
||||
is_confirmed = db.Column(db.Boolean, default=False)
|
||||
|
||||
player = db.relationship('User')
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Personal notes from coach to individual player."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PersonalNote(db.Model):
|
||||
"""Personal notes from coach to individual player."""
|
||||
__tablename__ = 'personal_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=True)
|
||||
|
||||
player = db.relationship('User', foreign_keys=[player_id], backref='personal_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
match = db.relationship('Match', foreign_keys=[match_id])
|
||||
team = db.relationship('Team', foreign_keys=[team_id])
|
||||
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Tryout-specific temporary team models."""
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
__all__ = ['Team', 'TeamMember']
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
__tablename__ = 'teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = db.relationship('User', backref='created_teams')
|
||||
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TeamMember(db.Model):
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
__tablename__ = 'team_members'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
player = db.relationship('User', overlaps="player_ref,team_assignments")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Team improvement notes from coach."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TeamNote(db.Model):
|
||||
"""Team improvement notes from coach."""
|
||||
__tablename__ = 'team_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
team = db.relationship('OrgTeam', backref='team_notes')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id])
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Tryout models."""
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
__all__ = ['Tryout', 'TryoutRegistration']
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Tryout(db.Model):
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
__tablename__ = 'tryouts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
game = db.Column(db.String(50), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
status = db.Column(db.String(20), default='upcoming')
|
||||
max_players = db.Column(db.Integer, nullable=True)
|
||||
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)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
coach_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], backref='created_tryouts')
|
||||
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
|
||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='coached_tryouts')
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Registration linking a player to a tryout."""
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TryoutRegistration(db.Model):
|
||||
"""Registration linking a player to a tryout."""
|
||||
__tablename__ = 'tryout_registrations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
status = db.Column(db.String(20), default='registered')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Store gamertag per game for each user."""
|
||||
from app.extensions import db
|
||||
from app.models._constants import TRN_URLS, PLATFORM_CODES
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
class UserGamertag(db.Model):
|
||||
"""Store gamertag per game for each user."""
|
||||
__tablename__ = 'user_gamertags'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
game = db.Column(db.String(50), nullable=False)
|
||||
gamertag = db.Column(db.String(120), nullable=False)
|
||||
platform = db.Column(db.String(30), nullable=True)
|
||||
|
||||
user = db.relationship('User', backref='gamertags')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
|
||||
)
|
||||
|
||||
def get_trn_url(self):
|
||||
if self.game not in TRN_URLS:
|
||||
return None
|
||||
url = TRN_URLS[self.game]
|
||||
encoded_gamertag = quote(self.gamertag, safe='')
|
||||
if '{platform_code}' in url and '{username}' in url:
|
||||
platform_code = PLATFORM_CODES.get(
|
||||
self.platform,
|
||||
self.platform.lower().replace(' ', '-') if self.platform else '',
|
||||
)
|
||||
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
||||
elif '{platform}' in url and '{username}' in url:
|
||||
return url.format(
|
||||
platform=self.platform.lower().replace(' ', '-'),
|
||||
username=encoded_gamertag,
|
||||
)
|
||||
elif '{username}' in url:
|
||||
return url.format(username=encoded_gamertag)
|
||||
return url
|
||||
@@ -0,0 +1,9 @@
|
||||
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
||||
from app.models.user_model.user import User
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
from app.models.user_model.coach import Coach
|
||||
from app.models.user_model.player import Player
|
||||
from app.models.user_model.scout import Scout
|
||||
|
||||
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Admin / President — full access to everything."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Admin(User):
|
||||
"""President / super-admin — full access to everything."""
|
||||
__mapper_args__ = {'polymorphic_identity': 'admin'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def can_manage_users(self):
|
||||
return True
|
||||
|
||||
def can_manage_teams(self):
|
||||
return True
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return True
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return True
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Coach(User):
|
||||
"""Coach — evaluates, schedules matches, manages their own org team."""
|
||||
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return True
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
if tryout.target_org_team_id:
|
||||
is_coach_of_target = OrgTeam.query.filter(
|
||||
OrgTeam.id == tryout.target_org_team_id,
|
||||
OrgTeam.coaches.any(id=self.id),
|
||||
).first() is not None
|
||||
if is_coach_of_target:
|
||||
return True
|
||||
if tryout.coach_id == self.id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
if org_team.coaches.filter_by(id=self.id).first():
|
||||
return True
|
||||
if org_team.coach_id == self.id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
team_ids = [t.id for t in self.coached_org_teams.all()]
|
||||
if not team_ids:
|
||||
return Tryout.query.filter(Tryout.id == -1).all() # empty
|
||||
return Tryout.query.filter(
|
||||
Tryout.target_org_team_id.in_(team_ids)
|
||||
).order_by(Tryout.date).all()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Manager(User):
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
__mapper_args__ = {'polymorphic_identity': 'manager'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def can_manage_teams(self):
|
||||
return True
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return True
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return True
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return tryout.created_by == self.id or tryout.manager_id == self.id
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
return Tryout.query.filter_by(created_by=self.id).order_by(Tryout.date).all()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Player(User):
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
__mapper_args__ = {'polymorphic_identity': 'player'}
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
|
||||
# tryouts they registered for
|
||||
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
||||
tryouts = Tryout.query.filter(
|
||||
Tryout.id.in_(player_tryout_ids)
|
||||
).order_by(Tryout.date).all() if player_tryout_ids else []
|
||||
|
||||
# plus tryouts where they participate in a match
|
||||
player_matches = Match.query.join(MatchParticipant).filter(
|
||||
MatchParticipant.player_id == self.id,
|
||||
).all()
|
||||
extra_ids = set(m.tryout_id for m in player_matches)
|
||||
extra = Tryout.query.filter(
|
||||
Tryout.id.in_(extra_ids),
|
||||
).order_by(Tryout.date).all() if extra_ids else []
|
||||
|
||||
all_ids = {t.id for t in tryouts}
|
||||
return tryouts + [t for t in extra if t.id not in all_ids]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Scout(User):
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
__mapper_args__ = {'polymorphic_identity': 'scout'}
|
||||
|
||||
def can_evaluate(self):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Base User model — shared fields and polymorphic configuration."""
|
||||
from app.extensions import db
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""Base user model — shared fields for every role.
|
||||
|
||||
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
||||
or Scout so that `polymorphic_identity` is set correctly.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
# --- columns -----------------------------------------------------------
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default='player') # polymorphic discriminator
|
||||
full_name = db.Column(db.String(100), nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
is_active_account = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# E-Sports fields
|
||||
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
|
||||
discord_username = db.Column(db.String(128), nullable=True)
|
||||
discord_user_id = db.Column(db.String(64), nullable=True)
|
||||
league_os_profile = db.Column(db.String(256), nullable=True)
|
||||
|
||||
# --- polymorphic configuration -----------------------------------------
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'user',
|
||||
'polymorphic_on': role,
|
||||
}
|
||||
|
||||
# --- relationships (defined once on the base) --------------------------
|
||||
evaluations_given = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id',
|
||||
backref='evaluator', lazy='dynamic')
|
||||
evaluations_received = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.player_id',
|
||||
backref='player', lazy='dynamic')
|
||||
tryout_registrations = db.relationship(
|
||||
'TryoutRegistration', backref='player', lazy='dynamic')
|
||||
team_assignments = db.relationship(
|
||||
'TeamMember', foreign_keys='TeamMember.player_id',
|
||||
backref='player_ref', lazy='dynamic')
|
||||
|
||||
# --- shared helper methods ---------------------------------------------
|
||||
def get_games_list(self):
|
||||
"""Return the user's games as a list."""
|
||||
if self.games:
|
||||
return [g.strip() for g in self.games.split(',') if g.strip()]
|
||||
return []
|
||||
|
||||
def get_gamertags(self):
|
||||
"""Return gamertags as a dict keyed by game."""
|
||||
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
|
||||
for gt in self.gamertags}
|
||||
|
||||
def get_org_teams(self):
|
||||
"""Return all OrgTeams this player belongs to."""
|
||||
return [tp.org_team for tp in self.team_placements]
|
||||
|
||||
# --- stubs (overridden in subclasses) ----------------------------------
|
||||
def can_evaluate(self):
|
||||
return False
|
||||
|
||||
def can_manage_users(self):
|
||||
return False
|
||||
|
||||
def can_manage_teams(self):
|
||||
return False
|
||||
|
||||
def can_manage_tryouts(self):
|
||||
return False
|
||||
|
||||
def can_schedule_matches(self):
|
||||
return False
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
return False
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return False
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
return []
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# Team Tryouts - Production Nginx Configuration (Windows)
|
||||
#
|
||||
# This configuration provides:
|
||||
# - HTTP to HTTPS redirect
|
||||
# - TLS 1.2/1.3 with strong ciphers
|
||||
# - HSTS enforcement
|
||||
# - Request size limits
|
||||
# - gzip compression
|
||||
# - Proxy to Waitress (Flask)
|
||||
# - Security headers (reinforced at reverse proxy level)
|
||||
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
# =========================================================================
|
||||
# Basic Settings
|
||||
# =========================================================================
|
||||
server_tokens off; # Hide Nginx version
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 16M; # Max upload size (matches Flask MAX_CONTENT_LENGTH)
|
||||
client_body_buffer_size 128k;
|
||||
client_header_buffer_size 1k;
|
||||
large_client_header_buffers 4 8k;
|
||||
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# =========================================================================
|
||||
# Logging
|
||||
# =========================================================================
|
||||
access_log logs/access.log;
|
||||
error_log logs/error.log warn;
|
||||
|
||||
# =========================================================================
|
||||
# Gzip Compression
|
||||
# =========================================================================
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml
|
||||
font/ttf
|
||||
font/otf;
|
||||
|
||||
# =========================================================================
|
||||
# HTTP → HTTPS Redirect
|
||||
# =========================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS Server
|
||||
# =========================================================================
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# SSL/TLS Configuration
|
||||
# ---------------------------------------------------------------------
|
||||
# Paths to SSL certificate and key (update these for your deployment)
|
||||
ssl_certificate C:/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key C:/nginx/certs/privkey.pem;
|
||||
|
||||
# Strong TLS configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
|
||||
# SSL session settings
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# OCSP Stapling (uncomment when running on a proper domain)
|
||||
# ssl_stapling on;
|
||||
# ssl_stapling_verify on;
|
||||
# ssl_trusted_certificate C:/nginx/certs/chain.pem;
|
||||
|
||||
# Diffie-Hellman parameters (generate with: openssl dhparam -out dhparam.pem 2048)
|
||||
# ssl_dhparam C:/nginx/certs/dhparam.pem;
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Security Headers (defense-in-depth with Flask's own headers)
|
||||
# ---------------------------------------------------------------------
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Proxy to Waitress (Flask)
|
||||
# ---------------------------------------------------------------------
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Static Files (served directly by Nginx for performance)
|
||||
# Uncomment and adjust path if you want Nginx to serve static files
|
||||
# ---------------------------------------------------------------------
|
||||
# location /static/ {
|
||||
# alias C:/path/to/team-tryouts/static/;
|
||||
# expires 30d;
|
||||
# add_header Cache-Control "public, immutable";
|
||||
# access_log off;
|
||||
# }
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
# ---------------------------------------------------------------------
|
||||
# Define rate limit zones (uncomment when rate limiting at Nginx level)
|
||||
# limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
# limit_req_zone $binary_remote_addr zone=global:10m rate=100r/m;
|
||||
|
||||
# location /auth/login {
|
||||
# limit_req zone=login burst=5 nodelay;
|
||||
# proxy_pass http://127.0.0.1:5000;
|
||||
# }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Authentication routes for user login, logout, and registration.
|
||||
|
||||
This module handles user authentication including login with account lockout
|
||||
protection, logout with session clearing, and new user registration with
|
||||
password policy enforcement and CAPTCHA verification.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.extensions import db, hash_password, check_password, limiter
|
||||
from app.models import User, Player, ESPORT_GAMES
|
||||
from app.validators import RegisterSchema, LoginSchema
|
||||
from marshmallow import ValidationError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Account lockout settings
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
def is_safe_url(url):
|
||||
"""Validate that a URL is safe for redirection (same origin).
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the URL is safe (relative or same origin).
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
parsed = urlparse(url)
|
||||
# Allow relative URLs (no netloc) or same-origin URLs
|
||||
return not parsed.netloc or parsed.netloc == request.host
|
||||
|
||||
|
||||
def generate_captcha():
|
||||
"""Generate a simple math CAPTCHA challenge.
|
||||
|
||||
Creates a random addition problem and stores the answer in the session.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
||||
"""
|
||||
import random
|
||||
a = random.randint(1, 10)
|
||||
b = random.randint(1, 10)
|
||||
captcha_id = str(uuid.uuid4())
|
||||
session['captcha_id'] = captcha_id
|
||||
session['captcha_answer'] = a + b
|
||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
||||
|
||||
|
||||
def verify_captcha(user_answer):
|
||||
"""Verify the CAPTCHA answer from the session.
|
||||
|
||||
Args:
|
||||
user_answer: The user's submitted answer (string or int).
|
||||
|
||||
Returns:
|
||||
bool: True if the answer matches the stored CAPTCHA, False otherwise.
|
||||
"""
|
||||
try:
|
||||
expected = session.pop('captcha_answer', None)
|
||||
session.pop('captcha_id', None)
|
||||
if expected is None:
|
||||
return False
|
||||
return int(user_answer) == expected
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
"""Handle user login authentication with account lockout protection.
|
||||
|
||||
GET: Render the login form.
|
||||
POST: Authenticate user credentials with lockout check and audit logging.
|
||||
|
||||
Account lockout: After 5 consecutive failed attempts, the account is
|
||||
locked for 15 minutes. Successful login resets the counter.
|
||||
|
||||
Redirects authenticated users to dashboard. Validates credentials and checks
|
||||
account status before login.
|
||||
|
||||
Returns:
|
||||
Response: Login form or redirect to dashboard/next page.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Validate input with marshmallow schema
|
||||
login_schema = LoginSchema()
|
||||
try:
|
||||
validated = login_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
username = validated['username']
|
||||
password = validated['password']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Check if account is locked
|
||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
||||
flash(
|
||||
f'Account is locked due to too many failed attempts. '
|
||||
f'Please try again in {remaining} minute(s).',
|
||||
'danger'
|
||||
)
|
||||
return render_template('pages/login.html')
|
||||
|
||||
if user and check_password(user.password_hash, password):
|
||||
if not user.is_active_account:
|
||||
flash('This account has been deactivated.', 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
# Reset failed login attempts on successful login
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.session.commit()
|
||||
|
||||
# Clear old session data and preserve CSRF token to prevent
|
||||
# session fixation attacks (Flask-Login rotates the session ID)
|
||||
_csrf_token = session.get('csrf_token')
|
||||
session.clear()
|
||||
if _csrf_token:
|
||||
session['csrf_token'] = _csrf_token
|
||||
|
||||
login_user(user)
|
||||
|
||||
# Validate redirect URL to prevent open redirect vulnerability
|
||||
next_page = request.args.get('next')
|
||||
if next_page and not is_safe_url(next_page):
|
||||
next_page = None
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
# Track failed login attempt
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
flash(
|
||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
||||
'danger'
|
||||
)
|
||||
else:
|
||||
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
|
||||
flash(
|
||||
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
|
||||
'danger'
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
|
||||
return render_template('pages/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per hour")
|
||||
def register():
|
||||
"""Handle new player registration with CAPTCHA and password policy.
|
||||
|
||||
GET: Render the registration form with E-Sports games list and CAPTCHA.
|
||||
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
|
||||
and create a new player account.
|
||||
|
||||
Only players can register through this form. Validates username/email
|
||||
uniqueness and password confirmation.
|
||||
|
||||
Returns:
|
||||
Response: Registration form or redirect to login.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# Generate CAPTCHA for GET requests
|
||||
captcha = generate_captcha()
|
||||
|
||||
if request.method == 'POST':
|
||||
# Validate CAPTCHA first
|
||||
captcha_answer = request.form.get('captcha_answer', '')
|
||||
if not verify_captcha(captcha_answer):
|
||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
||||
captcha = generate_captcha() # Generate new captcha
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
# Validate input with marshmallow schema
|
||||
register_schema = RegisterSchema()
|
||||
try:
|
||||
validated = register_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
username = validated['username']
|
||||
email = validated['email']
|
||||
password = validated['password']
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
trn_username = request.form.get('trn_username', '').strip() or None
|
||||
discord_username = validated.get('discord_username')
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user = Player(
|
||||
username=username,
|
||||
password_hash=hashed_password,
|
||||
role='player',
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
games=','.join(selected_games) if selected_games else None,
|
||||
discord_username=discord_username,
|
||||
league_os_profile=league_os_profile,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha)
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""Log out the current user and clear the session.
|
||||
|
||||
Clears the user session and regenerates session ID to prevent
|
||||
session fixation/replay after logout.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login page with logout message.
|
||||
"""
|
||||
logout_user()
|
||||
session.clear()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Evaluation routes for assessing player performance during tryouts.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||
|
||||
|
||||
def validate_score(score_value):
|
||||
"""Validate that a score is between 1 and 10."""
|
||||
if score_value is None:
|
||||
return None
|
||||
try:
|
||||
score = int(score_value)
|
||||
if 1 <= score <= 10:
|
||||
return score
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@evaluations_bp.route('')
|
||||
@login_required
|
||||
def list_evaluations():
|
||||
"""List all evaluations accessible to the current user."""
|
||||
user = current_user
|
||||
|
||||
if isinstance(user, Player):
|
||||
flash('You do not have permission to view evaluations.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
sort_column = request.args.get('sort', 'created_at')
|
||||
sort_order = request.args.get('order', 'desc')
|
||||
if sort_order not in ('asc', 'desc'):
|
||||
sort_order = 'desc'
|
||||
|
||||
player_alias = aliased(User, name='eval_player')
|
||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
||||
|
||||
sort_map = {
|
||||
'tryout': Tryout.title,
|
||||
'player': player_alias.username,
|
||||
'evaluator': evaluator_alias.username,
|
||||
'mecanics_score': Evaluation.mecanics_score,
|
||||
'cohesion_score': Evaluation.cohesion_score,
|
||||
'communication_score': Evaluation.communication_score,
|
||||
'gamesense_score': Evaluation.gamesense_score,
|
||||
'versatility_score': Evaluation.versatility_score,
|
||||
'discipline_score': Evaluation.discipline_score,
|
||||
'analysis_score': Evaluation.analysis_score,
|
||||
'sport_ethics_score': Evaluation.sport_ethics_score,
|
||||
'mental_score': Evaluation.mental_score,
|
||||
'overall_score': Evaluation.overall_score,
|
||||
'position_recommendation': Evaluation.position_recommendation,
|
||||
'created_at': Evaluation.created_at,
|
||||
}
|
||||
|
||||
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
|
||||
if sort_order == 'asc':
|
||||
sort_expr = sort_expr.asc()
|
||||
else:
|
||||
sort_expr = sort_expr.desc()
|
||||
|
||||
if isinstance(user, Admin):
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.order_by(sort_expr).all()
|
||||
avg_scores = db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).all()
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p, 'count': row.eval_count,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
||||
}
|
||||
elif user.can_evaluate():
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.filter(Evaluation.evaluator_id == user.id) \
|
||||
.order_by(sort_expr).all()
|
||||
player_scores = {}
|
||||
else:
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.filter(Evaluation.player_id == user.id) \
|
||||
.order_by(sort_expr).all()
|
||||
player_scores = {}
|
||||
|
||||
return render_template('pages/evaluations.html',
|
||||
evaluations=evaluations, player_scores=player_scores,
|
||||
sort_column=sort_column, sort_order=sort_order)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def evaluate_player(tryout_id, player_id):
|
||||
"""Evaluate a specific player in a tryout."""
|
||||
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'))
|
||||
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
).first() is not None
|
||||
if not is_registered:
|
||||
flash('Player is not registered for this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only evaluate players.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing_eval = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
|
||||
).first()
|
||||
|
||||
if request.method == 'POST':
|
||||
mecanics = validate_score(request.form.get('mecanics_score'))
|
||||
cohesion = validate_score(request.form.get('cohesion_score'))
|
||||
communication = validate_score(request.form.get('communication_score'))
|
||||
gamesense = validate_score(request.form.get('gamesense_score'))
|
||||
versatility = validate_score(request.form.get('versatility_score'))
|
||||
discipline = validate_score(request.form.get('discipline_score'))
|
||||
analysis = validate_score(request.form.get('analysis_score'))
|
||||
sport_ethics = validate_score(request.form.get('sport_ethics_score'))
|
||||
mental = validate_score(request.form.get('mental_score'))
|
||||
comments = request.form.get('comments')
|
||||
position = request.form.get('position_recommendation')
|
||||
|
||||
scores = [s for s in [mecanics, cohesion, communication, gamesense,
|
||||
versatility, discipline, analysis, sport_ethics, mental]
|
||||
if s is not None]
|
||||
overall = sum(scores) / len(scores) if scores else None
|
||||
|
||||
if existing_eval:
|
||||
existing_eval.mecanics_score = mecanics
|
||||
existing_eval.cohesion_score = cohesion
|
||||
existing_eval.communication_score = communication
|
||||
existing_eval.gamesense_score = gamesense
|
||||
existing_eval.versatility_score = versatility
|
||||
existing_eval.discipline_score = discipline
|
||||
existing_eval.analysis_score = analysis
|
||||
existing_eval.sport_ethics_score = sport_ethics
|
||||
existing_eval.mental_score = mental
|
||||
existing_eval.overall_score = overall
|
||||
existing_eval.comments = comments
|
||||
existing_eval.position_recommendation = position
|
||||
flash('Evaluation updated!', 'success')
|
||||
else:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
evaluator_id=current_user.id,
|
||||
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, overall_score=overall,
|
||||
comments=comments, position_recommendation=position,
|
||||
)
|
||||
db.session.add(evaluation)
|
||||
flash('Evaluation submitted successfully!', 'success')
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
evaluators = None
|
||||
if isinstance(current_user, Admin):
|
||||
all_evaluations = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
).all()
|
||||
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
|
||||
for e in all_evaluations]
|
||||
|
||||
return render_template('pages/evaluate_player.html',
|
||||
tryout=tryout, player=player,
|
||||
existing_eval=existing_eval,
|
||||
evaluators=evaluators,
|
||||
game_positions=GAME_POSITIONS)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||
@login_required
|
||||
def players_to_evaluate(tryout_id):
|
||||
"""List players that need evaluation in a specific tryout."""
|
||||
if not current_user.can_evaluate():
|
||||
flash('Permission denied.', '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'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
players = []
|
||||
for reg in registrations:
|
||||
p = User.query.get(reg.player_id)
|
||||
if p and isinstance(p, Player):
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
).first()
|
||||
players.append({'player': p, 'evaluated': existing is not None,
|
||||
'registration': reg})
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Main dashboard routes for the Team Tryouts application.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
|
||||
Match, MatchParticipant, OrgTeam,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from datetime import date
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Redirect root URL to login page."""
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the main dashboard with role-specific statistics.
|
||||
|
||||
Each User subclass provides its own stats view.
|
||||
"""
|
||||
user = current_user
|
||||
stats = {}
|
||||
|
||||
if isinstance(user, Admin):
|
||||
stats['total_users'] = User.query.count()
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_tryouts'] = Tryout.query.count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['active_tryouts'] = Tryout.query.filter_by(status='in_progress').count()
|
||||
stats['completed_tryouts'] = Tryout.query.filter_by(status='completed').count()
|
||||
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
||||
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
||||
today = date.today()
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all()
|
||||
|
||||
elif isinstance(user, Manager):
|
||||
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
||||
stats['active_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id, status='in_progress').count()
|
||||
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
stats['my_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||
today = date.today()
|
||||
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.tryout_id.in_(manager_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
|
||||
|
||||
elif isinstance(user, Coach):
|
||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
registrations = TryoutRegistration.query.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended'])).all()
|
||||
registered_player_ids = [r.player_id for r in registrations]
|
||||
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||
today = date.today()
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
||||
target_org_team_id=org_team.id).all()] if org_team else []
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.tryout_id.in_(coach_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
|
||||
|
||||
elif isinstance(user, Player):
|
||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
||||
stats['my_registrations'] = TryoutRegistration.query.filter_by(
|
||||
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
|
||||
|
||||
today = date.today()
|
||||
next_matches = []
|
||||
all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
|
||||
registered_tryout_ids = [r.tryout_id for r in all_registrations]
|
||||
player_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
|
||||
player_match_ids = [p.match_id for p in player_participant_matches]
|
||||
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
||||
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
||||
|
||||
upcoming_matches = Match.query.filter(
|
||||
Match.tryout_id.in_(registered_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).all()
|
||||
|
||||
for match in upcoming_matches:
|
||||
is_participant = False
|
||||
team = None
|
||||
if match.match_type == 'team_vs_team':
|
||||
if match.team1_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next((tm for tm in player_team_memberships
|
||||
if tm.team_id == match.team1_id), None)
|
||||
elif match.team2_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next((tm for tm in player_team_memberships
|
||||
if tm.team_id == match.team2_id), None)
|
||||
else:
|
||||
if match.id in player_match_ids:
|
||||
is_participant = True
|
||||
|
||||
if is_participant:
|
||||
next_matches.append({
|
||||
'tryout': match.tryout, 'match': match,
|
||||
'team': team.team if team else None,
|
||||
})
|
||||
|
||||
stats['next_matches'] = next_matches
|
||||
|
||||
elif isinstance(user, Scout):
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['avg_scores'] = db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).order_by(
|
||||
func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
||||
stats['top_players'] = []
|
||||
for row in stats['avg_scores']:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
stats['top_players'].append((p, round(row.avg_score, 1)))
|
||||
|
||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Match scheduling routes for managing scrimmages and matches within tryouts.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Match, MatchParticipant, Team, TeamMember,
|
||||
OrgTeam, TryoutRegistration, PlayerDisponibility,
|
||||
)
|
||||
from datetime import datetime, time, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
|
||||
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||
|
||||
|
||||
def can_schedule_match():
|
||||
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
|
||||
return isinstance(current_user, (Admin, Manager, Coach, Scout))
|
||||
|
||||
|
||||
def get_visible_tryouts_for_user():
|
||||
"""Get tryouts that the current user can see based on their role.
|
||||
|
||||
Delegates to the polymorphic User subclass.
|
||||
"""
|
||||
return current_user.get_visible_tryouts()
|
||||
|
||||
|
||||
@matches_bp.route('/calendar')
|
||||
@login_required
|
||||
def calendar():
|
||||
"""Render the calendar view."""
|
||||
return render_template('pages/calendar.html')
|
||||
|
||||
|
||||
@matches_bp.route('/api/events')
|
||||
@login_required
|
||||
def api_events():
|
||||
"""API endpoint returning calendar events for FullCalendar."""
|
||||
events = []
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
|
||||
for tryout in tryouts:
|
||||
events.append({
|
||||
'id': f'tryout_{tryout.id}',
|
||||
'title': tryout.title,
|
||||
'date': tryout.date.strftime('%Y-%m-%d'),
|
||||
'type': 'tryout', 'color': '#3b82f6',
|
||||
'extendedProps': {
|
||||
'location': tryout.location or 'TBD',
|
||||
'status': tryout.status,
|
||||
'description': tryout.description or '',
|
||||
'tryout_id': tryout.id,
|
||||
},
|
||||
})
|
||||
|
||||
for match in tryout.matches:
|
||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||
match_desc = match.description or ''
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
if match.team1:
|
||||
teams.append(match.team1.name)
|
||||
if match.team2:
|
||||
teams.append(match.team2.name)
|
||||
participants_str = f"{' vs '.join(teams)}"
|
||||
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
||||
else:
|
||||
player_names = []
|
||||
for p in match.participants.all():
|
||||
player_names.append(p.player.username if p.player else 'Unknown Player')
|
||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
||||
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
user_participant = MatchParticipant.query.filter_by(
|
||||
match_id=match.id, player_id=current_user.id,
|
||||
).first()
|
||||
|
||||
events.append({
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match', 'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status, 'description': match_desc,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id, 'match_id': match.id,
|
||||
'start_time': start_time_str, 'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
|
||||
},
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@matches_bp.route('/api/events/<int:tryout_id>')
|
||||
@login_required
|
||||
def api_events_for_tryout(tryout_id):
|
||||
"""API endpoint returning calendar events for a specific tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
|
||||
is_registered = False
|
||||
player_in_match = False
|
||||
if isinstance(current_user, Player):
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id,
|
||||
).first() is not None
|
||||
player_matches = Match.query.join(MatchParticipant).filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).all()
|
||||
player_in_match = len(player_matches) > 0
|
||||
|
||||
if not can_view and not is_registered and not player_in_match:
|
||||
return jsonify([])
|
||||
|
||||
events = [{
|
||||
'id': f'tryout_{tryout.id}',
|
||||
'title': f'Tryout: {tryout.title}',
|
||||
'date': tryout.date.strftime('%Y-%m-%d'),
|
||||
'type': 'tryout', 'color': '#3b82f6',
|
||||
'extendedProps': {
|
||||
'location': tryout.location or 'TBD',
|
||||
'status': tryout.status,
|
||||
'description': tryout.description or '',
|
||||
'tryout_id': tryout.id,
|
||||
},
|
||||
}]
|
||||
|
||||
for match in tryout.matches:
|
||||
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
if match.team1:
|
||||
teams.append(match.team1.name)
|
||||
if match.team2:
|
||||
teams.append(match.team2.name)
|
||||
participants_str = f"{' vs '.join(teams)}"
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
|
||||
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
|
||||
if team1_players and team2_players:
|
||||
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
|
||||
else:
|
||||
participants_str = 'TBD vs TBD'
|
||||
else:
|
||||
player_names = [p.player.username for p in match.participants.all() if p.player]
|
||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
events.append({
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match', 'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status, 'match_type': match.match_type,
|
||||
'tryout_id': tryout.id, 'match_id': match.id,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str, 'end_time': end_time_str,
|
||||
},
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_match(tryout_id):
|
||||
"""Create a new match / scrimmage within a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to schedule matches for this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
|
||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||
prefill_date = request.args.get('date', '')
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location')
|
||||
match_type = request.form.get('match_type')
|
||||
|
||||
if not start_time_str:
|
||||
flash('Start time is required. Please select a time slot.', 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(date_obj, start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash('Invalid time format.', 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout_id, title=title, description=description,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location, match_type=match_type, created_by=current_user.id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
|
||||
notified_player_ids = []
|
||||
notified_participant_ids = []
|
||||
|
||||
if match_type == 'team_vs_team':
|
||||
team1_id = request.form.get('team1_id')
|
||||
team2_id = request.form.get('team2_id')
|
||||
match.team1_id = int(team1_id) if team1_id else None
|
||||
match.team2_id = int(team2_id) if team2_id else None
|
||||
if match.team1_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
if match.team2_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
elif match_type == 'player_vs_player':
|
||||
team1_player_ids = request.form.get('team1_player_ids', '')
|
||||
team2_player_ids = request.form.get('team2_player_ids', '')
|
||||
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
|
||||
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
|
||||
for pid in team1_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
for pid in team2_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = team1_ids + team2_ids
|
||||
elif match_type == 'player_scrim':
|
||||
player_ids = request.form.getlist('player_ids')
|
||||
for pid in player_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = [int(p) for p in player_ids]
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
for i, player_id in enumerate(notified_player_ids):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
send_schedule_notification(
|
||||
user_id=player_id, event_type='match', event_title=match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
flash('Match scheduled successfully!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing match."""
|
||||
match = Match.query.get_or_404(match_id)
|
||||
tryout = match.tryout
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to edit this match.', 'danger')
|
||||
return redirect(url_for('matches.calendar'))
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
|
||||
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
|
||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||
current_player_ids = [p.player_id for p in match.participants.all()]
|
||||
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
|
||||
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
|
||||
|
||||
if request.method == 'POST':
|
||||
match.title = request.form.get('title')
|
||||
match.description = request.form.get('description')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location')
|
||||
status = request.form.get('status')
|
||||
|
||||
try:
|
||||
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids)
|
||||
|
||||
if not start_time_str:
|
||||
flash('Start time is required.', 'danger')
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids)
|
||||
|
||||
try:
|
||||
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(match.date, match.start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
match.end_time = end_dt.time()
|
||||
except ValueError:
|
||||
match.start_time = None
|
||||
|
||||
match.location = location
|
||||
if status in ['scheduled', 'completed', 'cancelled']:
|
||||
match.status = status
|
||||
|
||||
notified_player_ids = []
|
||||
notified_participant_ids = []
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
team1_id = request.form.get('team1_id')
|
||||
team2_id = request.form.get('team2_id')
|
||||
new_team1_id = int(team1_id) if team1_id else None
|
||||
new_team2_id = int(team2_id) if team2_id else None
|
||||
|
||||
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
match.team1_id = new_team1_id
|
||||
match.team2_id = new_team2_id
|
||||
if match.team1_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
if match.team2_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
else:
|
||||
if match.team1_id:
|
||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
|
||||
if match.team2_id:
|
||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
|
||||
elif match.match_type == 'player_vs_player':
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
team1_str = request.form.get('team1_player_ids', '')
|
||||
team2_str = request.form.get('team2_player_ids', '')
|
||||
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
|
||||
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
|
||||
for pid in t1_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
for pid in t2_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
|
||||
elif match.match_type == 'player_scrim':
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
player_ids = request.form.getlist('player_ids')
|
||||
for pid in player_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids = [int(p) for p in player_ids]
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
end_time_val = match.end_time or (match.start_time if match.start_time else None)
|
||||
if match.start_time and end_time_val:
|
||||
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
|
||||
else:
|
||||
event_time_str = 'TBD'
|
||||
event_date_str = match.date.strftime('%Y-%m-%d')
|
||||
for i, player_id in enumerate(notified_player_ids):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
send_schedule_notification(
|
||||
user_id=player_id, event_type='match', event_title=match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
flash('Match updated successfully!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
participants_map = {}
|
||||
for p in match.participants.all():
|
||||
participants_map[p.player_id] = {
|
||||
'participant_id': p.id,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
'team_side': p.team_side,
|
||||
}
|
||||
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
team1_player_ids=team1_player_ids,
|
||||
team2_player_ids=team2_player_ids,
|
||||
participants_map=participants_map)
|
||||
|
||||
|
||||
@matches_bp.route('/api/manageable-tryouts')
|
||||
@login_required
|
||||
def api_manageable_tryouts():
|
||||
"""API endpoint returning tryouts the current user can manage."""
|
||||
if not can_schedule_match():
|
||||
return jsonify([])
|
||||
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
manageable = []
|
||||
for t in tryouts:
|
||||
if current_user.can_manage_this_tryout(t):
|
||||
manageable.append({
|
||||
'id': t.id, 'title': t.title,
|
||||
'date': t.date.strftime('%Y-%m-%d'),
|
||||
})
|
||||
return jsonify(manageable)
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a match."""
|
||||
match = Match.query.get_or_404(match_id)
|
||||
tryout = match.tryout
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to delete this match.', 'danger')
|
||||
return redirect(url_for('matches.calendar'))
|
||||
db.session.delete(match)
|
||||
db.session.commit()
|
||||
flash('Match deleted successfully.', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
|
||||
def get_players_available_at_time(date_str, time_str):
|
||||
"""Get list of player IDs available at a specific date and time."""
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
date_for_day = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
day_of_week = date_for_day.weekday()
|
||||
|
||||
players = User.query.filter_by(role='player', is_active_account=True).all()
|
||||
available_players = []
|
||||
for player in players:
|
||||
disponibilities = PlayerDisponibility.query.filter_by(
|
||||
player_id=player.id, day_of_week=day_of_week,
|
||||
).all()
|
||||
for disp in disponibilities:
|
||||
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||
match_time = time_obj.hour * 60 + time_obj.minute
|
||||
if disp_start <= match_time < disp_end:
|
||||
available_players.append(player.id)
|
||||
break
|
||||
return available_players
|
||||
|
||||
|
||||
@matches_bp.route('/api/available_players/<date>/<time>')
|
||||
@login_required
|
||||
def api_available_players(date, time):
|
||||
"""API endpoint to get players available at a specific date/time slot."""
|
||||
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
player_ids = get_players_available_at_time(date, time)
|
||||
return jsonify({'available_player_ids': player_ids})
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle attendance_confirmed for a match participant."""
|
||||
match = Match.query.get_or_404(match_id)
|
||||
tryout = match.tryout
|
||||
|
||||
participant = MatchParticipant.query.get_or_404(participant_id)
|
||||
if participant.match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
is_self = participant.player_id == current_user.id
|
||||
if not is_self and not current_user.can_manage_this_tryout(tryout):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
participant.attendance_confirmed = not participant.attendance_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'participant_id': participant.id,
|
||||
'attendance_confirmed': participant.attendance_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Team match management routes for regular season matches.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
|
||||
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
||||
|
||||
|
||||
def can_manage_team_match(team):
|
||||
"""Check if current user can manage matches for this team."""
|
||||
if isinstance(current_user, Admin):
|
||||
return True
|
||||
if isinstance(current_user, Manager):
|
||||
return True
|
||||
if isinstance(current_user, Coach):
|
||||
if team.coaches.filter_by(id=current_user.id).first():
|
||||
return True
|
||||
if team.coach_id == current_user.id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@team_matches_bp.route('')
|
||||
@login_required
|
||||
def list_matches():
|
||||
"""List all team matches visible to the current user."""
|
||||
filter_team_id = request.args.get('team_id', type=int)
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
team_ids = [t.id for t in teams]
|
||||
matches_query = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(team_ids),
|
||||
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
elif isinstance(current_user, Player):
|
||||
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
|
||||
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
|
||||
matches_query = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(player_team_ids),
|
||||
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
else:
|
||||
teams = []
|
||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
|
||||
if filter_team_id:
|
||||
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
||||
|
||||
matches = matches_query.order_by(TeamMatch.date.desc()).all()
|
||||
|
||||
match_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participants = []
|
||||
for p in tm.participants.all():
|
||||
participants.append({
|
||||
'id': p.id, 'player': p.player,
|
||||
'is_confirmed': p.is_confirmed,
|
||||
})
|
||||
match_data.append({
|
||||
'match': tm, 'participants': participants,
|
||||
'confirmed_count': confirmed, 'total_count': total,
|
||||
})
|
||||
|
||||
return render_template('pages/team_matches.html',
|
||||
teams=teams, match_data=match_data,
|
||||
now=datetime.utcnow())
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_match(team_id):
|
||||
"""Create a new regular-season team match."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to schedule matches for this team.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
|
||||
prefill_date = request.args.get('date', '')
|
||||
is_practice = request.args.get('type') == 'practice'
|
||||
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
||||
|
||||
if is_practice and request.method == 'GET':
|
||||
class TryoutProxy:
|
||||
def __init__(self, team_obj):
|
||||
self.id = 0
|
||||
self.title = team_obj.name
|
||||
self.date = ''
|
||||
self.game = ''
|
||||
self.target_org_team = team_obj
|
||||
|
||||
proxy_tryout = TryoutProxy(team)
|
||||
all_players = [tp.player for tp in team_players if tp.player]
|
||||
|
||||
return render_template('pages/match_form.html',
|
||||
tryout=proxy_tryout, teams=[], all_players=all_players,
|
||||
prefill_date=prefill_date, is_practice=True,
|
||||
team_id=team_id, team=team)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', default_title)
|
||||
opponent = request.form.get('opponent', '').strip() if not is_practice else None
|
||||
description = request.form.get('description', '')
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
location = request.form.get('location', '')
|
||||
|
||||
if not date_str:
|
||||
flash('Date is required.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date)
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
if start_time_str:
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
if end_time_str:
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
else:
|
||||
start_dt = datetime.combine(date_obj, start_time)
|
||||
end_dt = start_dt + timedelta(minutes=30)
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash('Invalid time format.', 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
|
||||
team_match = TeamMatch(
|
||||
org_team_id=team_id, title=title,
|
||||
description=description or None,
|
||||
opponent=opponent or None,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location or None, created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team_match)
|
||||
db.session.flush()
|
||||
|
||||
notified_participant_ids = []
|
||||
for tp in team_players:
|
||||
participant = TeamMatchParticipant(
|
||||
team_match_id=team_match.id, player_id=tp.player_id,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
|
||||
for i, tp in enumerate(team_players):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
|
||||
send_schedule_notification(
|
||||
user_id=tp.player_id, event_type='match',
|
||||
event_title=team_match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
flash(f'Team match "{title}" scheduled successfully!', 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_match(match_id):
|
||||
"""Edit an existing team match."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to edit this match.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
if request.method == 'POST':
|
||||
team_match.title = request.form.get('title', team_match.title)
|
||||
team_match.description = request.form.get('description', '') or None
|
||||
team_match.opponent = request.form.get('opponent', '').strip() or None
|
||||
|
||||
date_str = request.form.get('date')
|
||||
if date_str:
|
||||
try:
|
||||
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return redirect(url_for('team_matches.edit_match', match_id=match_id))
|
||||
|
||||
start_time_str = request.form.get('start_time')
|
||||
if start_time_str:
|
||||
try:
|
||||
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
end_time_str = request.form.get('end_time')
|
||||
if end_time_str:
|
||||
try:
|
||||
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
team_match.location = request.form.get('location', '') or None
|
||||
status = request.form.get('status')
|
||||
if status in ['scheduled', 'completed', 'cancelled']:
|
||||
team_match.status = status
|
||||
|
||||
db.session.commit()
|
||||
flash('Match updated successfully!', 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html',
|
||||
match=team_match, team=team, team_players=[])
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_match(match_id):
|
||||
"""Delete a team match."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team = team_match.org_team
|
||||
if not can_manage_team_match(team):
|
||||
flash('You do not have permission to delete this match.', 'danger')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
db.session.delete(team_match)
|
||||
db.session.commit()
|
||||
flash('Match deleted successfully.', 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
|
||||
@team_matches_bp.route('/api/manageable-teams')
|
||||
@login_required
|
||||
def api_manageable_teams():
|
||||
"""API endpoint returning teams the current user can schedule matches for."""
|
||||
if not current_user.can_schedule_matches():
|
||||
return jsonify([])
|
||||
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
else:
|
||||
return jsonify([])
|
||||
|
||||
return jsonify([{'id': t.id, 'name': t.name} for t in teams])
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_presence(match_id, participant_id):
|
||||
"""Toggle is_confirmed for a team match participant."""
|
||||
team_match = TeamMatch.query.get_or_404(match_id)
|
||||
team = team_match.org_team
|
||||
|
||||
participant = TeamMatchParticipant.query.get_or_404(participant_id)
|
||||
if participant.team_match_id != match_id:
|
||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||
|
||||
can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id
|
||||
if not can_toggle:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
participant.is_confirmed = not participant.is_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'participant_id': participant.id,
|
||||
'is_confirmed': participant.is_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Organization team management routes.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, Team, TeamMember,
|
||||
PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
|
||||
@teams_bp.route('')
|
||||
@login_required
|
||||
def list_teams():
|
||||
"""List all organization teams visible to the current user."""
|
||||
can_manage = current_user.can_manage_teams()
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.managers.any(id=current_user.id),
|
||||
OrgTeam.manager_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Player):
|
||||
flash('Use My Team(s) to view your teams.', 'info')
|
||||
return redirect(url_for('teams.my_teams'))
|
||||
else:
|
||||
flash('You do not have permission to view teams.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
return render_template('pages/teams.html', teams=teams, coaches=coaches,
|
||||
managers=managers, all_players=all_players, can_manage=can_manage)
|
||||
|
||||
|
||||
@teams_bp.route('/my-teams')
|
||||
@login_required
|
||||
def my_teams():
|
||||
"""View the player's own teams with upcoming matches."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash('This page is for players.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
from app.models import TeamMatch, TeamMatchParticipant
|
||||
|
||||
player_teams = current_user.get_org_teams()
|
||||
now = datetime.utcnow()
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
matches = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id == org_team.id,
|
||||
TeamMatch.status == 'scheduled',
|
||||
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
|
||||
|
||||
matches_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participant = TeamMatchParticipant.query.filter_by(
|
||||
team_match_id=tm.id, player_id=current_user.id,
|
||||
).first()
|
||||
matches_data.append({
|
||||
'match': tm,
|
||||
'participant_id': participant.id if participant else None,
|
||||
'is_confirmed': participant.is_confirmed if participant else False,
|
||||
'confirmed_count': confirmed, 'total_count': total,
|
||||
})
|
||||
|
||||
team_data.append({
|
||||
'team': org_team, 'matches': matches_data,
|
||||
'coaches': org_team.get_coaches(),
|
||||
'managers': org_team.get_managers(),
|
||||
})
|
||||
|
||||
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
||||
|
||||
|
||||
@teams_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_team():
|
||||
"""Create a new organization team."""
|
||||
if not current_user.can_manage_teams():
|
||||
flash('You do not have permission to create teams.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
name = request.form.get('name')
|
||||
coach_id = request.form.get('coach_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
|
||||
if not name:
|
||||
flash('Team name is required.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = OrgTeam.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
flash(f'Team "{name}" already exists.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team = OrgTeam(
|
||||
name=name,
|
||||
coach_id=int(coach_id) if coach_id else None,
|
||||
manager_id=int(manager_id) if manager_id else None,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
|
||||
if coach_id:
|
||||
coach_user = User.query.get(int(coach_id))
|
||||
if coach_user:
|
||||
team.coaches.append(coach_user)
|
||||
if manager_id:
|
||||
manager_user = User.query.get(int(manager_id))
|
||||
if manager_user:
|
||||
team.managers.append(manager_user)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" created successfully!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
def edit_team(team_id):
|
||||
"""Edit an existing organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('You do not have permission to edit this team.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
name = request.form.get('name')
|
||||
coach_id = request.form.get('coach_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
|
||||
if not name:
|
||||
flash('Team name is required.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first()
|
||||
if existing:
|
||||
flash(f'Team "{name}" already exists.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if request.form.get('sync_staff') == '1':
|
||||
coach_ids = request.form.getlist('coach_ids')
|
||||
manager_ids = request.form.getlist('manager_ids')
|
||||
|
||||
team.coaches = []
|
||||
for cid in coach_ids:
|
||||
if cid and cid.strip():
|
||||
coach_user = User.query.get(int(cid))
|
||||
if coach_user and isinstance(coach_user, Coach):
|
||||
team.coaches.append(coach_user)
|
||||
coach_list = team.coaches.all()
|
||||
team.coach_id = coach_list[0].id if coach_list else None
|
||||
|
||||
team.managers = []
|
||||
for mid in manager_ids:
|
||||
if mid and mid.strip():
|
||||
manager_user = User.query.get(int(mid))
|
||||
if manager_user and isinstance(manager_user, Manager):
|
||||
team.managers.append(manager_user)
|
||||
manager_list = team.managers.all()
|
||||
team.manager_id = manager_list[0].id if manager_list else None
|
||||
else:
|
||||
team.coach_id = int(coach_id) if coach_id else None
|
||||
team.manager_id = int(manager_id) if manager_id else None
|
||||
|
||||
if coach_id:
|
||||
coach_user = User.query.get(int(coach_id))
|
||||
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
|
||||
team.coaches.append(coach_user)
|
||||
if manager_id:
|
||||
manager_user = User.query.get(int(manager_id))
|
||||
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
|
||||
team.managers.append(manager_user)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" updated successfully!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_team(team_id):
|
||||
"""Delete an organization team."""
|
||||
if not current_user.can_manage_teams():
|
||||
flash('You do not have permission to delete teams.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
name = team.name
|
||||
|
||||
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
|
||||
for t in tryouts:
|
||||
t.target_org_team_id = None
|
||||
db.session.commit()
|
||||
|
||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
|
||||
db.session.commit()
|
||||
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
flash(f'Team "{name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
||||
@login_required
|
||||
def add_coach(team_id):
|
||||
"""Add a coach to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach_id = request.form.get('coach_id')
|
||||
if not coach_id:
|
||||
flash('Please select a coach.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach = User.query.get_or_404(int(coach_id))
|
||||
if not isinstance(coach, Coach):
|
||||
flash('Only coaches can be assigned as coach.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.coaches.filter_by(id=coach.id).first():
|
||||
flash(f'{coach.username} is already a coach of {team.name}.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.coaches.append(coach)
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
flash(f'{coach.username} added as coach of {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
||||
@login_required
|
||||
def add_manager(team_id):
|
||||
"""Add a manager to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
manager_id = request.form.get('manager_id')
|
||||
if not manager_id:
|
||||
flash('Please select a manager.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
manager = User.query.get_or_404(int(manager_id))
|
||||
if not isinstance(manager, Manager):
|
||||
flash('Only managers can be assigned as manager.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.managers.filter_by(id=manager.id).first():
|
||||
flash(f'{manager.username} is already a manager of {team.name}.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.managers.append(manager)
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
flash(f'{manager.username} added as manager of {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
||||
@login_required
|
||||
def remove_coach(team_id):
|
||||
"""Remove a coach from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
coach_id = request.form.get('coach_id')
|
||||
if coach_id:
|
||||
coach = User.query.get(int(coach_id))
|
||||
if coach and team.coaches.filter_by(id=coach.id).first():
|
||||
team.coaches.remove(coach)
|
||||
if team.coach_id == coach.id:
|
||||
team.coach_id = None
|
||||
else:
|
||||
team.coaches = []
|
||||
team.coach_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Coach removed from {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
||||
@login_required
|
||||
def remove_manager(team_id):
|
||||
"""Remove a manager from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
manager_id = request.form.get('manager_id')
|
||||
if manager_id:
|
||||
manager = User.query.get(int(manager_id))
|
||||
if manager and team.managers.filter_by(id=manager.id).first():
|
||||
team.managers.remove(manager)
|
||||
if team.manager_id == manager.id:
|
||||
team.manager_id = None
|
||||
else:
|
||||
team.managers = []
|
||||
team.manager_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Manager removed from {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
||||
@login_required
|
||||
def add_player(team_id):
|
||||
"""Add a player to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player_id = request.form.get('player_id')
|
||||
status = request.form.get('status', 'starter')
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(int(player_id))
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only assign players to teams.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||
if existing:
|
||||
flash(f'{player.username} is already on {team.name}.', 'info')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||
db.session.add(tp)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} added to {team.name}!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player(team_id, player_id):
|
||||
"""Remove a player from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
db.session.delete(tp)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} removed from {team.name}.', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_player_status(team_id, player_id):
|
||||
"""Toggle a player's status between starter and substitute."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
return jsonify({'error': 'Player not found on this team'}), 404
|
||||
|
||||
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True, 'player_id': player_id,
|
||||
'new_status': tp.status, 'player_name': tp.player.username,
|
||||
})
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||
@login_required
|
||||
def add_team_note(team_id):
|
||||
"""Add a team improvement note (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('You do not have permission to add notes to this team.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash('Team notes added successfully!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_player_note(team_id, player_id):
|
||||
"""Add a personal note for a player (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash('You do not have permission to add notes to this team.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only add notes for players.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(f'{player.username} is not on {team.name}.', 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(f'Note added for {player.username}!', 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Tryout management routes for creating, viewing, and managing tryout events.
|
||||
|
||||
This module handles CRUD operations for tryouts and player registrations.
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
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, Manager, Coach, Player, Scout,
|
||||
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
|
||||
OrgTeam, Match, MatchParticipant,
|
||||
ESPORT_GAMES, GAME_POSITIONS,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
|
||||
|
||||
|
||||
def can_manage():
|
||||
"""Check if current user can manage tryouts (Admin or Manager)."""
|
||||
return isinstance(current_user, (Admin, Manager))
|
||||
|
||||
|
||||
@tryouts_bp.route('')
|
||||
@login_required
|
||||
def list_tryouts():
|
||||
"""List all tryouts visible to the current user.
|
||||
|
||||
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
|
||||
"""
|
||||
tryouts = current_user.get_visible_tryouts()
|
||||
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
|
||||
|
||||
|
||||
@tryouts_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_tryout():
|
||||
"""Create a new tryout event. Requires Admin or Manager."""
|
||||
if not can_manage():
|
||||
flash('You do not have permission to create tryouts.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
game = request.form.get('game')
|
||||
date_str = request.form.get('date')
|
||||
location = request.form.get('location')
|
||||
max_players = request.form.get('max_players')
|
||||
target_org_team_id = request.form.get('target_org_team_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
coach_id = request.form.get('coach_id')
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
|
||||
tryout = Tryout(
|
||||
title=title, description=description, game=game, date=date_obj,
|
||||
location=location,
|
||||
max_players=int(max_players) if max_players else None,
|
||||
created_by=current_user.id, status='upcoming',
|
||||
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
|
||||
manager_id=int(manager_id) if manager_id else None,
|
||||
coach_id=int(coach_id) if coach_id else None,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.commit()
|
||||
flash('Tryout created successfully!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_tryout(tryout_id):
|
||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('You do not have permission to edit this tryout.', '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.full_name).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
game = request.form.get('game')
|
||||
date_str = request.form.get('date')
|
||||
location = request.form.get('location')
|
||||
max_players = request.form.get('max_players')
|
||||
target_org_team_id = request.form.get('target_org_team_id')
|
||||
manager_id = request.form.get('manager_id')
|
||||
coach_id = request.form.get('coach_id')
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid date format.', 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
|
||||
tryout.title = title
|
||||
tryout.description = description
|
||||
tryout.game = game
|
||||
tryout.date = date_obj
|
||||
tryout.location = location
|
||||
tryout.max_players = int(max_players) if max_players else None
|
||||
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
|
||||
tryout.manager_id = int(manager_id) if manager_id else None
|
||||
tryout.coach_id = int(coach_id) if coach_id else None
|
||||
db.session.commit()
|
||||
flash('Tryout updated successfully!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>')
|
||||
@login_required
|
||||
def view_tryout(tryout_id):
|
||||
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
|
||||
can_view = False
|
||||
if isinstance(current_user, Admin):
|
||||
can_view = True
|
||||
elif isinstance(current_user, Manager) and tryout.created_by == current_user.id:
|
||||
can_view = True
|
||||
elif isinstance(current_user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
if org_team and tryout.target_org_team_id == org_team.id:
|
||||
can_view = True
|
||||
elif isinstance(current_user, Player):
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id).first() is not None
|
||||
player_in_match = MatchParticipant.query.join(Match).filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).first() is not None
|
||||
can_view = is_registered or player_in_match
|
||||
elif isinstance(current_user, Scout):
|
||||
can_view = True
|
||||
|
||||
if not can_view:
|
||||
flash('You do not have permission to view this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
|
||||
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
|
||||
|
||||
player_eval_status = {}
|
||||
if current_user.can_evaluate():
|
||||
for p in registered_players:
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
).first()
|
||||
player_eval_status[p.id] = existing is not None
|
||||
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id,
|
||||
).first() is not None
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
team_data = []
|
||||
for team in teams:
|
||||
members = TeamMember.query.filter_by(team_id=team.id).all()
|
||||
team_data.append({
|
||||
'team': team,
|
||||
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
|
||||
for m in members],
|
||||
})
|
||||
|
||||
can_edit = current_user.can_manage_this_tryout(tryout)
|
||||
|
||||
can_view_calendar = can_edit
|
||||
if isinstance(current_user, Player):
|
||||
player_in_match = MatchParticipant.query.join(Match).filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).first() is not None
|
||||
can_view_calendar = is_registered or player_in_match
|
||||
|
||||
all_players = None
|
||||
if can_edit:
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
|
||||
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
match_data = []
|
||||
for match in matches:
|
||||
all_participants = list(match.participants.all())
|
||||
confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed)
|
||||
total_count = len(all_participants)
|
||||
|
||||
player_presence = []
|
||||
for p in all_participants:
|
||||
if p.player:
|
||||
player_presence.append({
|
||||
'participant_id': p.id, 'player_id': p.player_id,
|
||||
'player_name': p.player.username,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
})
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
participants = {
|
||||
'team1': match.team1.name if match.team1 else 'TBD',
|
||||
'team2': match.team2.name if match.team2 else 'TBD',
|
||||
'team1_players': [{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team1.members.all()] if match.team1 else [],
|
||||
'team2_players': [{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team2.members.all()] if match.team2 else [],
|
||||
}
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=1).all() if p.player]
|
||||
team2_players = [{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=2).all() if p.player]
|
||||
participants = {
|
||||
'team1': 'Team 1', 'team2': 'Team 2',
|
||||
'team1_players': team1_players, 'team2_players': team2_players,
|
||||
}
|
||||
else:
|
||||
participants = [p.player.username for p in match.participants.all()]
|
||||
|
||||
match_data.append({
|
||||
'match': match, 'participants': participants,
|
||||
'confirmed_count': confirmed_count, 'total_count': total_count,
|
||||
'player_presence': player_presence,
|
||||
})
|
||||
|
||||
return render_template('pages/view_tryout.html',
|
||||
tryout=tryout, registered_players=registered_players,
|
||||
evaluations=evaluations, player_eval_status=player_eval_status,
|
||||
is_registered=is_registered, registrations=registrations,
|
||||
team_data=team_data, can_edit=can_edit,
|
||||
can_view_calendar=can_view_calendar, all_players=all_players,
|
||||
matches=matches, match_data=match_data,
|
||||
game_positions=GAME_POSITIONS, now=datetime.utcnow())
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
|
||||
@login_required
|
||||
def register_for_tryout(tryout_id):
|
||||
"""Register a player for a tryout. Only Players can self-register."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not isinstance(current_user, Player):
|
||||
flash('Only players can register for tryouts.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.status not in ['upcoming', 'in_progress']:
|
||||
flash('This tryout is not accepting registrations.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id).first()
|
||||
if existing:
|
||||
flash('You are already registered for this tryout.', 'info')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
flash('This tryout is full.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
flash('Successfully registered for tryout!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
||||
tryout.status = new_status
|
||||
db.session.commit()
|
||||
flash(f'Tryout status updated to {new_status}.', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first_or_404()
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['registered', 'attended', 'no_show']:
|
||||
registration.status = new_status
|
||||
db.session.commit()
|
||||
flash('Registration status updated.', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
||||
@login_required
|
||||
def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = Tryout.query.get_or_404(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))
|
||||
player_id = request.form.get('player_id')
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
player = User.query.get_or_404(int(player_id))
|
||||
if not isinstance(player, Player):
|
||||
flash('Can only register players.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player.id).first()
|
||||
if existing:
|
||||
flash(f'{player.username} is already registered for this tryout.', 'info')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||
if count >= tryout.max_players:
|
||||
flash('This tryout is full.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
|
||||
db.session.add(registration)
|
||||
db.session.commit()
|
||||
flash(f'{player.username} registered for tryout!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash('Permission denied.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first()
|
||||
if registration:
|
||||
db.session.delete(registration)
|
||||
|
||||
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
|
||||
if team_ids:
|
||||
TeamMember.query.filter(
|
||||
TeamMember.team_id.in_(team_ids),
|
||||
TeamMember.player_id == player_id,
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||
if match_ids:
|
||||
MatchParticipant.query.filter(
|
||||
MatchParticipant.match_id.in_(match_ids),
|
||||
MatchParticipant.player_id == player_id,
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'{player.username} removed from tryout.', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
tryout = Tryout.query.get_or_404(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))
|
||||
|
||||
team_name = request.form.get('team_name')
|
||||
if team_name:
|
||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(f'Team "{team_name}" created!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_team(tryout_id, team_id):
|
||||
"""Add a player to a tryout team."""
|
||||
team = Team.query.get_or_404(team_id)
|
||||
tryout = Tryout.query.get_or_404(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))
|
||||
|
||||
player_id = request.form.get('player_id')
|
||||
position = request.form.get('position', '')
|
||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||
if existing:
|
||||
flash('Player is already on this team.', 'info')
|
||||
else:
|
||||
member = TeamMember(team_id=team_id, player_id=int(player_id), position=position)
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
flash('Player added to team!', 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -0,0 +1,770 @@
|
||||
"""User management routes for profiles, disponibilities, and contracts.
|
||||
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db, hash_password, csrf
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, USER_TYPES, ESPORT_GAMES,
|
||||
PlayerDisponibility, UserGamertag, GAME_PLATFORMS,
|
||||
Contract, OrgTeam, CoachAvailability,
|
||||
TeamNote, PersonalNote, OneOnOneRequest,
|
||||
Evaluation, Match, Team, TeamMember,
|
||||
MatchParticipant, Tryout, TryoutRegistration, TeamPlayer,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
from marshmallow import ValidationError
|
||||
from app.validators import (
|
||||
CreateUserSchema, EditUserSchema, EditProfileSchema,
|
||||
UploadContractSchema, OneOnOneRequestSchema,
|
||||
)
|
||||
import requests
|
||||
|
||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
||||
|
||||
users_bp = Blueprint('users', __name__, url_prefix='/users')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gamertag helper (shared)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def update_user_gamertags(user, selected_games):
|
||||
"""Update gamertags for a user based on form input."""
|
||||
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||
for game in selected_games:
|
||||
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
||||
platform = request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
||||
existing = existing_gamertags.get(game)
|
||||
if gamertag:
|
||||
if existing:
|
||||
existing.gamertag = gamertag
|
||||
existing.platform = platform
|
||||
else:
|
||||
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
|
||||
db.session.add(gt)
|
||||
elif existing:
|
||||
db.session.delete(existing)
|
||||
for game in existing_gamertags:
|
||||
if game not in selected_games:
|
||||
db.session.delete(existing_gamertags[game])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# USER_TYPE → Model mapping for create_user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_USER_CLASS_MAP = {
|
||||
'admin': Admin,
|
||||
'manager': Manager,
|
||||
'coach': Coach,
|
||||
'player': Player,
|
||||
'scout': Scout,
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ROUTES
|
||||
# ===========================================================================
|
||||
|
||||
@users_bp.route('')
|
||||
@login_required
|
||||
def list_users():
|
||||
"""List all users for management (Admin only)."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash('Only the president can manage users.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
users = User.query.order_by(User.role, User.username).all()
|
||||
return render_template('pages/users.html', users=users, roles=USER_TYPES)
|
||||
|
||||
|
||||
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_user(user_id):
|
||||
"""Edit an existing user (Admin only)."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash('Only the president can edit users.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
full_name = request.form.get('full_name')
|
||||
email = request.form.get('email')
|
||||
phone = request.form.get('phone')
|
||||
role = request.form.get('role')
|
||||
is_active = request.form.get('is_active_account') == 'on'
|
||||
|
||||
if role not in USER_TYPES:
|
||||
flash('Invalid role selected.', 'danger')
|
||||
return render_template('pages/edit_user.html', user=user, roles=USER_TYPES,
|
||||
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS)
|
||||
|
||||
selected_games = request.form.getlist('games')
|
||||
discord_username = request.form.get('discord_username', '').strip()
|
||||
discord_user_id = request.form.get('discord_user_id', '').strip()
|
||||
league_os_profile = request.form.get('league_os_profile', '').strip()
|
||||
|
||||
user.full_name = full_name
|
||||
user.email = email
|
||||
user.phone = phone
|
||||
user.role = role
|
||||
user.is_active_account = is_active
|
||||
user.games = ','.join(selected_games) if selected_games else None
|
||||
user.discord_username = discord_username or None
|
||||
user.discord_user_id = discord_user_id or None
|
||||
user.league_os_profile = league_os_profile or None
|
||||
|
||||
update_user_gamertags(user, selected_games)
|
||||
|
||||
password = request.form.get('password')
|
||||
if password:
|
||||
user.password_hash = hash_password(password)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'User {user.username} updated successfully!', 'success')
|
||||
return redirect(url_for('users.list_users'))
|
||||
|
||||
user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
|
||||
for gt in user.gamertags}
|
||||
return render_template('pages/edit_user.html', user=user, roles=USER_TYPES,
|
||||
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=user_gamertags)
|
||||
|
||||
|
||||
@users_bp.route('/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_user(user_id):
|
||||
"""Delete a user (Admin only)."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash('Only the president can delete users.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if current_user.id == user_id:
|
||||
flash('You cannot delete your own account.', 'danger')
|
||||
return redirect(url_for('users.list_users'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
Evaluation.query.filter(
|
||||
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
|
||||
).delete(synchronize_session=False)
|
||||
PlayerDisponibility.query.filter_by(player_id=user_id).delete()
|
||||
CoachAvailability.query.filter_by(coach_id=user_id).delete()
|
||||
PersonalNote.query.filter(
|
||||
db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id),
|
||||
).delete(synchronize_session=False)
|
||||
TeamNote.query.filter_by(coach_id=user_id).delete()
|
||||
OneOnOneRequest.query.filter(
|
||||
db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id),
|
||||
).delete(synchronize_session=False)
|
||||
UserGamertag.query.filter_by(user_id=user_id).delete()
|
||||
Contract.query.filter_by(player_id=user_id).delete()
|
||||
TryoutRegistration.query.filter_by(player_id=user_id).delete()
|
||||
TeamPlayer.query.filter_by(player_id=user_id).delete()
|
||||
TeamMember.query.filter_by(player_id=user_id).delete()
|
||||
MatchParticipant.query.filter_by(player_id=user_id).delete()
|
||||
OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None})
|
||||
OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None})
|
||||
Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
|
||||
Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
|
||||
Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
|
||||
OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
|
||||
Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id})
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f'User {user.username} has been removed.', 'success')
|
||||
return redirect(url_for('users.list_users'))
|
||||
|
||||
|
||||
@users_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_user():
|
||||
"""Create a new user (Admin only). Uses the correct polymorphic subclass."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash('Only the president can create users.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
full_name = request.form.get('full_name')
|
||||
phone = request.form.get('phone')
|
||||
role = request.form.get('role')
|
||||
|
||||
if role not in USER_TYPES:
|
||||
flash('Invalid role selected.', 'danger')
|
||||
return render_template('pages/create_user.html', roles=USER_TYPES)
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
return render_template('pages/create_user.html', roles=USER_TYPES)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
return render_template('pages/create_user.html', roles=USER_TYPES)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user_cls = _USER_CLASS_MAP.get(role, Player)
|
||||
user = user_cls(
|
||||
username=username, password_hash=hashed_password,
|
||||
role=role, full_name=full_name,
|
||||
email=email, phone=phone,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash(f'User {full_name} created as {role}!', 'success')
|
||||
return redirect(url_for('users.list_users'))
|
||||
|
||||
return render_template('pages/create_user.html', roles=USER_TYPES)
|
||||
|
||||
|
||||
@users_bp.route('/<int:user_id>/view')
|
||||
@login_required
|
||||
def view_user(user_id):
|
||||
"""View a public profile for any user."""
|
||||
user = User.query.get_or_404(user_id)
|
||||
return render_template('pages/view_user.html', profile_user=user)
|
||||
|
||||
|
||||
@users_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
"""View the current user's profile."""
|
||||
contracts = None
|
||||
if isinstance(current_user, Player):
|
||||
contracts = Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
).order_by(Contract.uploaded_at.desc()).all()
|
||||
return render_template('pages/profile.html', user=current_user, contracts=contracts)
|
||||
|
||||
|
||||
@users_bp.route('/profile/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_profile():
|
||||
"""Edit the current user's profile."""
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
full_name = request.form.get('full_name')
|
||||
email = request.form.get('email')
|
||||
phone = request.form.get('phone')
|
||||
|
||||
selected_games = request.form.getlist('games')
|
||||
discord_username = request.form.get('discord_username', '').strip()
|
||||
discord_user_id = request.form.get('discord_user_id', '').strip()
|
||||
league_os_profile = request.form.get('league_os_profile', '').strip()
|
||||
|
||||
if username != current_user.username and User.query.filter_by(username=username).first():
|
||||
flash('Username already taken.', 'danger')
|
||||
return render_template('pages/edit_profile.html', user=current_user,
|
||||
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags())
|
||||
|
||||
if email != current_user.email and User.query.filter_by(email=email).first():
|
||||
flash('Email already in use.', 'danger')
|
||||
return render_template('pages/edit_profile.html', user=current_user,
|
||||
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags())
|
||||
|
||||
current_user.username = username
|
||||
current_user.full_name = full_name
|
||||
current_user.email = email
|
||||
current_user.phone = phone
|
||||
current_user.games = ','.join(selected_games) if selected_games else None
|
||||
current_user.discord_username = discord_username or None
|
||||
current_user.discord_user_id = discord_user_id or None
|
||||
current_user.league_os_profile = league_os_profile or None
|
||||
|
||||
update_user_gamertags(current_user, selected_games)
|
||||
|
||||
password = request.form.get('password')
|
||||
if password:
|
||||
current_user.password_hash = hash_password(password)
|
||||
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('users.profile'))
|
||||
|
||||
return render_template('pages/edit_profile.html', user=current_user,
|
||||
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disponibilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
|
||||
def add_30_minutes(t):
|
||||
return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time()
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities')
|
||||
@login_required
|
||||
def get_disponibilities():
|
||||
"""API endpoint to get all player disponibilities for scheduling."""
|
||||
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
players = User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
result = {}
|
||||
for player in players:
|
||||
disponibilities = list(player.disponibilities)
|
||||
result[player.id] = {
|
||||
'username': player.username,
|
||||
'disponibilities': [
|
||||
{
|
||||
'id': d.id, 'day_of_week': d.day_of_week,
|
||||
'day_name': DAY_NAMES[d.day_of_week],
|
||||
'start_time': d.start_time.strftime('%H:%M'),
|
||||
'end_time': d.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for d in disponibilities
|
||||
],
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/my')
|
||||
@login_required
|
||||
def get_my_disponibilities():
|
||||
"""API endpoint for players to get their own disponibilities."""
|
||||
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
|
||||
result = {}
|
||||
for d in disponibilities:
|
||||
day = d.day_of_week
|
||||
if day not in result:
|
||||
result[day] = []
|
||||
result[day].append({
|
||||
'id': d.id, 'day_of_week': d.day_of_week,
|
||||
'day_name': DAY_NAMES[d.day_of_week],
|
||||
'start_time': d.start_time.strftime('%H:%M'),
|
||||
'end_time': d.end_time.strftime('%H:%M'),
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_disponibility():
|
||||
"""Add a disponibility block for the current player."""
|
||||
day_of_week = request.form.get('day_of_week', type=int)
|
||||
start_time_str = request.form.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
return jsonify({'error': 'Invalid day of week'}), 400
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Invalid time format'}), 400
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id, day_of_week=day_of_week,
|
||||
start_time=start_time, end_time=end_time,
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'id': disponibility.id, 'day_of_week': disponibility.day_of_week,
|
||||
'day_name': DAY_NAMES[disponibility.day_of_week],
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
'end_time': disponibility.end_time.strftime('%H:%M'),
|
||||
})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
|
||||
@login_required
|
||||
def add_disponibilities_bulk():
|
||||
"""Add multiple disponibility blocks at once."""
|
||||
data = request.get_json()
|
||||
slots = data.get('slots', [])
|
||||
created = []
|
||||
for slot in slots:
|
||||
day_of_week = slot.get('day_of_week')
|
||||
start_time_str = slot.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
continue
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
existing = PlayerDisponibility.query.filter_by(
|
||||
player_id=current_user.id, day_of_week=day_of_week, start_time=start_time,
|
||||
).first()
|
||||
if not existing:
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id, day_of_week=day_of_week,
|
||||
start_time=start_time, end_time=end_time,
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.flush()
|
||||
created.append({
|
||||
'id': disponibility.id, 'day_of_week': disponibility.day_of_week,
|
||||
'day_name': DAY_NAMES[disponibility.day_of_week],
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
})
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'created': created})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
||||
@login_required
|
||||
def clear_disponibilities():
|
||||
"""Clear all disponibilities for the current player."""
|
||||
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_disponibility(disponibility_id):
|
||||
"""Delete a disponibility block."""
|
||||
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
|
||||
if disponibility.player_id != current_user.id:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
db.session.delete(disponibility)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def can_manage_player_contract(user, player_id):
|
||||
"""Check if a user can upload contracts for a specific player."""
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
if org_team:
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=org_team.id).first()
|
||||
if tp:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@users_bp.route('/contracts')
|
||||
@login_required
|
||||
def list_contracts():
|
||||
"""View contracts for the current user or players they manage."""
|
||||
contracts = None
|
||||
players = None
|
||||
|
||||
if isinstance(current_user, Player):
|
||||
contracts = Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
).order_by(Contract.uploaded_at.desc()).all()
|
||||
elif isinstance(current_user, (Admin, Manager, Coach)):
|
||||
players = []
|
||||
if isinstance(current_user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
if org_team:
|
||||
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
||||
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
||||
else:
|
||||
players = User.query.filter_by(role='player').all()
|
||||
|
||||
if players:
|
||||
player_ids = [p.id for p in players]
|
||||
contracts = Contract.query.filter(
|
||||
Contract.player_id.in_(player_ids),
|
||||
).order_by(Contract.uploaded_at.desc()).all()
|
||||
|
||||
return render_template('pages/contracts.html', contracts=contracts, players=players
|
||||
if isinstance(current_user, (Admin, Manager, Coach)) else None)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_contract():
|
||||
"""Upload a contract for a player."""
|
||||
if not isinstance(current_user, (Admin, Manager, Coach)):
|
||||
flash('Only presidents, managers, and coaches can upload contracts.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
if isinstance(current_user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
if org_team:
|
||||
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
||||
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
||||
else:
|
||||
players = []
|
||||
else:
|
||||
players = User.query.filter_by(role='player').all()
|
||||
|
||||
if request.method == 'POST':
|
||||
contract_schema = UploadContractSchema()
|
||||
try:
|
||||
validated = contract_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
player_id = validated['player_id']
|
||||
notes = validated.get('notes')
|
||||
|
||||
if not can_manage_player_contract(current_user, player_id):
|
||||
flash('You do not have permission to upload a contract for this player.', 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
if 'contract_file' not in request.files:
|
||||
flash('No file selected.', 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
file = request.files['contract_file']
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
if not file.filename.lower().endswith('.pdf'):
|
||||
flash('Only PDF files are allowed for contracts.', 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player_teams = player.get_org_teams()
|
||||
team = player_teams[0] if player_teams else None
|
||||
|
||||
if team:
|
||||
team_folder = os.path.join(upload_dir, secure_filename(team.name))
|
||||
os.makedirs(team_folder, exist_ok=True)
|
||||
final_dir = team_folder
|
||||
else:
|
||||
final_dir = upload_dir
|
||||
|
||||
original_filename = secure_filename(file.filename)
|
||||
file_uuid = str(uuid.uuid4())
|
||||
stored_filename = f"{file_uuid}.pdf"
|
||||
file_path = os.path.join(final_dir, stored_filename)
|
||||
file.save(file_path)
|
||||
|
||||
contract = Contract(
|
||||
player_id=player_id, team_id=team.id if team else None,
|
||||
uploaded_by_id=current_user.id,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=file_path,
|
||||
notes=notes if notes else None,
|
||||
)
|
||||
db.session.add(contract)
|
||||
db.session.commit()
|
||||
flash(f'Contract uploaded successfully for {player.username}!', 'success')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/upload_signed', methods=['POST'])
|
||||
@login_required
|
||||
def upload_signed_contract(contract_id):
|
||||
"""Upload a signed contract (player only)."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
if not contract.can_upload_signed(current_user):
|
||||
flash('Only the player can upload their signed contract.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
if 'signed_file' not in request.files:
|
||||
flash('No file selected.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
file = request.files['signed_file']
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
signed_filename = f"signed_{contract.stored_filename}"
|
||||
file.save(contract.file_path.replace(contract.stored_filename, signed_filename))
|
||||
|
||||
contract.signed_filename = signed_filename
|
||||
contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||
contract.status = 'signed'
|
||||
contract.signed_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash('Signed contract uploaded successfully!', 'success')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download')
|
||||
@login_required
|
||||
def download_contract(contract_id):
|
||||
"""Download a contract file."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
if not contract.can_view(current_user):
|
||||
flash('You do not have permission to download this contract.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(contract.file_path, as_attachment=True, download_name=contract.original_filename)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<int:contract_id>/download_signed')
|
||||
@login_required
|
||||
def download_signed_contract(contract_id):
|
||||
"""Download a signed contract file."""
|
||||
contract = Contract.query.get_or_404(contract_id)
|
||||
if not contract.can_view(current_user):
|
||||
flash('You do not have permission to download this contract.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
if not contract.signed_file_path:
|
||||
flash('No signed contract available.', 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One on One
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '')
|
||||
|
||||
|
||||
def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str,
|
||||
team_name, coach_name, coach_discord, coach_discord_id, request_id=None):
|
||||
"""Send a Discord notification for a One on One request."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if coach_discord_id:
|
||||
try:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name, coach_discord_id=coach_discord_id,
|
||||
player_name=player_name, team_name=team_name,
|
||||
date_str=date_str, start_time=start_time_str,
|
||||
end_time=end_time_str, points=points, request_id=request_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send Discord DM: {e}")
|
||||
|
||||
if DISCORD_WEBHOOK_URL:
|
||||
try:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
if DISCORD_WEBHOOK_URL.isdigit() and not coach_discord_id:
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name, coach_discord_id=DISCORD_WEBHOOK_URL,
|
||||
player_name=player_name, team_name=team_name,
|
||||
date_str=date_str, start_time=start_time_str,
|
||||
end_time=end_time_str, points=points,
|
||||
)
|
||||
elif not DISCORD_WEBHOOK_URL.isdigit():
|
||||
embed = {
|
||||
"embeds": [{
|
||||
"title": "One on One Request", "color": 3447003,
|
||||
"fields": [
|
||||
{"name": "Player", "value": player_name, "inline": True},
|
||||
{"name": "Team", "value": team_name or "Unknown Team", "inline": True},
|
||||
{"name": "Date", "value": date_str, "inline": True},
|
||||
{"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True},
|
||||
{"name": "Discussion Points", "value": points or "No specific points provided", "inline": False},
|
||||
],
|
||||
"footer": {
|
||||
"text": f"Coach: {coach_name}"
|
||||
+ (f" (Discord: {coach_discord})" if coach_discord else ""),
|
||||
},
|
||||
}],
|
||||
}
|
||||
requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send Discord notification: {e}")
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def one_on_one():
|
||||
"""One on One request page for players."""
|
||||
if not isinstance(current_user, Player):
|
||||
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:
|
||||
coach_availability = CoachAvailability.query.filter_by(coach_id=coach.id).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
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:
|
||||
flash('Cannot request One on One - no coach assigned.', '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()
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
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()
|
||||
|
||||
is_available = any(
|
||||
av.day_of_week == day_of_week and av.start_time <= start_time and av.end_time >= end_time
|
||||
for av in coach_availability
|
||||
)
|
||||
|
||||
if not is_available:
|
||||
flash("The requested time is not within the coach's availability.", 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
request_obj = OneOnOneRequest(
|
||||
player_id=current_user.id, coach_id=coach.id,
|
||||
org_team_id=org_team.id if org_team else None,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
points=points if points else None,
|
||||
)
|
||||
db.session.add(request_obj)
|
||||
db.session.commit()
|
||||
|
||||
send_discord_notification(
|
||||
player_name=current_user.full_name,
|
||||
points=points, date_str=date_str,
|
||||
start_time_str=start_time_str, end_time_str=end_time_str,
|
||||
team_name=org_team.name if org_team else 'Unknown Team',
|
||||
coach_name=coach.full_name,
|
||||
coach_discord=coach.discord_username or '',
|
||||
coach_discord_id=coach.discord_user_id or '',
|
||||
request_id=request_obj.id,
|
||||
)
|
||||
|
||||
flash('Your One on One request has been submitted!', 'success')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Team Tryouts - Main JavaScript Module
|
||||
*
|
||||
* This module provides core UI functionality including:
|
||||
* - Dark mode toggle and persistence
|
||||
* - Sidebar mobile toggle
|
||||
* - Draggable dashboard blocks
|
||||
* - Auto-dismissing alerts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Toggle dark mode theme.
|
||||
*
|
||||
* Switches between light and dark themes, updates the toggle button icon,
|
||||
* and persists the preference in localStorage.
|
||||
*/
|
||||
function toggleDarkMode() {
|
||||
const body = document.documentElement;
|
||||
const isDark = body.getAttribute('data-theme') === 'dark';
|
||||
const toggle = document.getElementById('darkModeToggle');
|
||||
|
||||
if (isDark) {
|
||||
body.removeAttribute('data-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
toggle.innerHTML = '<i class="fas fa-moon"></i>';
|
||||
} else {
|
||||
body.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
toggle.innerHTML = '<i class="fas fa-sun"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved theme preference from localStorage.
|
||||
*
|
||||
* Called on page load to restore the user's preferred theme.
|
||||
*/
|
||||
function loadTheme() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const toggle = document.getElementById('darkModeToggle');
|
||||
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
if (toggle) {
|
||||
toggle.innerHTML = '<i class="fas fa-sun"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle sidebar visibility on mobile devices.
|
||||
*
|
||||
* Adds/removes 'open' class on sidebar to show/hide it.
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
sidebar.classList.toggle('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside sidebar to close it on mobile.
|
||||
*
|
||||
* Listens for document clicks and closes sidebar when clicking
|
||||
* outside of it, but only on screens smaller than 768px.
|
||||
*/
|
||||
document.addEventListener('click', function(event) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
if (window.innerWidth <= 768) {
|
||||
if (!sidebar.contains(event.target) && !toggle.contains(event.target)) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize confirmation dialogs for elements with data-confirm attribute.
|
||||
*
|
||||
* Adds click handler to show confirmation dialog before submitting forms.
|
||||
*/
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(el) {
|
||||
el.addEventListener('click', function(e) {
|
||||
if (!confirm(this.getAttribute('data-confirm'))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize draggable blocks functionality on dashboard pages.
|
||||
*
|
||||
* Adds drag handles to card headers and enables drag-and-drop
|
||||
* reordering of cards. Saves layout to localStorage.
|
||||
*/
|
||||
function initDraggableBlocks() {
|
||||
const grids = document.querySelectorAll('.dashboard-grid');
|
||||
|
||||
grids.forEach(function(grid) {
|
||||
const cards = grid.querySelectorAll('.card');
|
||||
|
||||
// Add drag handles to card headers
|
||||
cards.forEach(function(card, index) {
|
||||
const header = card.querySelector('.card-header');
|
||||
if (header) {
|
||||
// Add unique ID to card if not present
|
||||
if (!card.id) {
|
||||
card.id = 'block-' + index;
|
||||
}
|
||||
|
||||
// Create drag handle
|
||||
const dragHandle = document.createElement('div');
|
||||
dragHandle.className = 'drag-handle';
|
||||
dragHandle.innerHTML = '<i class="fas fa-grip-vertical"></i>';
|
||||
dragHandle.title = 'Drag to reorder';
|
||||
|
||||
// Add draggable class to header
|
||||
header.classList.add('draggable');
|
||||
header.prepend(dragHandle);
|
||||
|
||||
// Make header draggable
|
||||
makeHeaderDraggable(header, card, grid);
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved layout
|
||||
loadLayout(grid);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a card header draggable.
|
||||
*
|
||||
* @param {HTMLElement} header - The card header element
|
||||
* @param {HTMLElement} card - The card element being dragged
|
||||
* @param {HTMLElement} grid - The parent grid container
|
||||
*/
|
||||
function makeHeaderDraggable(header, card, grid) {
|
||||
let draggedElement = null;
|
||||
let placeholder = null;
|
||||
let ghost = null;
|
||||
let animationFrame = null;
|
||||
|
||||
header.addEventListener('mousedown', function(e) {
|
||||
// Only start drag from drag handle
|
||||
if (!e.target.closest('.drag-handle')) return;
|
||||
|
||||
e.preventDefault();
|
||||
draggedElement = card;
|
||||
|
||||
// Get card dimensions
|
||||
const rect = card.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left;
|
||||
const offsetY = e.clientY - rect.top;
|
||||
|
||||
// Store original data-position to restore later
|
||||
const originalDataPosition = card.getAttribute('data-position');
|
||||
|
||||
// Remove data-position during drag to allow free placement
|
||||
if (originalDataPosition) {
|
||||
card.removeAttribute('data-position');
|
||||
}
|
||||
|
||||
// Create ghost element (floating preview)
|
||||
ghost = document.createElement('div');
|
||||
ghost.className = 'sortable-ghost';
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.pointerEvents = 'none';
|
||||
ghost.style.zIndex = '99999';
|
||||
ghost.style.width = rect.width + 'px';
|
||||
ghost.style.height = rect.height + 'px';
|
||||
ghost.style.backgroundColor = 'var(--card-bg, white)';
|
||||
ghost.style.border = '2px solid var(--primary)';
|
||||
ghost.style.borderRadius = 'var(--radius)';
|
||||
ghost.style.opacity = '0.9';
|
||||
ghost.style.boxShadow = 'var(--shadow-lg)';
|
||||
ghost.style.cursor = 'grabbing';
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
// Create placeholder element (shows drop position)
|
||||
placeholder = document.createElement('div');
|
||||
placeholder.className = 'sortable-placeholder';
|
||||
placeholder.style.minHeight = rect.height + 'px';
|
||||
card.parentNode.insertBefore(placeholder, card);
|
||||
|
||||
// Keep original card visible but add dragging style
|
||||
card.classList.add('dragging');
|
||||
card.style.opacity = '0.5';
|
||||
card.style.transform = 'scale(0.98)';
|
||||
|
||||
// Initial position
|
||||
ghost.style.top = (e.clientY - offsetY) + 'px';
|
||||
ghost.style.left = (e.clientX - offsetX) + 'px';
|
||||
|
||||
/**
|
||||
* Update ghost position during drag.
|
||||
* @param {number} clientY - Mouse Y coordinate
|
||||
* @param {number} clientX - Mouse X coordinate
|
||||
*/
|
||||
function updateGhostPosition(clientY, clientX) {
|
||||
// Position ghost directly at cursor position
|
||||
ghost.style.top = (clientY - offsetY) + 'px';
|
||||
ghost.style.left = (clientX - offsetX) + 'px';
|
||||
}
|
||||
|
||||
// Get grid layout info for 2D position tracking
|
||||
const gridRect = grid.getBoundingClientRect();
|
||||
const gridStyle = window.getComputedStyle(grid);
|
||||
const gridGap = parseInt(gridStyle.gap) || 20;
|
||||
|
||||
/**
|
||||
* Calculate which column the X position falls into.
|
||||
* @param {number} x - X coordinate relative to grid
|
||||
* @returns {number} Column index
|
||||
*/
|
||||
function getColumnFromX(x) {
|
||||
// Calculate which column the x position falls into
|
||||
const relativeX = x - gridRect.left;
|
||||
const colWidth = (gridRect.width + gridGap) / Math.max(1, Math.floor(gridRect.width / 300)); // Estimate columns based on min 300px width
|
||||
return Math.floor(relativeX / (colWidth + gridGap));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse movement during drag.
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
function onMouseMove(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame for smooth updates
|
||||
animationFrame = requestAnimationFrame(function() {
|
||||
updateGhostPosition(e.clientY, e.clientX);
|
||||
|
||||
// Use a point slightly offset from cursor to avoid ghost interference
|
||||
// This ensures we detect the card under the cursor, not the ghost
|
||||
const checkX = e.clientX;
|
||||
const checkY = e.clientY + 10; // 10px below cursor for better detection
|
||||
|
||||
// Find the element under the offset point
|
||||
const elementUnderCursor = document.elementFromPoint(checkX, checkY);
|
||||
|
||||
// Check if we're directly over the placeholder (skip processing to avoid flicker)
|
||||
if (elementUnderCursor && elementUnderCursor.closest('.sortable-placeholder')) {
|
||||
return; // Already over placeholder, don't change position
|
||||
}
|
||||
|
||||
// Find the card that contains or is the element under cursor
|
||||
let targetCard = null;
|
||||
if (elementUnderCursor) {
|
||||
targetCard = elementUnderCursor.closest('.card');
|
||||
// Make sure it's not the dragged card
|
||||
if (targetCard && targetCard.classList.contains('dragging')) {
|
||||
targetCard = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a valid target card, determine insert position
|
||||
if (targetCard) {
|
||||
const targetRect = targetCard.getBoundingClientRect();
|
||||
const targetMiddleY = targetRect.top + targetRect.height / 2;
|
||||
|
||||
// If cursor is above the card's middle, insert before it
|
||||
// If cursor is below, insert after it
|
||||
// Use checkY for consistent comparison with detection point
|
||||
if (checkY < targetMiddleY) {
|
||||
// Check if placeholder is already in the correct position
|
||||
if (targetCard.nextElementSibling !== placeholder) {
|
||||
grid.insertBefore(placeholder, targetCard);
|
||||
}
|
||||
} else {
|
||||
// Insert after the target card
|
||||
const nextCard = targetCard.nextElementSibling;
|
||||
// If next sibling is the placeholder or the dragged element, it's already in the right position
|
||||
if (nextCard === placeholder || nextCard === draggedElement) {
|
||||
// Already in correct position, don't move
|
||||
} else if (nextCard && nextCard.classList.contains('card')) {
|
||||
grid.insertBefore(placeholder, nextCard);
|
||||
} else {
|
||||
grid.appendChild(placeholder);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No target card found - check if we should move to end
|
||||
// This handles the case where we're dragging over empty space or the grid background
|
||||
// If cursor is within the grid bounds, move to end
|
||||
if (e.clientX >= gridRect.left && e.clientX <= gridRect.right &&
|
||||
e.clientY >= gridRect.top && e.clientY <= gridRect.bottom) {
|
||||
// Check if we're over the dragged element (which is hidden but still in DOM)
|
||||
const isOverDraggedElement = elementUnderCursor &&
|
||||
(elementUnderCursor.closest('.card.dragging') ||
|
||||
elementUnderCursor.closest('.sortable-placeholder'));
|
||||
|
||||
if (!isOverDraggedElement) {
|
||||
// Move placeholder to end if not already there
|
||||
const lastCard = grid.querySelector('.card:last-of-type');
|
||||
if (lastCard && lastCard.nextElementSibling !== placeholder) {
|
||||
grid.appendChild(placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse release to complete drag.
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
function onMouseUp(e) {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
if (draggedElement) {
|
||||
draggedElement.classList.remove('dragging');
|
||||
// Restore original card styles
|
||||
draggedElement.style.opacity = '';
|
||||
draggedElement.style.transform = '';
|
||||
|
||||
// Ensure the card is placed in the correct position
|
||||
// The placeholder shows where the card should go
|
||||
if (placeholder && placeholder.parentNode) {
|
||||
// Move the dragged element to the placeholder position
|
||||
placeholder.parentNode.replaceChild(draggedElement, placeholder);
|
||||
} else {
|
||||
// If no placeholder exists, append to the end
|
||||
grid.appendChild(draggedElement);
|
||||
}
|
||||
|
||||
// Save layout
|
||||
saveLayout(grid);
|
||||
}
|
||||
|
||||
if (ghost && ghost.parentNode) {
|
||||
ghost.parentNode.removeChild(ghost);
|
||||
}
|
||||
|
||||
if (placeholder && placeholder.parentNode) {
|
||||
placeholder.parentNode.removeChild(placeholder);
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current card layout order to localStorage.
|
||||
*
|
||||
* @param {HTMLElement} grid - The grid container to save layout for
|
||||
*/
|
||||
function saveLayout(grid) {
|
||||
const pageKey = getPageKey();
|
||||
const cards = grid.querySelectorAll('.card');
|
||||
const order = Array.from(cards).map(card => card.id);
|
||||
localStorage.setItem('blockLayout_' + pageKey, JSON.stringify(order));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved card layout from localStorage.
|
||||
*
|
||||
* @param {HTMLElement} grid - The grid container to load layout for
|
||||
*/
|
||||
function loadLayout(grid) {
|
||||
const pageKey = getPageKey();
|
||||
const saved = localStorage.getItem('blockLayout_' + pageKey);
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved);
|
||||
const cards = Array.from(grid.querySelectorAll('.card'));
|
||||
|
||||
// Sort cards according to saved order
|
||||
order.forEach(function(id) {
|
||||
const card = cards.find(c => c.id === id);
|
||||
if (card) {
|
||||
grid.appendChild(card);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load layout:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page key for layout storage.
|
||||
*
|
||||
* @returns {string} Page identifier based on URL path
|
||||
*/
|
||||
function getPageKey() {
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/tryouts/')) {
|
||||
return 'tryout';
|
||||
}
|
||||
if (path.includes('/profile')) {
|
||||
return 'profile';
|
||||
}
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize on DOM ready.
|
||||
*
|
||||
* Loads theme preference and initializes draggable blocks.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load theme preference
|
||||
loadTheme();
|
||||
|
||||
// Initialize draggable blocks
|
||||
initDraggableBlocks();
|
||||
|
||||
/**
|
||||
* Auto-dismiss flash alerts after 5 seconds.
|
||||
*/
|
||||
const alerts = document.querySelectorAll('.alert-dismissible');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
if (alert.parentElement) {
|
||||
alert.style.opacity = '0';
|
||||
alert.style.transition = 'opacity 0.3s ease';
|
||||
setTimeout(function() {
|
||||
if (alert.parentElement) {
|
||||
alert.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
# supporting scripts package
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Database backup script for the Team Tryouts application.
|
||||
|
||||
This module provides a simple backup mechanism for the SQLite database
|
||||
and uploaded contract documents. Designed to be run as a scheduled task
|
||||
(Windows Task Scheduler) or cron job.
|
||||
|
||||
Usage:
|
||||
python backup.py
|
||||
|
||||
Configuration via environment variables:
|
||||
BACKUP_DIR: Directory to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
||||
DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db'))
|
||||
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
|
||||
|
||||
|
||||
def create_backup_dir():
|
||||
"""Create the backup directory if it doesn't exist."""
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def backup_database():
|
||||
"""Backup the SQLite database using sqlite3's built-in backup API.
|
||||
|
||||
Returns:
|
||||
str: Path to the created backup file, or None if failed.
|
||||
"""
|
||||
if not os.path.exists(DATABASE_PATH):
|
||||
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'db_backup_{timestamp}.db'
|
||||
backup_path = os.path.join(BACKUP_DIR, backup_filename)
|
||||
|
||||
try:
|
||||
source = sqlite3.connect(DATABASE_PATH)
|
||||
destination = sqlite3.connect(backup_path)
|
||||
source.backup(destination)
|
||||
source.close()
|
||||
destination.close()
|
||||
print(f'[OK] Database backed up to: {backup_path}')
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Database backup failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def backup_documents():
|
||||
"""Backup the uploaded contract documents directory.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if no documents exist.
|
||||
"""
|
||||
if not os.path.exists(DOCUMENTS_DIR):
|
||||
print('[INFO] No documents directory found. Skipping document backup.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = f'documents_backup_{timestamp}'
|
||||
archive_path = os.path.join(BACKUP_DIR, archive_basename)
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR)
|
||||
zip_path = f'{archive_path}.zip'
|
||||
print(f'[OK] Documents backed up to: {zip_path}')
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Document backup failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||
if not os.path.exists(BACKUP_DIR):
|
||||
return
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
|
||||
removed_count = 0
|
||||
|
||||
for filename in os.listdir(BACKUP_DIR):
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||
except OSError as e:
|
||||
print(f'[WARNING] Could not remove {filename}: {e}')
|
||||
|
||||
if removed_count > 0:
|
||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||
else:
|
||||
print('[CLEANUP] No old backups to remove.')
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Verify a database backup by running a quick integrity check.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the backup file to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if backup is valid, False otherwise.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(backup_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('PRAGMA integrity_check')
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
is_valid = result[0] == 'ok'
|
||||
if is_valid:
|
||||
print(f'[OK] Backup integrity verified: {backup_path}')
|
||||
else:
|
||||
print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}')
|
||||
return is_valid
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Backup verification failed: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the full backup process.
|
||||
|
||||
Steps:
|
||||
1. Create backup directory
|
||||
2. Backup database
|
||||
3. Backup documents (if any)
|
||||
4. Verify database backup
|
||||
5. Clean up old backups
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on failure.
|
||||
"""
|
||||
print(f'=== Team Tryouts Backup ===')
|
||||
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print(f'Backup directory: {BACKUP_DIR}')
|
||||
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
||||
print()
|
||||
|
||||
create_backup_dir()
|
||||
|
||||
# 1. Backup database
|
||||
db_backup_path = backup_database()
|
||||
success = True
|
||||
|
||||
# 2. Verify database backup
|
||||
if db_backup_path:
|
||||
if not verify_backup(db_backup_path):
|
||||
success = False
|
||||
|
||||
# 3. Backup documents
|
||||
backup_documents()
|
||||
|
||||
# 4. Cleanup old backups
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if success:
|
||||
print('=== Backup completed successfully ===')
|
||||
else:
|
||||
print('=== Backup completed with warnings ===')
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Development HTTPS server for testing production settings locally.
|
||||
|
||||
Generates a self-signed certificate (if not present) and runs the
|
||||
application via Waitress wrapped in a TLS socket. This simulates the
|
||||
production environment where Nginx handles TLS termination.
|
||||
|
||||
Usage:
|
||||
python run_https.py
|
||||
|
||||
The server will listen on https://localhost:8443
|
||||
Accept the self-signed certificate warning in your browser to proceed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import ssl
|
||||
import sys
|
||||
from waitress.server import create_server
|
||||
from app.app import create_app
|
||||
|
||||
CERT_FILE = 'certs/localhost.pem'
|
||||
KEY_FILE = 'certs/localhost-key.pem'
|
||||
|
||||
|
||||
def generate_self_signed_cert():
|
||||
"""Generate a self-signed certificate for local HTTPS testing.
|
||||
|
||||
Uses OpenSSL to create a key and certificate valid for 365 days.
|
||||
Skips generation if certificate files already exist.
|
||||
"""
|
||||
if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
|
||||
print('[OK] Self-signed certificate already exists.')
|
||||
return
|
||||
|
||||
os.makedirs('certs', exist_ok=True)
|
||||
|
||||
print('[INFO] Generating self-signed certificate for localhost...')
|
||||
try:
|
||||
subprocess.run([
|
||||
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||
'-keyout', KEY_FILE,
|
||||
'-out', CERT_FILE,
|
||||
'-days', '365',
|
||||
'-nodes',
|
||||
'-subj', '/CN=localhost'
|
||||
], check=True, capture_output=True)
|
||||
print('[OK] Certificate generated: certs/localhost.pem')
|
||||
except FileNotFoundError:
|
||||
print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
|
||||
print(' winget install OpenSSL.OpenSSL')
|
||||
print(' OR download from https://slproweb.com/products/Win32OpenSSL.html')
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'[ERROR] Certificate generation failed: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the HTTPS development server."""
|
||||
port = int(os.getenv('HTTPS_PORT', 8443))
|
||||
host = os.getenv('HOST', '127.0.0.1')
|
||||
|
||||
# Ensure certificate exists
|
||||
generate_self_signed_cert()
|
||||
|
||||
# Create the Flask application
|
||||
app = create_app()
|
||||
|
||||
# Create SSL context
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(CERT_FILE, KEY_FILE)
|
||||
|
||||
# Create a TCP socket, wrap it with TLS, then pass to Waitress
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((host, port))
|
||||
sock.listen(5)
|
||||
|
||||
# Wrap the socket with TLS
|
||||
ssl_sock = context.wrap_socket(sock, server_side=True)
|
||||
|
||||
# =====================================================================
|
||||
# WSGI Middleware: Tell Flask the connection is HTTPS
|
||||
#
|
||||
# Waitress doesn't know the underlying socket is TLS, so Flask sees
|
||||
# wsgi.url_scheme = "http". Without this middleware, force_https()
|
||||
# would cause an infinite redirect loop (ERR_TOO_MANY_REDIRECTS).
|
||||
# =====================================================================
|
||||
class ForceHTTPSMiddleware:
|
||||
"""WSGI middleware that sets url_scheme to 'https'.
|
||||
|
||||
Since we're wrapping the TCP socket with SSL before passing it
|
||||
to Waitress, Flask's request.is_secure returns False because
|
||||
Waitress reports wsgi.url_scheme='http'. This middleware fixes
|
||||
that so Flask correctly identifies the connection as HTTPS.
|
||||
"""
|
||||
|
||||
def __init__(self, wsgi_app):
|
||||
self.wsgi_app = wsgi_app
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
environ['wsgi.url_scheme'] = 'https'
|
||||
environ['HTTPS'] = 'on'
|
||||
return self.wsgi_app(environ, start_response)
|
||||
|
||||
# Wrap the Flask app with the HTTPS middleware
|
||||
app.wsgi_app = ForceHTTPSMiddleware(app.wsgi_app)
|
||||
|
||||
print(f'\n╔══════════════════════════════════════════════════════╗')
|
||||
print(f'║ TEAM TRYOUTS - Development HTTPS Server ║')
|
||||
print(f'╠══════════════════════════════════════════════════════╣')
|
||||
print(f'║ URL: https://{host}:{port} ║')
|
||||
print(f'║ Cert: self-signed (accept browser warning) ║')
|
||||
print(f'║ Press Ctrl+C to stop ║')
|
||||
print(f'╚══════════════════════════════════════════════════════╝\n')
|
||||
|
||||
# Create Waitress server with the SSL-wrapped socket
|
||||
server = create_server(
|
||||
app,
|
||||
sockets=[ssl_sock],
|
||||
threads=4,
|
||||
channel_timeout=30,
|
||||
)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Security validation script for the Team Tryouts application.
|
||||
|
||||
This script performs pre-deployment security checks to validate:
|
||||
- HTTP security headers
|
||||
- Cookie security attributes
|
||||
- Debug mode status
|
||||
- HTTPS configuration
|
||||
- Dependency vulnerabilities
|
||||
- Database connectivity
|
||||
|
||||
Usage:
|
||||
python security_scan.py [--url http://localhost:5000]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def check_environment():
|
||||
"""Check required environment variables are set.
|
||||
|
||||
Returns:
|
||||
bool: True if all critical variables are set.
|
||||
"""
|
||||
print('=' * 60)
|
||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
critical_vars = ['SECRET_KEY']
|
||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
||||
all_ok = True
|
||||
|
||||
for var in critical_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
# Check SECRET_KEY is not a default/weak value
|
||||
if var == 'SECRET_KEY' and len(value) < 32:
|
||||
print(f'[WARN] {var} is set but too short (less than 32 chars)')
|
||||
all_ok = False
|
||||
else:
|
||||
print(f'[OK] {var} is set')
|
||||
else:
|
||||
print(f'[FAIL] {var} is not set!')
|
||||
all_ok = False
|
||||
|
||||
for var in recommended_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
print(f'[OK] {var} is set')
|
||||
else:
|
||||
print(f'[INFO] {var} is not set (using default)')
|
||||
|
||||
# Check FLASK_DEBUG
|
||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||
if debug == 'true':
|
||||
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
|
||||
else:
|
||||
print('[OK] FLASK_DEBUG is disabled')
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_https_headers(url):
|
||||
"""Check HTTP security headers from a running application.
|
||||
|
||||
Args:
|
||||
url: The base URL of the application to check.
|
||||
|
||||
Returns:
|
||||
bool: True if all critical headers are present.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('2. HTTP SECURITY HEADERS CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
required_headers = {
|
||||
'Strict-Transport-Security': 'HSTS enabled',
|
||||
'X-Content-Type-Options': 'Prevents MIME sniffing',
|
||||
'X-Frame-Options': 'Prevents clickjacking',
|
||||
'Content-Security-Policy': 'CSP configured',
|
||||
'Referrer-Policy': 'Referrer control',
|
||||
'Permissions-Policy': 'Permissions control',
|
||||
'Cross-Origin-Opener-Policy': 'Cross-origin isolation',
|
||||
}
|
||||
|
||||
all_ok = True
|
||||
|
||||
try:
|
||||
# Create a context that doesn't verify SSL (for local testing)
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
|
||||
headers = response.headers
|
||||
status = response.status
|
||||
|
||||
print(f'[INFO] Response status: {status}')
|
||||
|
||||
for header, description in required_headers.items():
|
||||
if header in headers:
|
||||
print(f'[OK] {header}: {description}')
|
||||
else:
|
||||
print(f'[FAIL] {header} is missing: {description}')
|
||||
all_ok = False
|
||||
|
||||
# Check cookie attributes if any set-cookie headers exist
|
||||
if 'Set-Cookie' in headers:
|
||||
cookie = headers['Set-Cookie']
|
||||
if 'Secure' in cookie:
|
||||
print('[OK] Cookies have Secure flag')
|
||||
else:
|
||||
print('[WARN] Cookies missing Secure flag')
|
||||
all_ok = False
|
||||
|
||||
if 'HttpOnly' in cookie:
|
||||
print('[OK] Cookies have HttpOnly flag')
|
||||
else:
|
||||
print('[WARN] Cookies missing HttpOnly flag')
|
||||
all_ok = False
|
||||
|
||||
if 'SameSite' in cookie:
|
||||
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}')
|
||||
else:
|
||||
print('[WARN] Cookies missing SameSite attribute')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[INFO] No Set-Cookie headers in response')
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f'[INFO] Got HTTP {e.code} (may need authentication)')
|
||||
# Still check headers even on error responses
|
||||
for header, description in required_headers.items():
|
||||
if header in e.headers:
|
||||
print(f'[OK] {header}: {description}')
|
||||
else:
|
||||
print(f'[FAIL] {header} is missing: {description}')
|
||||
all_ok = False
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
||||
print('[SKIP] Run with --url <application_url> to check headers')
|
||||
return True # Not a failure, just can't check
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Run pip-audit to check for known vulnerabilities.
|
||||
|
||||
Returns:
|
||||
bool: True if no critical vulnerabilities found.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('3. DEPENDENCY VULNERABILITY SCAN')
|
||||
print('=' * 60)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print('[OK] No known vulnerabilities found')
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
vulns = data.get('dependencies', [])
|
||||
if vulns:
|
||||
for vuln in vulns:
|
||||
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}')
|
||||
return False
|
||||
else:
|
||||
print('[OK] No vulnerabilities found')
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
if result.stdout:
|
||||
print(f'[INFO] {result.stdout.strip()}')
|
||||
if result.stderr:
|
||||
print(f'[WARN] {result.stderr.strip()}')
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[WARN] pip-audit timed out')
|
||||
return True
|
||||
|
||||
|
||||
def check_file_permissions():
|
||||
"""Check for common security issues in the project structure.
|
||||
|
||||
Returns:
|
||||
bool: True if no critical issues found.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('4. PROJECT FILES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
all_ok = True
|
||||
|
||||
# Check .gitignore exists and contains important patterns
|
||||
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
|
||||
if os.path.exists(gitignore_path):
|
||||
required_patterns = ['.env', 'instance/', '*.db', '*.log']
|
||||
with open(gitignore_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
for pattern in required_patterns:
|
||||
if pattern in content:
|
||||
print(f'[OK] .gitignore contains: {pattern}')
|
||||
else:
|
||||
print(f'[WARN] .gitignore missing: {pattern}')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[FAIL] .gitignore file not found!')
|
||||
all_ok = False
|
||||
|
||||
# Check for .env in working directory (should NOT be committed)
|
||||
env_path = os.path.join(os.getcwd(), '.env')
|
||||
if os.path.exists(env_path):
|
||||
print('[INFO] .env file exists (ensure it is NOT committed)')
|
||||
else:
|
||||
print('[WARN] No .env file found')
|
||||
|
||||
# Check for leftover .pyc or __pycache__
|
||||
pycache_count = 0
|
||||
for root, dirs, files in os.walk(os.getcwd()):
|
||||
if '__pycache__' in dirs:
|
||||
pycache_count += 1
|
||||
for f in files:
|
||||
if f.endswith('.pyc'):
|
||||
pycache_count += 1
|
||||
if pycache_count == 0:
|
||||
print('[OK] No __pycache__ or .pyc files found')
|
||||
else:
|
||||
print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)')
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_flask_config():
|
||||
"""Check Flask application configuration for security.
|
||||
|
||||
Returns:
|
||||
bool: True if configuration looks secure.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('5. FLASK CONFIGURATION CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
all_ok = True
|
||||
|
||||
try:
|
||||
from app.app import create_app
|
||||
app = create_app()
|
||||
|
||||
# Check session cookie settings
|
||||
cookie_checks = [
|
||||
('SESSION_COOKIE_SECURE', True, 'Secure cookies'),
|
||||
('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'),
|
||||
('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'),
|
||||
]
|
||||
|
||||
for config_key, expected, description in cookie_checks:
|
||||
value = app.config.get(config_key)
|
||||
if config_key == 'PERMANENT_SESSION_LIFETIME':
|
||||
if value and value <= 3600:
|
||||
print(f'[OK] {description}: {value}s')
|
||||
else:
|
||||
print(f'[WARN] {description}: {value}s (should be <= 1 hour)')
|
||||
all_ok = False
|
||||
elif value == expected:
|
||||
print(f'[OK] {description}: enabled')
|
||||
else:
|
||||
print(f'[FAIL] {description}: {value}')
|
||||
all_ok = False
|
||||
|
||||
# Check MAX_CONTENT_LENGTH
|
||||
max_content = app.config.get('MAX_CONTENT_LENGTH')
|
||||
if max_content:
|
||||
mb = max_content / (1024 * 1024)
|
||||
print(f'[OK] MAX_CONTENT_LENGTH: {mb}MB')
|
||||
else:
|
||||
print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)')
|
||||
all_ok = False
|
||||
|
||||
# Check CSRF
|
||||
csrf_enabled = app.config.get('WTF_CSRF_ENABLED')
|
||||
if csrf_enabled:
|
||||
print('[OK] CSRF protection: enabled')
|
||||
else:
|
||||
print('[FAIL] CSRF protection: disabled')
|
||||
all_ok = False
|
||||
|
||||
# Check if app is in DEBUG mode
|
||||
if app.debug:
|
||||
print('[FAIL] DEBUG mode is enabled!')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[OK] DEBUG mode: disabled')
|
||||
|
||||
except Exception as e:
|
||||
print(f'[SKIP] Cannot check Flask config: {e}')
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all security checks and produce a summary report.
|
||||
|
||||
Returns:
|
||||
int: 0 if all checks pass, 1 if any fail.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Security validation scanner')
|
||||
parser.add_argument('--url', default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)')
|
||||
args = parser.parse_args()
|
||||
|
||||
print('╔══════════════════════════════════════════════════════════╗')
|
||||
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║')
|
||||
print('╠══════════════════════════════════════════════════════════╣')
|
||||
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print('╚══════════════════════════════════════════════════════════╝')
|
||||
|
||||
checks = [
|
||||
check_environment,
|
||||
lambda: check_https_headers(args.url),
|
||||
check_dependencies,
|
||||
check_file_permissions,
|
||||
check_flask_config,
|
||||
]
|
||||
|
||||
results = []
|
||||
for check in checks:
|
||||
results.append(check())
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('SUMMARY')
|
||||
print('=' * 60)
|
||||
|
||||
passed = sum(1 for r in results if r)
|
||||
failed = sum(1 for r in results if not r)
|
||||
total = len(results)
|
||||
|
||||
print(f'Passed: {passed}/{total}')
|
||||
print(f'Failed: {failed}/{total}')
|
||||
|
||||
if failed == 0:
|
||||
print('\n[OK] All security checks passed!')
|
||||
return 0
|
||||
else:
|
||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Database seeding script for Team Tryouts application.
|
||||
|
||||
Creates sample data using the polymorphic User subclasses:
|
||||
Admin, Manager, Coach, Player, Scout.
|
||||
"""
|
||||
|
||||
from app.extensions import db, hash_password
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
|
||||
OrgTeam, TeamPlayer,
|
||||
PlayerDisponibility, CoachAvailability,
|
||||
UserGamertag, TeamNote, PersonalNote,
|
||||
Match, MatchParticipant,
|
||||
BaseAvailability, BaseMatch, BaseParticipant,
|
||||
)
|
||||
from datetime import datetime, timedelta, time
|
||||
import random
|
||||
|
||||
|
||||
def seed_database():
|
||||
"""Seed the database with sample data for development and testing."""
|
||||
|
||||
# Clear existing data (order matters for FK constraints)
|
||||
for table in ['player_disponibilities', 'coach_availabilities',
|
||||
'match_participants', 'team_match_participants',
|
||||
'matches', 'team_matches',
|
||||
'team_players', 'team_members', 'teams',
|
||||
'evaluations', 'tryout_registrations', 'tryouts',
|
||||
'org_team_coaches', 'org_team_managers',
|
||||
'org_teams', 'user_gamertags',
|
||||
'personal_notes', 'team_notes',
|
||||
'one_on_one_requests',
|
||||
'users']:
|
||||
db.session.execute(db.text(f'DELETE FROM {table}'))
|
||||
db.session.commit()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Staff users (Admin, Manager, Coach, Scout)
|
||||
# -----------------------------------------------------------------------
|
||||
admin = Admin(
|
||||
username='admin', password_hash=hash_password('password'),
|
||||
role='admin', full_name='Sarah Johnson',
|
||||
email='[email protected]', phone='555-0101')
|
||||
db.session.add(admin)
|
||||
|
||||
manager1 = Manager(
|
||||
username='manager1', password_hash=hash_password('password'),
|
||||
role='manager', full_name='Mike Williams',
|
||||
email='[email protected]', phone='555-0102')
|
||||
db.session.add(manager1)
|
||||
|
||||
manager2 = Manager(
|
||||
username='manager2', password_hash=hash_password('password'),
|
||||
role='manager', full_name='Emily Davis',
|
||||
email='[email protected]', phone='555-0103')
|
||||
db.session.add(manager2)
|
||||
|
||||
coach1 = Coach(
|
||||
username='coach1', password_hash=hash_password('password'),
|
||||
role='coach', full_name='Coach Thompson',
|
||||
email='[email protected]', phone='555-0104',
|
||||
discord_user_id='484107446298738689')
|
||||
db.session.add(coach1)
|
||||
|
||||
coach2 = Coach(
|
||||
username='coach2', password_hash=hash_password('password'),
|
||||
role='coach', full_name='Coach Martinez',
|
||||
email='[email protected]', phone='555-0105')
|
||||
db.session.add(coach2)
|
||||
|
||||
coach3 = Coach(
|
||||
username='coach3', password_hash=hash_password('password'),
|
||||
role='coach', full_name='Coach Anderson',
|
||||
email='[email protected]', phone='555-0106')
|
||||
db.session.add(coach3)
|
||||
|
||||
scout = Scout(
|
||||
username='scout1', password_hash=hash_password('password'),
|
||||
role='scout', full_name='Alex Rivera',
|
||||
email='[email protected]', phone='555-0107')
|
||||
db.session.add(scout)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Players
|
||||
# -----------------------------------------------------------------------
|
||||
player_data = [
|
||||
{'username': 'jplayer1', 'full_name': 'nordjan', 'email': '[email protected]',
|
||||
'games': 'Valorant, Counter-Strike 2, Rainbow Six Siege, Rocket League, Overwatch 2',
|
||||
'discord_username': 'nordjan', 'discord_user_id': '484107446298738689',
|
||||
'league_os_profile': 'https://leagueos.gg/player/nordjan'},
|
||||
{'username': 'jplayer2', 'full_name': 'Emma Garcia', 'email': '[email protected]',
|
||||
'games': 'League of Legends,Valorant',
|
||||
'discord_username': 'EmmaG#4452', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/emmagarcia'},
|
||||
{'username': 'jplayer3', 'full_name': 'Liam Brown', 'email': '[email protected]',
|
||||
'games': 'Apex Legends,Fortnite',
|
||||
'discord_username': 'LiamB#8103', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/liambrown'},
|
||||
{'username': 'jplayer4', 'full_name': 'Sophia Lee', 'email': '[email protected]',
|
||||
'games': 'Overwatch 2,Valorant',
|
||||
'discord_username': 'SophiaL#3327', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/sophialee'},
|
||||
{'username': 'jplayer5', 'full_name': 'Noah Taylor', 'email': '[email protected]',
|
||||
'games': 'Counter-Strike 2,Rainbow Six Siege',
|
||||
'discord_username': 'NoahT#6614', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/noahtaylor'},
|
||||
{'username': 'jplayer6', 'full_name': 'Olivia Martin', 'email': '[email protected]',
|
||||
'games': 'Rocket League,Fortnite',
|
||||
'discord_username': 'OliviaM#2298', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/oliviamartin'},
|
||||
{'username': 'jplayer7', 'full_name': 'Ethan Clark', 'email': '[email protected]',
|
||||
'games': 'Valorant,Apex Legends',
|
||||
'discord_username': 'EthanC#7743', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/ethanclark'},
|
||||
{'username': 'jplayer8', 'full_name': 'Ava White', 'email': '[email protected]',
|
||||
'games': 'League of Legends,Counter-Strike 2',
|
||||
'discord_username': 'AvaW#5561', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/avawhite'},
|
||||
{'username': 'jplayer9', 'full_name': 'Mason Hall', 'email': '[email protected]',
|
||||
'games': 'Call of Duty,Rocket League',
|
||||
'discord_username': 'MasonH#1189', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/masonhall'},
|
||||
{'username': 'jplayer10', 'full_name': 'Isabella Adams', 'email': '[email protected]',
|
||||
'games': 'Overwatch 2,Dota 2',
|
||||
'discord_username': 'IsabellaA#4437', 'discord_user_id': '',
|
||||
'league_os_profile': 'https://leagueos.gg/player/isabellaadams'},
|
||||
]
|
||||
|
||||
players = []
|
||||
for i, data in enumerate(player_data, start=10):
|
||||
p = Player(
|
||||
username=data['username'], password_hash=hash_password('password'),
|
||||
role='player', full_name=data['full_name'],
|
||||
email=data['email'], phone=f'555-01{i:02d}',
|
||||
games=data['games'],
|
||||
discord_username=data['discord_username'],
|
||||
discord_user_id=data['discord_user_id'],
|
||||
league_os_profile=data['league_os_profile'])
|
||||
db.session.add(p)
|
||||
players.append(p)
|
||||
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {7 + 10} users (7 staff + 10 players)")
|
||||
|
||||
# Map for easy reference
|
||||
coaches = [coach1, coach2, coach3]
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Gamertags
|
||||
# -----------------------------------------------------------------------
|
||||
gt_map = [
|
||||
(players[0], 'Valorant', 'nordjan#bad', None),
|
||||
(players[0], 'Counter-Strike 2', 'nordjan', None),
|
||||
(players[0], 'Rainbow Six Siege', 'nordjan', 'Ubisoft'),
|
||||
(players[0], 'Rocket League', 'nordjiano', 'Epic'),
|
||||
(players[0], 'Overwatch 2', 'nordjan', 'PC'),
|
||||
(players[1], 'League of Legends', 'emmagarcia_lol', None),
|
||||
(players[1], 'Valorant', 'emmagarcia_val', None),
|
||||
(players[2], 'Apex Legends', 'liambrown_apex', 'PC'),
|
||||
(players[2], 'Fortnite', 'liambrown_fn', 'PC'),
|
||||
(players[3], 'Overwatch 2', 'sophialee_ow', 'PC'),
|
||||
(players[3], 'Valorant', 'sophialee_val', None),
|
||||
(players[4], 'Counter-Strike 2', 'noahtaylor_cs', None),
|
||||
(players[4], 'Rainbow Six Siege', 'noahtaylor_r6', 'Ubisoft'),
|
||||
(players[5], 'Rocket League', 'oliviamartin_rl', 'Epic'),
|
||||
(players[5], 'Fortnite', 'oliviamartin_fn', 'PC'),
|
||||
(players[6], 'Valorant', 'ethanclark_val', None),
|
||||
(players[6], 'Apex Legends', 'ethanclark_apex', 'PC'),
|
||||
(players[7], 'League of Legends', 'avawhite_lol', None),
|
||||
(players[7], 'Counter-Strike 2', 'avawhite_cs', None),
|
||||
(players[8], 'Call of Duty', 'masonhall_cod', 'PC'),
|
||||
(players[8], 'Rocket League', 'masonhall_rl', 'Epic'),
|
||||
(players[9], 'Overwatch 2', 'isabellaadams_ow', 'PC'),
|
||||
(players[9], 'Dota 2', 'isabellaadams_dota', None),
|
||||
]
|
||||
for p, game, tag, platform in gt_map:
|
||||
db.session.add(UserGamertag(user_id=p.id, game=game, gamertag=tag, platform=platform))
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(gt_map)} gamertags")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Organisation Teams
|
||||
# -----------------------------------------------------------------------
|
||||
org_teams_data = [
|
||||
{'name': 'Rocket League main', 'coach': coaches[0], 'creator': admin},
|
||||
{'name': 'CS2', 'coach': coaches[1], 'creator': admin},
|
||||
{'name': 'Valorant', 'coach': coaches[2], 'creator': admin},
|
||||
{'name': 'Rocket League acad', 'coach': None, 'creator': manager1},
|
||||
]
|
||||
|
||||
org_teams = []
|
||||
for data in org_teams_data:
|
||||
ot = OrgTeam(
|
||||
name=data['name'],
|
||||
coach_id=data['coach'].id if data['coach'] else None,
|
||||
created_by=data['creator'].id)
|
||||
db.session.add(ot)
|
||||
org_teams.append(ot)
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(org_teams)} organisation teams")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Tryouts
|
||||
# -----------------------------------------------------------------------
|
||||
tryouts_data = [
|
||||
{'title': 'Rocket Leauge Tryouts', 'game': 'Rocket League',
|
||||
'date': datetime.utcnow(), 'location': 'En ligne',
|
||||
'description': 'Tryouts for the spring competitive season. All positions welcome.',
|
||||
'status': 'in_progress', 'creator': manager1, 'target_team': org_teams[0]},
|
||||
{'title': 'CS2 Tryouts', 'game': 'Counter-Strike 2',
|
||||
'date': datetime.utcnow() + timedelta(days=4), 'location': 'En ligne',
|
||||
'description': 'Trials for the fall select team. High skill level required.',
|
||||
'status': 'upcoming', 'creator': manager1, 'target_team': org_teams[3]},
|
||||
{'title': 'Valorant Tryouts', 'game': 'Valorant',
|
||||
'date': datetime.utcnow() - timedelta(days=2), 'location': 'En ligne',
|
||||
'description': "Séléction pour l'équipe de Valorant",
|
||||
'status': 'in_progress', 'creator': manager2, 'target_team': org_teams[2]},
|
||||
]
|
||||
|
||||
tryouts = []
|
||||
for data in tryouts_data:
|
||||
t = Tryout(
|
||||
title=data['title'], game=data['game'],
|
||||
date=data['date'].date(),
|
||||
location=data['location'],
|
||||
description=data['description'],
|
||||
status=data['status'], max_players=15,
|
||||
created_by=data['creator'].id,
|
||||
target_org_team_id=data['target_team'].id if data['target_team'] else None)
|
||||
db.session.add(t)
|
||||
tryouts.append(t)
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(tryouts)} tryouts")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Registrations
|
||||
# -----------------------------------------------------------------------
|
||||
reg_assignments = [
|
||||
(tryouts[0], players[:3]),
|
||||
(tryouts[1], players[3:5]),
|
||||
(tryouts[2], players),
|
||||
]
|
||||
regs = []
|
||||
for tryout, plist in reg_assignments:
|
||||
for player in plist:
|
||||
reg = TryoutRegistration(
|
||||
tryout_id=tryout.id, player_id=player.id,
|
||||
status=random.choice(['registered', 'attended', 'attended', 'no_show']))
|
||||
db.session.add(reg)
|
||||
regs.append(reg)
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(regs)} registrations")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Evaluations
|
||||
# -----------------------------------------------------------------------
|
||||
eval_count = 0
|
||||
rl_positions = ['None needed', 'None', 'N/A']
|
||||
for player in players[:8]:
|
||||
for coach in coaches:
|
||||
if random.random() > 0.3:
|
||||
ms = [random.randint(4, 10) for _ in range(9)]
|
||||
overall = round(sum(ms) / 9, 1)
|
||||
db.session.add(Evaluation(
|
||||
tryout_id=tryouts[0].id, player_id=player.id,
|
||||
evaluator_id=coach.id,
|
||||
mecanics_score=ms[0], cohesion_score=ms[1],
|
||||
communication_score=ms[2], gamesense_score=ms[3],
|
||||
versatility_score=ms[4], discipline_score=ms[5],
|
||||
analysis_score=ms[6], sport_ethics_score=ms[7],
|
||||
mental_score=ms[8], overall_score=overall,
|
||||
comments=f"{'Great' if overall > 7 else 'Good'} performance. {'Shows promise.' if overall > 6 else 'Needs improvement in some areas.'}",
|
||||
position_recommendation=random.choice(rl_positions)))
|
||||
eval_count += 1
|
||||
|
||||
val_positions = ['Controller', 'Initiator', 'Duelist', 'Sentinel']
|
||||
for player in players[:6]:
|
||||
for coach in coaches[:2]:
|
||||
ms = [random.randint(3, 10) for _ in range(9)]
|
||||
overall = round(sum(ms) / 9, 1)
|
||||
db.session.add(Evaluation(
|
||||
tryout_id=tryouts[2].id, player_id=player.id,
|
||||
evaluator_id=coach.id,
|
||||
mecanics_score=ms[0], cohesion_score=ms[1],
|
||||
communication_score=ms[2], gamesense_score=ms[3],
|
||||
versatility_score=ms[4], discipline_score=ms[5],
|
||||
analysis_score=ms[6], sport_ethics_score=ms[7],
|
||||
mental_score=ms[8], overall_score=overall,
|
||||
comments=f"{'Excellent' if overall > 8 else 'Solid'} display of skills during the camp.",
|
||||
position_recommendation=random.choice(val_positions)))
|
||||
eval_count += 1
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {eval_count} evaluations")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Tryout-specific teams
|
||||
# -----------------------------------------------------------------------
|
||||
team1 = Team(tryout_id=tryouts[2].id, name='Alpha Team', created_by=admin.id)
|
||||
team2 = Team(tryout_id=tryouts[2].id, name='Bravo Team', created_by=admin.id)
|
||||
db.session.add(team1)
|
||||
db.session.add(team2)
|
||||
db.session.commit()
|
||||
|
||||
val_positions = ['Controller', 'Initiator', 'Duelist', 'Sentinel']
|
||||
team_members_tuples = [
|
||||
(team1, players[0]), (team1, players[1]),
|
||||
(team1, players[2]), (team1, players[3]),
|
||||
(team2, players[4]), (team2, players[5]),
|
||||
]
|
||||
for t, p in team_members_tuples:
|
||||
db.session.add(TeamMember(team_id=t.id, player_id=p.id,
|
||||
position=random.choice(val_positions)))
|
||||
db.session.commit()
|
||||
print("[OK] Created 2 tryout-specific teams with player assignments")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Org-team player assignments
|
||||
# -----------------------------------------------------------------------
|
||||
assignments = [
|
||||
(org_teams[0], players[0], 'starter'),
|
||||
(org_teams[0], players[1], 'starter'),
|
||||
(org_teams[0], players[2], 'substitute'),
|
||||
(org_teams[1], players[3], 'starter'),
|
||||
(org_teams[1], players[4], 'starter'),
|
||||
(org_teams[1], players[5], 'substitute'),
|
||||
(org_teams[2], players[6], 'starter'),
|
||||
(org_teams[2], players[7], 'substitute'),
|
||||
]
|
||||
for team, player, status in assignments:
|
||||
db.session.add(TeamPlayer(player_id=player.id, org_team_id=team.id, status=status))
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(assignments)} org-team player assignments")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Disponibilities
|
||||
# -----------------------------------------------------------------------
|
||||
time_slots = [(17, 0), (17, 30), (18, 0), (18, 30), (19, 0), (19, 30),
|
||||
(20, 0), (20, 30), (21, 0), (21, 30), (22, 0), (22, 30), (23, 0)]
|
||||
disp_count = 0
|
||||
for player in players:
|
||||
for day in range(7):
|
||||
num = random.randint(3, 8)
|
||||
for hour, minute in random.sample(time_slots, min(num, len(time_slots))):
|
||||
end_h, end_m = hour, minute + 30
|
||||
if end_m >= 60:
|
||||
end_m -= 60; end_h += 1
|
||||
db.session.add(PlayerDisponibility(
|
||||
player_id=player.id, day_of_week=day,
|
||||
start_time=time(hour, minute), end_time=time(end_h, end_m)))
|
||||
disp_count += 1
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {disp_count} player disponibilities")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Coach availabilities
|
||||
# -----------------------------------------------------------------------
|
||||
coach_slots = [(16, 0), (16, 30), (17, 0), (17, 30), (18, 0), (18, 30),
|
||||
(19, 0), (19, 30), (20, 0), (20, 30), (21, 0), (21, 30)]
|
||||
ca_count = 0
|
||||
for coach, _ in zip(coaches[:3], org_teams[:3]):
|
||||
days = [0, 1, 2, 3, 4] if coach.id == coaches[2].id else [0, 1, 2, 3, 4, 5]
|
||||
for day in days:
|
||||
num = random.randint(3, 5)
|
||||
for hour, minute in random.sample(coach_slots, min(num, len(coach_slots))):
|
||||
end_h, end_m = hour, minute + 30
|
||||
if end_m >= 60:
|
||||
end_m -= 60; end_h += 1
|
||||
db.session.add(CoachAvailability(
|
||||
coach_id=coach.id, day_of_week=day,
|
||||
start_time=time(hour, minute), end_time=time(end_h, end_m)))
|
||||
ca_count += 1
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {ca_count} coach availabilities")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Team notes
|
||||
# -----------------------------------------------------------------------
|
||||
notes = [
|
||||
(org_teams[0], coaches[0],
|
||||
'Team, focus on rotation and positioning during scrims. '
|
||||
'We need to improve our mechanical consistency and work on post-platoon transitions. '
|
||||
'Remember to communicate clearly and stay positive!'),
|
||||
(org_teams[1], coaches[1],
|
||||
'Great progress this week! Keep working on your smoke lineups and utility usage. '
|
||||
'Individual practice on aim trainers is paying off. Next week we focus on map control and trading.'),
|
||||
(org_teams[2], coaches[2],
|
||||
'Agent comp needs work. Make sure to stick to your roles and trust your teammates. '
|
||||
'Work on your crosshair placement and pre-aim common angles. Team chemistry is key!'),
|
||||
]
|
||||
for team, coach, content in notes:
|
||||
db.session.add(TeamNote(org_team_id=team.id, coach_id=coach.id, content=content))
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(notes)} team notes")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Personal notes
|
||||
# -----------------------------------------------------------------------
|
||||
pnotes = [
|
||||
(players[0], coaches[0], 'Your mechanics are improving! Focus on staying calm during high-pressure situations. Keep practicing those flip resets.'),
|
||||
(players[0], coaches[0], 'Good positioning in last scrim. Work on your kickoffs - consistency will help the team.'),
|
||||
(players[1], coaches[0], 'Your aerial game is strong. Try to be more aggressive on the ball when you have space.'),
|
||||
(players[3], coaches[1], 'Need to work on your smoke grenade placement. Practice pre-aiming and strafe stopping.'),
|
||||
(players[4], coaches[1], 'Good clutch performance! Keep your utility management consistent throughout rounds.'),
|
||||
(players[6], coaches[2], 'Your aim trainer routine is paying off. Work on your agent abilities usage timing.'),
|
||||
(players[7], coaches[2], 'Focus on communication in matches. Call out enemy positions clearly and ask for help when needed.'),
|
||||
]
|
||||
for player, coach, content in pnotes:
|
||||
db.session.add(PersonalNote(player_id=player.id, coach_id=coach.id, content=content))
|
||||
db.session.commit()
|
||||
print(f"[OK] Created {len(pnotes)} personal notes")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Matches
|
||||
# -----------------------------------------------------------------------
|
||||
team1 = Team.query.filter_by(name='Alpha Team').first()
|
||||
team2 = Team.query.filter_by(name='Bravo Team').first()
|
||||
|
||||
m1 = Match(tryout_id=tryouts[0].id, title='Alpha vs Bravo',
|
||||
date=tryouts[0].date, start_time=time(18, 0), end_time=time(18, 30),
|
||||
match_type='team_vs_team', created_by=admin.id,
|
||||
team1_id=team1.id if team1 else None, team2_id=team2.id if team2 else None)
|
||||
m2 = Match(tryout_id=tryouts[0].id, title='Bravo vs Alpha',
|
||||
date=tryouts[0].date, start_time=time(19, 0), end_time=time(19, 30),
|
||||
match_type='team_vs_team', created_by=admin.id,
|
||||
team1_id=team2.id if team2 else None, team2_id=team1.id if team1 else None)
|
||||
m3 = Match(tryout_id=tryouts[1].id, title='Scrimmage',
|
||||
date=tryouts[1].date, start_time=time(17, 0), end_time=time(17, 30),
|
||||
match_type='player_scrim', created_by=admin.id)
|
||||
m4 = Match(tryout_id=tryouts[2].id, title='Team Alpha Scrim',
|
||||
date=tryouts[2].date, start_time=time(18, 30), end_time=time(19, 0),
|
||||
match_type='player_vs_player', created_by=admin.id)
|
||||
db.session.add_all([m1, m2, m3, m4])
|
||||
db.session.commit()
|
||||
print("[OK] Created 4 matches")
|
||||
|
||||
# Match participants
|
||||
for player in players[3:5]:
|
||||
db.session.add(MatchParticipant(match_id=m3.id, player_id=player.id))
|
||||
for player in players[:2]:
|
||||
db.session.add(MatchParticipant(match_id=m4.id, player_id=player.id, team_side=1))
|
||||
for player in players[2:4]:
|
||||
db.session.add(MatchParticipant(match_id=m4.id, player_id=player.id, team_side=2))
|
||||
db.session.commit()
|
||||
print("[OK] Created match participants")
|
||||
|
||||
print("\n[SUCCESS] Database seeded successfully!")
|
||||
print("\n=== Login Credentials ===")
|
||||
print("Admin: username='admin', password='password'")
|
||||
print("Manager: username='manager1', password='password'")
|
||||
print("Coach: username='coach1', password='password'")
|
||||
print("Player: username='jplayer1', password='password'")
|
||||
print("Scout: username='scout1', password='password'")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from app.app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
seed_database()
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}400 Bad Request - TryoutPro{% endblock %}
|
||||
{% block page_title %}Bad Request{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h2>400 — Bad Request</h2>
|
||||
<p>The request could not be understood by the server. Please check your input and try again.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}403 Forbidden - TryoutPro{% endblock %}
|
||||
{% block page_title %}Access Denied{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h2>403 — Forbidden</h2>
|
||||
<p>You do not have permission to access this resource. If you believe this is an error, please contact an administrator.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}404 Not Found - TryoutPro{% endblock %}
|
||||
{% block page_title %}Page Not Found{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h2>404 — Not Found</h2>
|
||||
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i> Return Home
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}429 Too Many Requests - TryoutPro{% endblock %}
|
||||
{% block page_title %}Rate Limit Exceeded{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<h2>429 — Too Many Requests</h2>
|
||||
<p>You have sent too many requests in a short period. Please wait a moment and try again.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}500 Server Error - TryoutPro{% endblock %}
|
||||
{% block page_title %}Internal Server Error{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-cogs"></i>
|
||||
</div>
|
||||
<h2>500 — Internal Server Error</h2>
|
||||
<p>Something went wrong on our end. The error has been logged and will be investigated. Please try again later.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-redo-alt"></i> Try Again
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Team Tryout Management{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<i class="fas fa-trophy"></i>
|
||||
<span>TryoutPro</span>
|
||||
</div>
|
||||
<div class="user-badge">
|
||||
<div class="user-avatar">
|
||||
{{ current_user.username[:2] | upper }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ current_user.username }}</span>
|
||||
<span class="user-role role-{{ current_user.role }}">{{ current_user.role | capitalize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-th-large"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
<span>Tryouts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<li>
|
||||
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
<span>Evaluations</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' %}
|
||||
<li>
|
||||
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>My Team(s)</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Manage Teams</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.can_manage_users() %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Manage Users</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span>My Notes</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'coach' %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.manage_coach_availability') }}" class="{% if request.endpoint == 'users.manage_coach_availability' %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Availability</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span>Notes & One on One</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
||||
<i class="fas fa-file-contract"></i>
|
||||
<span>Contracts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-divider"></li>
|
||||
<li>
|
||||
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>My Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('auth.logout') }}" class="logout-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="main-content" id="mainContent">
|
||||
<header class="top-bar">
|
||||
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="page-header">
|
||||
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
</div>
|
||||
<button class="dark-mode-toggle" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark mode">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
{% block header_actions %}{% endblock %}
|
||||
</header>
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="auth-wrapper">
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="auth-container">
|
||||
<div class="auth-header">
|
||||
<i class="fas fa-trophy"></i>
|
||||
<h2>TryoutPro</h2>
|
||||
<p>Team Tryout Management System</p>
|
||||
</div>
|
||||
{% block auth_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
{#
|
||||
Jinja Macros for Team Tryouts Application
|
||||
|
||||
This file contains reusable HTML components to reduce code duplication
|
||||
across templates. Import with {% import 'layouts/macros.html' as macros %}
|
||||
#}
|
||||
|
||||
{# Page Header Macro - renders title and breadcrumb #}
|
||||
{% macro page_header(title, breadcrumb) %}
|
||||
{% block title %}{{ title }} - TryoutPro{% endblock %}
|
||||
{% block page_title %}{{ title }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">{{ breadcrumb }}</span>{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{# Card Header Macro - renders card with optional actions #}
|
||||
{% macro card_header(title, icon_class, actions=None) %}
|
||||
<div class="card-header">
|
||||
<h3><i class="{{ icon_class }}"></i> {{ title }}</h3>
|
||||
{% if actions %}
|
||||
<div class="card-actions">{{ actions }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Stat Card Macro - renders statistics card #}
|
||||
{% macro stat_card(value, label, icon_class, bg_class) %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon {{ bg_class }}">
|
||||
<i class="{{ icon_class }}"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ value }}</h3>
|
||||
<p>{{ label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Badge Macro - renders status/role badges with appropriate styling #}
|
||||
{% macro badge(text, type='default') %}
|
||||
<span class="badge badge-{{ type }}">{{ text }}</span>
|
||||
{% endmacro %}
|
||||
|
||||
{# User Avatar Macro - renders user avatar with name #}
|
||||
{% macro user_avatar(user, size='sm') %}
|
||||
<div class="user-mini">
|
||||
<div class="avatar-{{ size }}">{{ user.username[:2] | upper }}</div>
|
||||
<span>{{ user.username }}</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Form Field Macro - renders labeled form input #}
|
||||
{% macro form_field(label, type, name, value='', placeholder='', required=false, extra_classes='') %}
|
||||
<div class="form-group">
|
||||
<label for="{{ name }}">{{ label }}</label>
|
||||
<input type="{{ type }}" id="{{ name }}" name="{{ name }}" value="{{ value }}" placeholder="{{ placeholder }}" {% if required %}required{% endif %} class="{{ extra_classes }}">
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Form Row Macro - renders a row of form fields #}
|
||||
{% macro form_row(fields) %}
|
||||
<div class="form-row">
|
||||
{% for field in fields %}
|
||||
<div class="form-group {{ field.col_class | default('col-6') }}">
|
||||
<label for="{{ field.name }}">{{ field.label }}</label>
|
||||
{% if field.type == 'select' %}
|
||||
<select id="{{ field.name }}" name="{{ field.name }}" {% if field.required %}required{% endif %}>
|
||||
{% for option in field.options %}
|
||||
<option value="{{ option.value }}" {% if option.selected %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="{{ field.type }}" id="{{ field.name }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}" {% if field.required %}required{% endif %}>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Table Macro - renders a table with headers and optional empty state #}
|
||||
{% macro table(headers, rows, empty_message='No records found') %}
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if rows %}
|
||||
{{ rows }}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{{ headers | length }}" class="text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<h3>{{ empty_message }}</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Modal Macro - renders a modal dialog #}
|
||||
{% macro modal(id, title, content, footer_buttons=None) %}
|
||||
<div id="{{ id }}" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideModal('{{ id }}')"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="modal-close" onclick="hideModal('{{ id }}')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ content }}
|
||||
{% if footer_buttons %}
|
||||
<div class="form-actions mt-3">{{ footer_buttons }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Detail Item Macro - renders a key-value pair in detail view #}
|
||||
{% macro detail_item(label, value) %}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ label }}</span>
|
||||
<span class="detail-value">{{ value }}</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Action Button Macro - renders a button link #}
|
||||
{% macro action_button(url, text, icon, style='outline', size='sm') %}
|
||||
<a href="{{ url }}" class="btn btn-{{ size }} btn-{{ style }}">
|
||||
{% if icon %}<i class="{{ icon }}"></i>{% endif %}
|
||||
{{ text }}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Add Personal Note - TryoutPro{% endblock %}
|
||||
{% block page_title %}Add Personal Note{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<span class="breadcrumb">
|
||||
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
|
||||
{% if context_type == 'tryout' %}
|
||||
/ <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a>
|
||||
/ Add Note
|
||||
{% elif context_type == 'match' %}
|
||||
/ <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.tryout.id) }}">{{ tryout.tryout.title }}</a>
|
||||
/ Add Note
|
||||
{% else %}
|
||||
/ Add Note
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-sticky-note"></i> Add Personal Note</h3>
|
||||
{% if context_type == 'tryout' %}
|
||||
<p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p>
|
||||
{% elif context_type == 'match' %}
|
||||
<p class="text-muted small">Context: <strong>Match - {{ match.title }}</strong></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="
|
||||
{% if context_type == 'tryout' %}
|
||||
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
||||
{% elif context_type == 'match' %}
|
||||
{{ url_for('users.add_note_from_match', match_id=match.id) }}
|
||||
{% else %}
|
||||
{{ url_for('users.add_personal_note') }}
|
||||
{% endif %}
|
||||
" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
{% if context_type == 'tryout' %}
|
||||
<input type="hidden" name="tryout_id" value="{{ tryout.id }}">
|
||||
{% elif context_type == 'match' %}
|
||||
<input type="hidden" name="match_id" value="{{ match.id }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player_id">Player</label>
|
||||
<select name="player_id" id="player_id" class="form-select" required>
|
||||
<option value="">-- Select a player --</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}>
|
||||
{{ player.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Note Content</label>
|
||||
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="Enter your coaching notes..." required></textarea>
|
||||
</div>
|
||||
|
||||
{% if context_type != 'tryout' and context_type != 'match' %}
|
||||
<div class="form-group">
|
||||
<label for="tryout_id">Link to Tryout (Optional)</label>
|
||||
<select name="tryout_id" id="tryout_id" class="form-select">
|
||||
<option value="">-- No tryout --</option>
|
||||
{% for t in tryouts %}
|
||||
<option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="match_id">Link to Match (Optional)</label>
|
||||
<select name="match_id" id="match_id" class="form-select">
|
||||
<option value="">-- No match --</option>
|
||||
{% for m in matches %}
|
||||
<option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="team_id">Link to Team (Optional)</label>
|
||||
<select name="team_id" id="team_id" class="form-select">
|
||||
<option value="">-- No team --</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if team_notes %}
|
||||
<div class="form-group">
|
||||
<label>Team Notes (Reference)</label>
|
||||
<div class="team-notes-reference">
|
||||
{% for note in team_notes %}
|
||||
<div class="note-reference-item">
|
||||
<small class="text-muted">{{ note.created_at.strftime('%Y-%m-%d') }} - {{ note.coach.username }}:</small>
|
||||
<p>{{ note.content[:200] }}{% if note.content|length > 200 %}...{% endif %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="
|
||||
{% if context_type == 'tryout' %}
|
||||
{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}
|
||||
{% elif context_type == 'match' %}
|
||||
{{ url_for('tryouts.view_tryout', tryout_id=tryout.tryout.id) }}
|
||||
{% else %}
|
||||
{{ url_for('users.notes_dashboard') }}
|
||||
{% endif %}
|
||||
" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Add Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.team-notes-reference {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.note-reference-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.note-reference-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.note-reference-item p {
|
||||
margin: 5px 0 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,361 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Calendar - TryoutPro{% endblock %}
|
||||
{% block page_title %}Calendar{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-alt"></i> Schedule</h3>
|
||||
<div class="header-actions">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('dayGridMonth')">
|
||||
<i class="fas fa-calendar"></i> Month
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridWeek')">
|
||||
<i class="fas fa-calendar-week"></i> Week
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridDay')">
|
||||
<i class="fas fa-calendar-day"></i> Day
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('listMonth')">
|
||||
<i class="fas fa-list"></i> List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Event Modal (for clicking empty days) -->
|
||||
<div id="createEventModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3>
|
||||
<button class="modal-close" onclick="hideCreateEventModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p>
|
||||
<input type="hidden" id="createEventDateInput"/>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5><i class="fas fa-futbol"></i> Schedule Tryout Match</h5>
|
||||
<p class="text-muted small">Add a scrim/match inside an existing tryout</p>
|
||||
<div class="form-inline">
|
||||
<select id="createTryoutSelect" class="form-select" style="flex:1;">
|
||||
<option value="">-- Select a tryout --</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()">
|
||||
<i class="fas fa-arrow-right"></i> Go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5><i class="fas fa-users"></i> Schedule Team Match</h5>
|
||||
<p class="text-muted small">Regular season match for an org team</p>
|
||||
<div class="form-inline">
|
||||
<select id="createTeamSelect" class="form-select" style="flex:1;">
|
||||
<option value="">-- Select a team --</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()">
|
||||
<i class="fas fa-arrow-right"></i> Go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5>
|
||||
<p class="text-muted small">Create a brand new tryout event</p>
|
||||
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()">
|
||||
<i class="fas fa-plus"></i> Create Tryout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details Modal -->
|
||||
<div id="eventModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideEventModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">Event Details</h3>
|
||||
<button class="modal-close" onclick="hideEventModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modalContent"></div>
|
||||
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
|
||||
<button class="btn btn-sm btn-outline" id="calPresenceBtn">Confirm</button>
|
||||
</div>
|
||||
<div id="modalActions" class="form-actions mt-3" style="display: none;">
|
||||
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
|
||||
<i class="fas fa-trash"></i> Delete Match
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
|
||||
<i class="fas fa-edit"></i> Edit Match
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
|
||||
<i class="fas fa-eye"></i> View Tryout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"></script>
|
||||
<script>
|
||||
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
||||
},
|
||||
events: '/matches/api/events',
|
||||
eventClick: function(info) {
|
||||
showEventModal(info.event);
|
||||
},
|
||||
dateClick: function(info) {
|
||||
if (canScheduleMatches) {
|
||||
var dateStr = info.dateStr;
|
||||
showCreateEventModal(dateStr);
|
||||
}
|
||||
},
|
||||
selectable: true,
|
||||
select: function(info) {
|
||||
if (canScheduleMatches) {
|
||||
var dateStr = info.startStr;
|
||||
showCreateEventModal(dateStr);
|
||||
calendar.unselect();
|
||||
}
|
||||
},
|
||||
slotMinTime: '12:00:00',
|
||||
slotMaxTime: '24:00:00'
|
||||
});
|
||||
calendar.render();
|
||||
|
||||
window.fcCalendar = calendar;
|
||||
|
||||
// Pre-load tryout and team options for the create modal
|
||||
if (canScheduleMatches) {
|
||||
fetchTryoutOptions();
|
||||
fetchTeamOptions();
|
||||
}
|
||||
});
|
||||
|
||||
function changeView(viewName) {
|
||||
if (window.fcCalendar) {
|
||||
window.fcCalendar.changeView(viewName);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create Event Modal ---
|
||||
function showCreateEventModal(dateStr) {
|
||||
document.getElementById('createEventDate').textContent = dateStr;
|
||||
document.getElementById('createEventDateInput').value = dateStr;
|
||||
|
||||
// Reset dropdowns
|
||||
document.getElementById('createTryoutSelect').value = '';
|
||||
document.getElementById('createTeamSelect').value = '';
|
||||
|
||||
document.getElementById('createEventModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideCreateEventModal() {
|
||||
document.getElementById('createEventModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function goToTryoutMatch() {
|
||||
var tryoutId = document.getElementById('createTryoutSelect').value;
|
||||
if (!tryoutId) { alert('Please select a tryout.'); return; }
|
||||
var date = document.getElementById('createEventDateInput').value;
|
||||
window.location.href = '/matches/create/' + tryoutId + '?date=' + date;
|
||||
}
|
||||
|
||||
function goToTeamMatch() {
|
||||
var teamId = document.getElementById('createTeamSelect').value;
|
||||
if (!teamId) { alert('Please select a team.'); return; }
|
||||
var date = document.getElementById('createEventDateInput').value;
|
||||
window.location.href = '/team-matches/' + teamId + '/create?date=' + date;
|
||||
}
|
||||
|
||||
function goToCreateTryout() {
|
||||
// Tryout creation doesn't support pre-filling date easily, just navigate
|
||||
window.location.href = '/tryouts/create';
|
||||
}
|
||||
|
||||
// Pre-fetch data for dropdowns
|
||||
function fetchTryoutOptions() {
|
||||
fetch('/matches/api/manageable-tryouts')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var sel = document.getElementById('createTryoutSelect');
|
||||
sel.innerHTML = '<option value="">-- Select a tryout --</option>';
|
||||
data.forEach(function(t) {
|
||||
sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>';
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function fetchTeamOptions() {
|
||||
fetch('/team-matches/api/manageable-teams')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var sel = document.getElementById('createTeamSelect');
|
||||
sel.innerHTML = '<option value="">-- Select a team --</option>';
|
||||
data.forEach(function(t) {
|
||||
sel.innerHTML += '<option value="' + t.id + '">' + t.name + '</option>';
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function showEventModal(event) {
|
||||
var props = event.extendedProps;
|
||||
var title = event.title;
|
||||
var type = props.type;
|
||||
var date = event.start ? event.start.toDateString() : '';
|
||||
|
||||
var content = '<div class="detail-grid">';
|
||||
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
|
||||
content += '<span class="badge badge-' + (type === 'tryout' ? 'info' : (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning')) + '">';
|
||||
content += (type === 'tryout' ? 'Tryout' : (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim'))) + '</span>';
|
||||
content += '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
|
||||
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
|
||||
content += '<span class="badge badge-' + (props.status || 'scheduled') + '">' + (props.status || 'scheduled') + '</span>';
|
||||
content += '</span></div>';
|
||||
|
||||
// Add team separation for matches
|
||||
if (type === 'match' && props.participants) {
|
||||
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">';
|
||||
if (props.match_type === 'team_vs_team') {
|
||||
// For team vs team, participants is "Team1 vs Team2"
|
||||
var teams = props.participants.split(' vs ');
|
||||
if (teams.length >= 2) {
|
||||
content += '<div class="match-teams">';
|
||||
content += '<div class="match-team"><span class="team-name">' + teams[0] + '</span></div>';
|
||||
content += '<div class="match-vs">vs</div>';
|
||||
content += '<div class="match-team"><span class="team-name">' + teams[1] + '</span></div>';
|
||||
content += '</div>';
|
||||
} else {
|
||||
content += props.participants;
|
||||
}
|
||||
} else if (props.match_type === 'player_vs_player') {
|
||||
// For player vs player, we need to parse the participants
|
||||
// The format is "player1, player2 vs player3, player4"
|
||||
var parts = props.participants.split(' vs ');
|
||||
if (parts.length >= 2) {
|
||||
content += '<div class="match-teams">';
|
||||
content += '<div class="match-team"><span class="team-name">Team 1</span><ul class="team-players-list"><li>' + parts[0].split(', ').join('</li><li>') + '</li></ul></div>';
|
||||
content += '<div class="match-vs">vs</div>';
|
||||
content += '<div class="match-team"><span class="team-name">Team 2</span><ul class="team-players-list"><li>' + parts[1].split(', ').join('</li><li>') + '</li></ul></div>';
|
||||
content += '</div>';
|
||||
} else {
|
||||
content += props.participants;
|
||||
}
|
||||
} else {
|
||||
// For player scrim, just show the list
|
||||
content += props.participants;
|
||||
}
|
||||
content += '</span></div>';
|
||||
}
|
||||
|
||||
if (props.description) {
|
||||
content += '<div class="detail-item full-width"><span class="detail-label">Description</span><span class="detail-value">' + props.description + '</span></div>';
|
||||
}
|
||||
content += '</div>';
|
||||
|
||||
document.getElementById('modalTitle').textContent = type === 'tryout' ? 'Tryout Details' : 'Match Details';
|
||||
document.getElementById('modalContent').innerHTML = content;
|
||||
|
||||
// Reset buttons
|
||||
document.getElementById('deleteMatchBtn').style.display = 'none';
|
||||
document.getElementById('editMatchBtn').style.display = 'none';
|
||||
document.getElementById('viewTryoutBtn').style.display = 'none';
|
||||
|
||||
// Show action buttons for matches (coaches and above)
|
||||
if (type === 'match' && canScheduleMatches) {
|
||||
document.getElementById('modalActions').style.display = 'flex';
|
||||
document.getElementById('editMatchBtn').style.display = 'inline-flex';
|
||||
document.getElementById('editMatchBtn').onclick = function() {
|
||||
window.location.href = '/matches/' + props.match_id + '/edit';
|
||||
};
|
||||
} else if (type === 'tryout' && canScheduleMatches) {
|
||||
document.getElementById('modalActions').style.display = 'flex';
|
||||
document.getElementById('viewTryoutBtn').style.display = 'inline-flex';
|
||||
document.getElementById('viewTryoutBtn').onclick = function() {
|
||||
window.location.href = '/tryouts/' + props.tryout_id;
|
||||
};
|
||||
} else {
|
||||
document.getElementById('modalActions').style.display = 'none';
|
||||
}
|
||||
|
||||
// Show presence toggle for matches where user is a participant
|
||||
var presenceDiv = document.getElementById('modalPresenceToggle');
|
||||
if (type === 'match' && props.user_participant_id) {
|
||||
presenceDiv.style.display = 'flex';
|
||||
var confirmed = props.user_attendance_confirmed || false;
|
||||
var toggleBtn = document.getElementById('calPresenceBtn');
|
||||
toggleBtn.textContent = confirmed ? '✅ Confirmed' : 'Confirm';
|
||||
toggleBtn.className = 'btn btn-sm ' + (confirmed ? 'btn-success' : 'btn-outline');
|
||||
toggleBtn.onclick = function() {
|
||||
toggleCalendarPresence(props.match_id, props.user_participant_id, toggleBtn);
|
||||
};
|
||||
} else {
|
||||
presenceDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('eventModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function toggleCalendarPresence(matchId, participantId, btn) {
|
||||
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.attendance_confirmed) {
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-outline');
|
||||
btn.textContent = '✅ Confirmed';
|
||||
} else {
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-outline');
|
||||
btn.textContent = 'Confirm';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error toggling presence:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function hideEventModal() {
|
||||
document.getElementById('eventModal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,219 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Manage Availability - TryoutPro{% endblock %}
|
||||
{% block page_title %}Manage Availability{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> Set Your Weekly Availability</h3>
|
||||
<p class="text-muted small">Select time slots when you're available for One on One sessions</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-4">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.availability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
padding: 6px 8px;
|
||||
margin: 4px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.time-slot:hover {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.time-slot.selected {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.time-slot.selected:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Time slots from 8:00 AM to 10:00 PM (30-minute intervals)
|
||||
const TIME_SLOTS = [];
|
||||
for (let h = 8; h <= 22; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
||||
const displayHour = h > 12 ? h - 12 : h;
|
||||
const displayAmpm = h >= 12 ? 'PM' : 'AM';
|
||||
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
|
||||
TIME_SLOTS.push({ time: timeStr, display: displayTime });
|
||||
}
|
||||
}
|
||||
|
||||
// Day names
|
||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
// Track selected slots: {day_of_week: [time_strings]}
|
||||
let selectedSlots = {};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadExistingAvailability();
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
function loadExistingAvailability() {
|
||||
// Load from existing data
|
||||
{% for av in existing_availability %}
|
||||
if (!selectedSlots[{{ av.day_of_week }}]) {
|
||||
selectedSlots[{{ av.day_of_week }}] = [];
|
||||
}
|
||||
selectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('availability-grid');
|
||||
let html = '';
|
||||
|
||||
DAYS.forEach((day, dayIndex) => {
|
||||
html += '<div class="day-column">';
|
||||
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
|
||||
|
||||
TIME_SLOTS.forEach(slot => {
|
||||
const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time);
|
||||
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
|
||||
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" onclick="toggleSlot(' + dayIndex + ', \'' + slot.time + '\', this)">' + slot.display + '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleSlot(dayOfWeek, timeStr, element) {
|
||||
if (!selectedSlots[dayOfWeek]) {
|
||||
selectedSlots[dayOfWeek] = [];
|
||||
}
|
||||
|
||||
const index = selectedSlots[dayOfWeek].indexOf(timeStr);
|
||||
if (index === -1) {
|
||||
selectedSlots[dayOfWeek].push(timeStr);
|
||||
element.classList.add('selected');
|
||||
} else {
|
||||
selectedSlots[dayOfWeek].splice(index, 1);
|
||||
element.classList.remove('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function saveAvailability() {
|
||||
const slots = [];
|
||||
for (let day in selectedSlots) {
|
||||
selectedSlots[day].forEach(time => {
|
||||
slots.push({ day_of_week: parseInt(day), start_time: time });
|
||||
});
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.manage_coach_availability") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slots: slots })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
flash('Availability saved!', 'success');
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Save error:', error);
|
||||
flash('Error saving availability.', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function clearAllAvailability() {
|
||||
if (!confirm('Are you sure you want to clear all your availability slots?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.clear_coach_availability") }}', { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
selectedSlots = {};
|
||||
renderGrid();
|
||||
flash('Availability cleared!', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flash(message, type) {
|
||||
const flashContainer = document.querySelector('.flash-messages');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-' + type + ' alert-dismissible';
|
||||
alert.innerHTML = '<span>' + message + '</span><button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>';
|
||||
flashContainer.appendChild(alert);
|
||||
}
|
||||
|
||||
// Auto-save on change (debounced)
|
||||
let saveTimeout;
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('time-slot')) {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(saveAvailability, 1000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Contracts - TryoutPro{% endblock %}
|
||||
{% block page_title %}Contracts{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
{% if current_user.role in ['admin', 'manager', 'coach'] %}
|
||||
<a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload Contract
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-file-contract"></i> Contract Dropbox</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if contracts %}
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Team</th>
|
||||
<th>Contract</th>
|
||||
<th>Status</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contract in contracts %}
|
||||
<tr>
|
||||
<td>{{ contract.player.username }}</td>
|
||||
<td>{{ contract.team.name if contract.team else 'N/A' }}</td>
|
||||
<td>{{ contract.original_filename }}</td>
|
||||
<td>
|
||||
{% if contract.status == 'signed' %}
|
||||
<span class="badge badge-success">Signed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Pending Signature</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ contract.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('users.download_contract', contract_id=contract.id) }}" class="btn btn-sm btn-primary" title="Download">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
{% if contract.signed_file_path %}
|
||||
<a href="{{ url_for('users.download_signed_contract', contract_id=contract.id) }}" class="btn btn-sm btn-success" title="Download Signed">
|
||||
<i class="fas fa-file-signature"></i> Signed
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'player' and contract.status == 'pending' %}
|
||||
<button class="btn btn-sm btn-warning" onclick="showUploadSignedForm({{ contract.id }})" title="Upload Signed Contract">
|
||||
<i class="fas fa-upload"></i> Return Signed
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-contract"></i>
|
||||
<h4>No contracts found</h4>
|
||||
{% if current_user.role == 'player' %}
|
||||
<p>No contracts have been uploaded for you yet. Contact your coach or manager.</p>
|
||||
{% else %}
|
||||
<p>No contracts have been uploaded yet. Upload a contract using the button above.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.role == 'player' %}
|
||||
<!-- Upload Signed Contract Modal -->
|
||||
<div id="uploadSignedModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideUploadSignedForm()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Upload Signed Contract</h3>
|
||||
<button class="modal-close" onclick="hideUploadSignedForm()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="uploadSignedForm" method="POST" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="signed_file">Select Signed Contract File</label>
|
||||
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
||||
<small class="form-text text-muted">Accepted formats: PDF, DOC, DOCX, JPG, PNG</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="hideUploadSignedForm()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload Signed Contract</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function showUploadSignedForm(contractId) {
|
||||
document.getElementById('uploadSignedForm').action = '/users/contracts/' + contractId + '/upload_signed';
|
||||
document.getElementById('uploadSignedModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideUploadSignedForm() {
|
||||
document.getElementById('uploadSignedModal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Create User - TryoutPro{% endblock %}
|
||||
{% block page_title %}Create User{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Create</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.create_user') }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="full_name">Full Name</label>
|
||||
<input type="text" id="full_name" name="full_name" placeholder="Enter full name" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Choose username" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" placeholder="Enter email" required>
|
||||
</div>
|
||||
<div class="form-group col-3">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="Phone number">
|
||||
</div>
|
||||
<div class="form-group col-3">
|
||||
<label for="role">Role</label>
|
||||
<select id="role" name="role" required>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ r | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Set password" required minlength="6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.list_users') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,381 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Dashboard - TryoutPro{% endblock %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Dashboard</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
{% if user.role == 'admin' %}
|
||||
<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-friends"></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-calendar-check"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_tryouts }}</h3>
|
||||
<p>Total Tryouts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_evaluations }}</h3>
|
||||
<p>Evaluations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-danger">
|
||||
<i class="fas fa-play-circle"></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">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user-plus"></i> Recent Users</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Role</th><th>Joined</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in stats.recent_users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td><span class="badge badge-{{ u.role }}">{{ u.role }}</span></td>
|
||||
<td>{{ u.created_at.strftime('%m/%d/%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-alt"></i> Recent Tryouts</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Date</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in stats.recent_tryouts %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('tryouts.view_tryout', tryout_id=t.id) }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td><span class="badge badge-{{ t.status }}">{{ t.status }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.upcoming_matches %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> Upcoming Matches</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Match</th><th>Tryout</th><th>Date & Time</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for match in stats.upcoming_matches %}
|
||||
<tr>
|
||||
<td>{{ match.title }}</td>
|
||||
<td><a href="{{ url_for('tryouts.view_tryout', tryout_id=match.tryout_id) }}">{{ match.tryout.title }}</a></td>
|
||||
<td>
|
||||
{{ match.date.strftime('%m/%d/%Y') }}
|
||||
{% if match.start_time %} at {{ match.start_time.strftime('%I:%M %p') }}{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-{{ match.status }}">{{ match.status }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif user.role == 'manager' %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-primary">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_tryouts }}</h3>
|
||||
<p>My Tryouts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-success">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.active_tryouts }}</h3>
|
||||
<p>Active</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_evaluations }}</h3>
|
||||
<p>My Evaluations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar"></i> My Tryouts</h3>
|
||||
<a href="{{ url_for('tryouts.create_tryout') }}" class="btn btn-sm btn-primary">+ New Tryout</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Date</th><th>Status</th><th>Players</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in stats.my_tryouts %}
|
||||
<tr>
|
||||
<td>{{ t.title }}</td>
|
||||
<td>{{ t.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td><span class="badge badge-{{ t.status }}">{{ t.status }}</span></td>
|
||||
<td>{{ t.registrations.count() }}</td>
|
||||
<td><a href="{{ url_for('tryouts.view_tryout', tryout_id=t.id) }}" class="btn btn-sm btn-outline">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not stats.my_tryouts %}
|
||||
<tr><td colspan="5" class="text-center">No tryouts yet. <a href="{{ url_for('tryouts.create_tryout') }}">Create one</a></td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.upcoming_matches %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> Upcoming Matches</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Match</th><th>Tryout</th><th>Date & Time</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for match in stats.upcoming_matches %}
|
||||
<tr>
|
||||
<td>{{ match.title }}</td>
|
||||
<td><a href="{{ url_for('tryouts.view_tryout', tryout_id=match.tryout_id) }}">{{ match.tryout.title }}</a></td>
|
||||
<td>
|
||||
{{ match.date.strftime('%m/%d/%Y') }}
|
||||
{% if match.start_time %} at {{ match.start_time.strftime('%I:%M %p') }}{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-{{ match.status }}">{{ match.status }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif user.role == 'coach' %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-primary">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.my_evaluations }}</h3>
|
||||
<p>Evaluations Done</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-warning">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.pending_evaluations }}</h3>
|
||||
<p>Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-history"></i> Recent Evaluations</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Player</th><th>Tryout</th><th>Score</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in stats.my_recent_evaluations %}
|
||||
<tr>
|
||||
<td>{{ e.player.username }}</td>
|
||||
<td>{{ e.tryout.title }}</td>
|
||||
<td><span class="score">{{ e.overall_score }}</span></td>
|
||||
<td>{{ e.created_at.strftime('%m/%d/%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif user.role == 'player' %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-primary">
|
||||
<i class="fas fa-calendar-check"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.my_tryouts }}</h3>
|
||||
<p>My Tryouts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> My Next Matches</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.next_matches %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Tryout</th><th>Match</th><th>Date & Time</th><th>Opponent</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in stats.next_matches %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('tryouts.view_tryout', tryout_id=item.tryout.id) }}">{{ item.tryout.title }}</a></td>
|
||||
<td>{{ item.match.title }}</td>
|
||||
<td>
|
||||
{{ item.match.date.strftime('%m/%d/%Y') }}
|
||||
{% if item.match.start_time %} at {{ item.match.start_time.strftime('%I:%M %p') }}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.match.match_type == 'team_vs_team' and item.team %}
|
||||
{% if item.match.team1_id == item.team.id %}
|
||||
vs {{ item.match.team2.name if item.match.team2 else 'TBD' }}
|
||||
{% else %}
|
||||
vs {{ item.match.team1.name if item.match.team1 else 'TBD' }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No upcoming matches yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> My Registrations</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Tryout</th><th>Date</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in stats.my_registrations %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('tryouts.view_tryout', tryout_id=r.tryout.id) }}">{{ r.tryout.title }}</a></td>
|
||||
<td>{{ r.tryout.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td><span class="badge badge-{{ r.status }}">{{ r.status }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif user.role == 'scout' %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-primary">
|
||||
<i class="fas fa-user-friends"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_players }}</h3>
|
||||
<p>Total Players</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ stats.total_evaluations }}</h3>
|
||||
<p>Total Evaluations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-trophy"></i> Top Rated Players</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>#</th><th>Player</th><th>Avg Score</th></tr></thead>
|
||||
<tbody>
|
||||
{% for player, score in stats.top_players %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ player.username }}</td>
|
||||
<td><span class="score">{{ score }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not stats.top_players %}
|
||||
<tr><td colspan="3" class="text-center">No evaluations yet.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,341 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Edit Profile - TryoutPro{% endblock %}
|
||||
{% block page_title %}Edit Profile{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Edit</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.edit_profile') }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ user.username }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="full_name">Full Name</label>
|
||||
<input type="text" id="full_name" name="full_name" value="{{ user.full_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ user.phone or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> E-Sports Profile</h4>
|
||||
<p class="text-muted small">Update your competitive gaming profile for tryouts.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label><i class="fas fa-headset"></i> Games You Play</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for game in esport_games %}
|
||||
{% set games_list = user.get_games_list() %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="games" value="{{ game }}" {% if game in games_list %}checked{% endif %}>
|
||||
<span>{{ game }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small class="form-text text-muted">Select all games you're signing in for.</small>
|
||||
</div>
|
||||
|
||||
<!-- Gamertag inputs - will be shown when game is selected -->
|
||||
<div id="gamertag-section" style="display: none;">
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-chart-line"></i> Gamertags for TRN</h4>
|
||||
<p class="text-muted small">Enter your gamertag for each selected game to link to your Tracker Network profile.</p>
|
||||
|
||||
{% for game in esport_games %}
|
||||
{% set gamertag_data = user_gamertags.get(game) %}
|
||||
<div class="gamertag-input-row" data-game="{{ game }}" style="display: none;">
|
||||
<h5>{{ game }}</h5>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="gamertag_{{ game }}">Gamertag</label>
|
||||
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}" value="{{ gamertag_data.gamertag if gamertag_data else '' }}" placeholder="Your {{ game }} gamertag">
|
||||
</div>
|
||||
{% if game_platforms.get(game) %}
|
||||
<div class="form-group col-6">
|
||||
<label for="platform_{{ game }}">Platform</label>
|
||||
<select id="platform_{{ game }}" name="platform_{{ game }}">
|
||||
<option value="">Select Platform</option>
|
||||
{% for platform in game_platforms[game] %}
|
||||
<option value="{{ platform }}" {% if gamertag_data and gamertag_data.platform == platform %}selected{% endif %}>{{ platform }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> Discord Username</label>
|
||||
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="e.g. Name#1234">
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>(for DMs)</small></label>
|
||||
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="Numeric ID (e.g. 123456789012345678)">
|
||||
<small class="text-muted">Enable Developer Mode in Discord → Right-click profile → Copy ID</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> League OS Connection</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="League OS profile link or ID">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-lock"></i> Change Password</h4>
|
||||
<p class="text-muted small">Leave blank to keep your current password.</p>
|
||||
<div class="form-group">
|
||||
<label for="password">New Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter new password">
|
||||
</div>
|
||||
|
||||
{% if user.role == 'player' %}
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-clock"></i> My Disponibilities</h4>
|
||||
<p class="text-muted small">Select your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
|
||||
|
||||
<div id="disponibilities-grid">
|
||||
<p class="text-muted">Loading...</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.profile') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var GAME_PLATFORMS = {{ game_platforms|tojson }};
|
||||
|
||||
// Show/hide gamertag inputs when games are checked
|
||||
function toggleGamertagInputs() {
|
||||
var selectedGames = [];
|
||||
document.querySelectorAll('input[name="games"]:checked').forEach(function(cb) {
|
||||
selectedGames.push(cb.value);
|
||||
});
|
||||
|
||||
if (selectedGames.length > 0) {
|
||||
document.getElementById('gamertag-section').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('gamertag-section').style.display = 'none';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.gamertag-input-row').forEach(function(row) {
|
||||
if (selectedGames.includes(row.dataset.game)) {
|
||||
row.style.display = 'block';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up event listeners for game checkboxes
|
||||
document.querySelectorAll('input[name="games"]').forEach(function(cb) {
|
||||
cb.addEventListener('change', toggleGamertagInputs);
|
||||
});
|
||||
|
||||
// Show gamertag inputs for already selected games
|
||||
toggleGamertagInputs();
|
||||
|
||||
{% if user.role == 'player' %}
|
||||
renderDisponibilityGrid();
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
{% if user.role == 'player' %}
|
||||
// Generate time slots from 5pm (17:00) to 12am (24:00)
|
||||
var TIME_SLOTS = [];
|
||||
for (var h = 17; h <= 24; h++) {
|
||||
for (var m = 0; m < 60; m += 30) {
|
||||
if (h === 24 && m > 0) continue;
|
||||
var displayHour;
|
||||
var displayAmpm;
|
||||
if (h === 24) {
|
||||
displayHour = 12;
|
||||
displayAmpm = 'AM';
|
||||
} else if (h > 12) {
|
||||
displayHour = h - 12;
|
||||
displayAmpm = 'PM';
|
||||
} else {
|
||||
displayHour = h;
|
||||
displayAmpm = 'PM';
|
||||
}
|
||||
var timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
||||
var displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
|
||||
TIME_SLOTS.push({ time: timeStr, display: displayTime });
|
||||
}
|
||||
}
|
||||
|
||||
var DAYS = [
|
||||
{ value: 0, name: 'Monday' },
|
||||
{ value: 1, name: 'Tuesday' },
|
||||
{ value: 2, name: 'Wednesday' },
|
||||
{ value: 3, name: 'Thursday' },
|
||||
{ value: 4, name: 'Friday' },
|
||||
{ value: 5, name: 'Saturday' },
|
||||
{ value: 6, name: 'Sunday' }
|
||||
];
|
||||
|
||||
// Store selected slots: {day: [time, time, ...]}
|
||||
var selectedSlots = {};
|
||||
|
||||
function renderDisponibilityGrid() {
|
||||
var grid = document.getElementById('disponibilities-grid');
|
||||
grid.innerHTML = '<div style="margin-bottom: 10px;"><strong>Click time blocks to select your available hours</strong></div>';
|
||||
|
||||
var container = document.createElement('div');
|
||||
container.className = 'disponibility-grid';
|
||||
|
||||
DAYS.forEach(function(day) {
|
||||
var dayRow = document.createElement('div');
|
||||
dayRow.className = 'disponibility-day-row';
|
||||
|
||||
var dayLabel = document.createElement('div');
|
||||
dayLabel.className = 'disponibility-day-label';
|
||||
dayLabel.textContent = day.name;
|
||||
dayRow.appendChild(dayLabel);
|
||||
|
||||
var timeBlocks = document.createElement('div');
|
||||
timeBlocks.className = 'disponibility-time-blocks';
|
||||
|
||||
TIME_SLOTS.forEach(function(slot) {
|
||||
var block = document.createElement('div');
|
||||
block.className = 'disponibility-time-block';
|
||||
block.dataset.day = day.value;
|
||||
block.dataset.time = slot.time;
|
||||
block.textContent = slot.display;
|
||||
block.onclick = function() {
|
||||
toggleSlot(day.value, slot.time, block);
|
||||
};
|
||||
timeBlocks.appendChild(block);
|
||||
});
|
||||
|
||||
dayRow.appendChild(timeBlocks);
|
||||
container.appendChild(dayRow);
|
||||
});
|
||||
|
||||
grid.appendChild(container);
|
||||
loadMyDisponibilities();
|
||||
}
|
||||
|
||||
function toggleSlot(day, time, element) {
|
||||
if (!selectedSlots[day]) selectedSlots[day] = [];
|
||||
|
||||
var index = selectedSlots[day].indexOf(time);
|
||||
if (index > -1) {
|
||||
selectedSlots[day].splice(index, 1);
|
||||
element.classList.remove('selected');
|
||||
} else {
|
||||
selectedSlots[day].push(time);
|
||||
element.classList.add('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function loadMyDisponibilities() {
|
||||
fetch('{{ url_for("users.get_my_disponibilities") }}')
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
selectedSlots = {};
|
||||
|
||||
for (var day in data) {
|
||||
var slots = data[day];
|
||||
slots.forEach(function(slot) {
|
||||
selectedSlots[day] = selectedSlots[day] || [];
|
||||
selectedSlots[day].push(slot.start_time);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.disponibility-time-block').forEach(function(block) {
|
||||
var day = block.dataset.day;
|
||||
var time = block.dataset.time;
|
||||
if (selectedSlots[day] && selectedSlots[day].indexOf(time) > -1) {
|
||||
block.classList.add('selected');
|
||||
} else {
|
||||
block.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error loading disponibilities:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function saveDisponibilities() {
|
||||
var slots = [];
|
||||
for (var day in selectedSlots) {
|
||||
selectedSlots[day].forEach(function(time) {
|
||||
slots.push({ day_of_week: parseInt(day), start_time: time });
|
||||
});
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.add_disponibilities_bulk") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ slots: slots })
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'alert alert-success';
|
||||
msg.style.marginTop = '10px';
|
||||
msg.innerHTML = '<i class="fas fa-check"></i> Disponibilities saved successfully!';
|
||||
document.getElementById('disponibilities-grid').appendChild(msg);
|
||||
setTimeout(function() { msg.remove(); }, 3000);
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error saving disponibilities:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function clearDisponibilities() {
|
||||
if (!confirm('Are you sure you want to clear all your disponibilities?')) return;
|
||||
|
||||
fetch('{{ url_for("users.clear_disponibilities") }}', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
selectedSlots = {};
|
||||
document.querySelectorAll('.disponibility-time-block').forEach(function(block) {
|
||||
block.classList.remove('selected');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,151 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Edit {{ user.username }} - TryoutPro{% endblock %}
|
||||
{% block page_title %}Edit User{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Edit</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.edit_user', user_id=user.id) }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="full_name">Full Name</label>
|
||||
<input type="text" id="full_name" name="full_name" value="{{ user.username }}" required>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ user.phone or '' }}">
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="role">Role</label>
|
||||
<select id="role" name="role" required>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {% if user.role == r %}selected{% endif %}>{{ r | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="is_active_account">
|
||||
<input type="checkbox" id="is_active_account" name="is_active_account" {% if user.is_active_account %}checked{% endif %}>
|
||||
Active Account
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> E-Sports Profile</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Games</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for game in esport_games %}
|
||||
{% set games_list = user.get_games_list() %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="games" value="{{ game }}" {% if game in games_list %}checked{% endif %}>
|
||||
<span>{{ game }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gamertag inputs - will be shown when game is selected -->
|
||||
<div id="gamertag-section" style="display: none;">
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-chart-line"></i> Gamertags for TRN</h4>
|
||||
<p class="text-muted small">Enter gamertag for each selected game to link to Tracker Network.</p>
|
||||
|
||||
{% for game in esport_games %}
|
||||
{% set gamertag_data = user_gamertags.get(game) %}
|
||||
<div class="gamertag-input-row" data-game="{{ game }}" style="display: none;">
|
||||
<h5>{{ game }}</h5>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="gamertag_{{ game }}">Gamertag</label>
|
||||
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}" value="{{ gamertag_data.gamertag if gamertag_data else '' }}" placeholder="Gamertag">
|
||||
</div>
|
||||
{% if game_platforms.get(game) %}
|
||||
<div class="form-group col-6">
|
||||
<label for="platform_{{ game }}">Platform</label>
|
||||
<select id="platform_{{ game }}" name="platform_{{ game }}">
|
||||
<option value="">Select Platform</option>
|
||||
{% for platform in game_platforms[game] %}
|
||||
<option value="{{ platform }}" {% if gamertag_data and gamertag_data.platform == platform %}selected{% endif %}>{{ platform }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> Discord Username</label>
|
||||
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="e.g. Name#1234">
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>(for DMs)</small></label>
|
||||
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="Numeric ID (e.g. 123456789012345678)">
|
||||
<small class="text-muted">Enable Developer Mode in Discord → Right-click profile → Copy ID</small>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> League OS Connection</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="League OS profile link or ID">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">New Password <small>(leave blank to keep current)</small></label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter new password">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.list_users') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Update User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show/hide gamertag inputs when games are checked
|
||||
function toggleGamertagInputs() {
|
||||
var selectedGames = [];
|
||||
document.querySelectorAll('input[name="games"]:checked').forEach(function(cb) {
|
||||
selectedGames.push(cb.value);
|
||||
});
|
||||
|
||||
if (selectedGames.length > 0) {
|
||||
document.getElementById('gamertag-section').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('gamertag-section').style.display = 'none';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.gamertag-input-row').forEach(function(row) {
|
||||
if (selectedGames.includes(row.dataset.game)) {
|
||||
row.style.display = 'block';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up event listeners for game checkboxes
|
||||
document.querySelectorAll('input[name="games"]').forEach(function(cb) {
|
||||
cb.addEventListener('change', toggleGamertagInputs);
|
||||
});
|
||||
|
||||
// Show gamertag inputs for already selected games
|
||||
toggleGamertagInputs();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,184 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Evaluate {{ player.username }} - TryoutPro{% endblock %}
|
||||
{% block page_title %}Evaluate {{ player.username }}{% 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 %}
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> Player Evaluation</h3>
|
||||
<span class="badge badge-info">{{ tryout.title }} - {{ tryout.game }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="eval-player-info mb-4">
|
||||
<div class="user-avatar avatar-lg">{{ player.username[:2] | upper }}</div>
|
||||
<div>
|
||||
<h3>{{ player.username }}</h3>
|
||||
<p class="text-muted">{{ player.email }} | {{ player.phone or 'No phone' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if existing_eval %}
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> You have already evaluated this player. Your previous scores are shown below.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="mecanics_score">Mecanics (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="mecanics_score" name="mecanics_score" min="1" max="10" value="{{ existing_eval.mecanics_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.mecanics_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="cohesion_score">Cohesion (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="cohesion_score" name="cohesion_score" min="1" max="10" value="{{ existing_eval.cohesion_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.cohesion_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="communication_score">Communication (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="communication_score" name="communication_score" min="1" max="10" value="{{ existing_eval.communication_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.communication_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="gamesense_score">Gamesense (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="gamesense_score" name="gamesense_score" min="1" max="10" value="{{ existing_eval.gamesense_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.gamesense_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="versatility_score">Versatility (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="versatility_score" name="versatility_score" min="1" max="10" value="{{ existing_eval.versatility_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.versatility_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="discipline_score">Discipline (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="discipline_score" name="discipline_score" min="1" max="10" value="{{ existing_eval.discipline_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.discipline_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="analysis_score">Analysis (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="analysis_score" name="analysis_score" min="1" max="10" value="{{ existing_eval.analysis_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.analysis_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="sport_ethics_score">Sport Ethics (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="sport_ethics_score" name="sport_ethics_score" min="1" max="10" value="{{ existing_eval.sport_ethics_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.sport_ethics_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="mental_score">Mental (1-10)</label>
|
||||
<div class="score-input">
|
||||
<input type="range" id="mental_score" name="mental_score" min="1" max="10" value="{{ existing_eval.mental_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="range-value">{{ existing_eval.mental_score or 5 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
<div class="form-row">
|
||||
<div class="form-group col-12">
|
||||
<label for="position_recommendation">Recommended Position</label>
|
||||
{% if positions %}
|
||||
<select id="position_recommendation" name="position_recommendation" class="form-select">
|
||||
<option value="">-- Select Position --</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}" {% if existing_eval.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" id="position_recommendation" name="position_recommendation" value="{{ existing_eval.position_recommendation or '' }}" placeholder="Enter position (optional)">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="comments">Comments</label>
|
||||
<textarea id="comments" name="comments" rows="4" placeholder="Enter your evaluation notes...">{{ existing_eval.comments or '' }}</textarea>
|
||||
</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-save"></i> {% if existing_eval %}Update Evaluation{% else %}Submit Evaluation{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('users.notes_dashboard') }}" class="btn btn-outline" title="Add note for this player">
|
||||
<i class="fas fa-sticky-note"></i> Add Note
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if evaluators %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> All Evaluations for {{ player.username }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Evaluator</th>
|
||||
<th>Mecanics</th>
|
||||
<th>Cohesion</th>
|
||||
<th>Communication</th>
|
||||
<th>Gamesense</th>
|
||||
<th>Versatility</th>
|
||||
<th>Discipline</th>
|
||||
<th>Analysis</th>
|
||||
<th>Sport Ethics</th>
|
||||
<th>Mental</th>
|
||||
<th>Overall</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in evaluators %}
|
||||
<tr>
|
||||
<td>{{ entry.evaluator.username }}</td>
|
||||
<td>{{ entry.eval.mecanics_score or '-' }}</td>
|
||||
<td>{{ entry.eval.cohesion_score or '-' }}</td>
|
||||
<td>{{ entry.eval.communication_score or '-' }}</td>
|
||||
<td>{{ entry.eval.gamesense_score or '-' }}</td>
|
||||
<td>{{ entry.eval.versatility_score or '-' }}</td>
|
||||
<td>{{ entry.eval.discipline_score or '-' }}</td>
|
||||
<td>{{ entry.eval.analysis_score or '-' }}</td>
|
||||
<td>{{ entry.eval.sport_ethics_score or '-' }}</td>
|
||||
<td>{{ entry.eval.mental_score or '-' }}</td>
|
||||
<td><span class="score">{{ entry.eval.overall_score or '-' }}</span></td>
|
||||
<td>{{ entry.eval.position_recommendation or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Evaluations - TryoutPro{% endblock %}
|
||||
{% block page_title %}Evaluations{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set sort_column = sort_column | default('created_at') %}
|
||||
{% set sort_order = sort_order | default('desc') %}
|
||||
|
||||
{% macro sort_link(column, label) %}
|
||||
<th>
|
||||
{% set new_order = 'asc' if (sort_column == column and sort_order == 'desc') else 'desc' %}
|
||||
<a href="{{ url_for('evaluations.list_evaluations', sort=column, order=new_order) }}" class="sort-link">
|
||||
{{ label }}
|
||||
{% if sort_column == column %}
|
||||
<i class="fas fa-arrow-{{ 'up' if sort_order == 'asc' else 'down' }}"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-arrows-alt-v sort-inactive"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% if current_user.role == 'admin' and player_scores %}
|
||||
<div class="stats-grid mb-4">
|
||||
{% for pid, data in player_scores.items() %}
|
||||
<div class="stat-card stat-card-sm">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ data.player.username[:12] }}</h3>
|
||||
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i>
|
||||
{% if current_user.role == 'player' %}
|
||||
My Evaluations
|
||||
{% else %}
|
||||
All Evaluations
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ sort_link('tryout', 'Tryout') }}
|
||||
{{ sort_link('player', 'Player') }}
|
||||
{{ sort_link('evaluator', 'Evaluator') }}
|
||||
{{ sort_link('mecanics_score', 'Mecanics') }}
|
||||
{{ sort_link('cohesion_score', 'Cohesion') }}
|
||||
{{ sort_link('communication_score', 'Communication') }}
|
||||
{{ sort_link('gamesense_score', 'Gamesense') }}
|
||||
{{ sort_link('versatility_score', 'Versatility') }}
|
||||
{{ sort_link('discipline_score', 'Discipline') }}
|
||||
{{ sort_link('analysis_score', 'Analysis') }}
|
||||
{{ sort_link('sport_ethics_score', 'Sport Ethics') }}
|
||||
{{ sort_link('mental_score', 'Mental') }}
|
||||
{{ sort_link('overall_score', 'Overall') }}
|
||||
{{ sort_link('position_recommendation', 'Position') }}
|
||||
{{ sort_link('created_at', 'Date') }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eval in evaluations %}
|
||||
<tr>
|
||||
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td>
|
||||
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td>
|
||||
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td>
|
||||
<td>{{ eval.mecanics_score or '-' }}</td>
|
||||
<td>{{ eval.cohesion_score or '-' }}</td>
|
||||
<td>{{ eval.communication_score or '-' }}</td>
|
||||
<td>{{ eval.gamesense_score or '-' }}</td>
|
||||
<td>{{ eval.versatility_score or '-' }}</td>
|
||||
<td>{{ eval.discipline_score or '-' }}</td>
|
||||
<td>{{ eval.analysis_score or '-' }}</td>
|
||||
<td>{{ eval.sport_ethics_score or '-' }}</td>
|
||||
<td>{{ eval.mental_score or '-' }}</td>
|
||||
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
|
||||
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td>
|
||||
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="15" class="text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-clipboard"></i>
|
||||
<h3>No evaluations yet</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Login - TryoutPro{% endblock %}
|
||||
{% block auth_content %}
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="username"><i class="fas fa-user"></i> Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Enter your username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password"><i class="fas fa-lock"></i> Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
|
||||
<p class="auth-link">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}My Team(s) - TryoutPro{% endblock %}
|
||||
{% block page_title %}My Team(s){% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / My Team(s)</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if team_data %}
|
||||
{% for item in team_data %}
|
||||
{% set team = item.team %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
|
||||
</div>
|
||||
<!-- Staff bar -->
|
||||
<div class="team-staff-bar">
|
||||
<div class="staff-row">
|
||||
<div class="staff-group">
|
||||
<span class="staff-label"><i class="fas fa-chalkboard-teacher"></i> Coaches</span>
|
||||
<div class="staff-items">
|
||||
{% if item.coaches %}
|
||||
{% for c in item.coaches %}
|
||||
<span class="staff-tag">{{ c.username }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted text-sm">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="staff-group">
|
||||
<span class="staff-label"><i class="fas fa-user-tie"></i> Managers</span>
|
||||
<div class="staff-items">
|
||||
{% if item.managers %}
|
||||
{% for m in item.managers %}
|
||||
<span class="staff-tag manager-tag">{{ m.username }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted text-sm">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Team Roster -->
|
||||
<h4 class="mb-3"><i class="fas fa-users"></i> Team Roster</h4>
|
||||
{% set roster = team.get_players_with_status() %}
|
||||
{% if roster %}
|
||||
<div class="table-container mb-4">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Status</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in roster %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if entry.status == 'starter' %}badge-success{% else %}badge-warning{% endif %}">
|
||||
{{ entry.status | capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.position or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="fas fa-users-slash"></i> No players on this team.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Team Matches -->
|
||||
<h4 class="mb-3"><i class="fas fa-futbol"></i> Upcoming Matches</h4>
|
||||
{% if item.matches %}
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Match</th>
|
||||
<th>Opponent</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Location</th>
|
||||
<th>Presence</th>
|
||||
<th>My Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mdata in item.matches %}
|
||||
{% set tm = mdata.match %}
|
||||
<tr>
|
||||
<td class="cell-title">{{ tm.title }}</td>
|
||||
<td>
|
||||
{% if tm.opponent %}
|
||||
{{ tm.opponent }}
|
||||
{% else %}
|
||||
<span class="badge badge-info">Practice</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ tm.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td>
|
||||
{% if tm.start_time and tm.end_time %}
|
||||
{{ tm.start_time.strftime('%H:%M') }} - {{ tm.end_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
TBD
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ tm.location or '—' }}</td>
|
||||
<td>
|
||||
{% if mdata.total_count > 0 %}
|
||||
<span title="{{ mdata.confirmed_count }} of {{ mdata.total_count }} confirmed">
|
||||
{% if mdata.confirmed_count == mdata.total_count and mdata.total_count > 0 %}
|
||||
✅ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
|
||||
{% elif mdata.confirmed_count > 0 %}
|
||||
⏳ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
|
||||
{% else %}
|
||||
❌ 0/{{ mdata.total_count }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if mdata.participant_id %}
|
||||
<button class="btn btn-sm {% if mdata.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
|
||||
data-match-id="{{ tm.id }}"
|
||||
data-participant-id="{{ mdata.participant_id }}"
|
||||
onclick="togglePresence(this)">
|
||||
{% if mdata.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="fas fa-calendar-alt"></i> No upcoming matches scheduled.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h3>No Teams</h3>
|
||||
<p class="text-muted">You are not currently assigned to any team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function togglePresence(btn) {
|
||||
var matchId = btn.getAttribute('data-match-id');
|
||||
var participantId = btn.getAttribute('data-participant-id');
|
||||
|
||||
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.is_confirmed) {
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-outline');
|
||||
btn.innerHTML = '✅ Confirmed';
|
||||
} else {
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-outline');
|
||||
btn.innerHTML = 'Confirm';
|
||||
}
|
||||
location.reload();
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.team-staff-bar {
|
||||
padding: 10px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.staff-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.staff-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.staff-label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.staff-label i {
|
||||
margin-right: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.staff-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.staff-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
padding: 3px 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.staff-tag.manager-tag {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.presence-toggle-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .team-staff-bar {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
[data-theme="dark"] .staff-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .staff-tag {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
[data-theme="dark"] .staff-tag.manager-tag {
|
||||
background: rgba(229, 169, 57, 0.2);
|
||||
color: #fcd34d;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,172 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Notes - TryoutPro{% endblock %}
|
||||
{% block page_title %}Notes{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Notes</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-grid">
|
||||
<!-- Add Team Notes Section -->
|
||||
{% if org_team %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Team Notes</h3>
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="team_notes_content">Team Notes Content</label>
|
||||
<textarea name="content" id="team_notes_content" class="form-textarea" rows="4" placeholder="Enter improvement suggestions and notes for your team...">{{ latest_team_note.content if latest_team_note else '' }}</textarea>
|
||||
<p class="form-text">These notes will be visible to all players on your team.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {% if latest_team_note %}Update{% else %}Add{% endif %} Team Notes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add Personal Note Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user-friends"></i> Add Personal Note</h3>
|
||||
{% if org_team %}
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player_id">Select Player</label>
|
||||
<select name="player_id" id="player_id" class="form-select" required>
|
||||
<option value="">-- Select a Player --</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}">{{ player.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="note_content">Note Content</label>
|
||||
<textarea name="content" id="note_content" class="form-textarea" rows="3" placeholder="Enter personal feedback or coaching tips for this player..." required></textarea>
|
||||
<p class="form-text">These notes will only be visible to the selected player.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="context">Context (Optional)</label>
|
||||
<p class="form-text text-muted">Link this note to a specific match, tryout, or team for better organization.</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="note_match_id">Match</label>
|
||||
<select name="match_id" id="note_match_id" class="form-select">
|
||||
<option value="">-- Select Match --</option>
|
||||
{% for match in matches %}
|
||||
<option value="{{ match.id }}">{{ match.title }} - {{ match.date.strftime('%m/%d/%Y') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="note_tryout_id">Tryout</label>
|
||||
<select name="tryout_id" id="note_tryout_id" class="form-select">
|
||||
<option value="">-- Select Tryout --</option>
|
||||
{% for tryout in tryouts %}
|
||||
<option value="{{ tryout.id }}">{{ tryout.title }} - {{ tryout.date.strftime('%m/%d/%Y') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="note_team_id">Team</label>
|
||||
<select name="team_id" id="note_team_id" class="form-select">
|
||||
<option value="">-- Select Team --</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Add Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid mt-4">
|
||||
<!-- Team Notes History -->
|
||||
{% if org_team and team_notes %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-history"></i> Team Notes History</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last Updated</th>
|
||||
<th>Content Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for note in team_notes %}
|
||||
<tr>
|
||||
<td>{{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') if note.updated_at else 'Unknown date' }}</td>
|
||||
<td>{{ note.content[:100] if note.content else '' }}{% if note.content and note.content|length > 100 %}...{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Personal Notes History -->
|
||||
{% if personal_notes %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-sticky-note"></i> Recent Personal Notes</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-grid">
|
||||
{% for note in personal_notes %}
|
||||
<div class="detail-item full-width mb-4">
|
||||
<span class="detail-label">
|
||||
<i class="fas fa-user"></i> {{ note.player.username if note.player else 'Unknown Player' }} -
|
||||
<span class="text-muted">{{ note.created_at.strftime('%B %d, %Y') if note.created_at else 'Unknown date' }}</span>
|
||||
</span>
|
||||
<span class="detail-value">{{ note.content | nl2br if note.content else '' }}</span>
|
||||
<span class="detail-label text-muted small">From: {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
|
||||
{% if note.match_id or note.team_id or note.tryout_id %}
|
||||
<div class="mt-2">
|
||||
{% if note.match_id and note.match %}
|
||||
<span class="badge badge-info" title="From match"><i class="fas fa-futbol"></i> {{ note.match.title }}</span>
|
||||
{% endif %}
|
||||
{% if note.team_id and note.team %}
|
||||
<span class="badge badge-warning" title="From team"><i class="fas fa-users"></i> {{ note.team.name }}</span>
|
||||
{% endif %}
|
||||
{% if note.tryout_id and note.tryout %}
|
||||
<span class="badge badge-success" title="From tryout"><i class="fas fa-calendar-alt"></i> {{ note.tryout.title }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,250 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}One on One - TryoutPro{% endblock %}
|
||||
{% block page_title %}One on One{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / One on One</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-grid">
|
||||
<!-- Team Notes Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Team Notes</h3>
|
||||
{% if org_team %}
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if team_notes %}
|
||||
{% for note in team_notes %}
|
||||
<div class="detail-grid mt-4">
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label"><i class="fas fa-user"></i> Coach: {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
|
||||
<span class="detail-value">{{ note.content | nl2br }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2">Updated: {{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No team notes have been added yet. Your coach will post improvement suggestions here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Notes Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user"></i> Personal Notes</h3>
|
||||
{% if coach %}
|
||||
<span class="badge badge-coach">From: {{ coach.username }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if personal_notes %}
|
||||
{% for note in personal_notes %}
|
||||
<div class="detail-grid mt-4">
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label"><i class="fas fa-sticky-note"></i> Note from {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
|
||||
<span class="detail-value">{{ note.content | nl2br }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2">Added: {{ note.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No personal notes have been added yet. Your coach may provide individual feedback here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One on One Request Section -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-check"></i> Request One on One Session</h3>
|
||||
{% if coach %}
|
||||
<span class="badge badge-info">Coach: {{ coach.username }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if coach %}
|
||||
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="date">Select Date</label>
|
||||
<select name="date" id="date" class="form-select" onchange="updateTimeSlots()" required>
|
||||
{% for d in dates %}
|
||||
<option value="{{ d.value }}" data-day="{{ d.day_of_week }}">{{ d.display }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="start_time">Start Time</label>
|
||||
<select name="start_time" id="start_time" class="form-select" onchange="updateEndTimeOptions()" required>
|
||||
<option value="">-- Select Date First --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_time">End Time</label>
|
||||
<select name="end_time" id="end_time" class="form-select" required>
|
||||
<option value="">-- Select Start Time First --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="points">Discussion Points <span class="text-muted">(What would you like to discuss?)</span></label>
|
||||
<textarea name="points" id="points" class="form-textarea" placeholder="Enter topics you'd like to cover in your One on One session..." rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane"></i> Send Request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted">You need to be assigned to a team with a coach to request a One on One session.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if coach %}
|
||||
<!-- Hidden data for JavaScript -->
|
||||
<script id="coach-availability-data" type="application/json">
|
||||
{{ coach_availability | tojson }}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Time slots from 8:00 AM to 10:00 PM
|
||||
const TIME_SLOTS = [];
|
||||
for (let h = 8; h <= 22; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
const displayHour = h > 12 ? h - 12 : h;
|
||||
const displayAmpm = h >= 12 ? 'PM' : 'AM';
|
||||
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
||||
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
|
||||
TIME_SLOTS.push({ time: timeStr, display: displayTime });
|
||||
}
|
||||
}
|
||||
|
||||
// Coach availability data
|
||||
let coachAvailability = [];
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCoachAvailability();
|
||||
updateTimeSlots();
|
||||
});
|
||||
|
||||
function loadCoachAvailability() {
|
||||
const dataEl = document.getElementById('coach-availability-data');
|
||||
if (!dataEl) return;
|
||||
|
||||
try {
|
||||
coachAvailability = JSON.parse(dataEl.textContent);
|
||||
} catch (e) {
|
||||
coachAvailability = [];
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimeSlots() {
|
||||
const dateSelect = document.getElementById('date');
|
||||
const startTimeSelect = document.getElementById('start_time');
|
||||
|
||||
const selectedOption = dateSelect.options[dateSelect.selectedIndex];
|
||||
const dayOfWeek = parseInt(selectedOption.getAttribute('data-day'));
|
||||
|
||||
// Get available time slots for this day
|
||||
const dayAvailability = coachAvailability.filter(av => av.day_of_week === dayOfWeek);
|
||||
|
||||
// Build available slots - collect all available minutes then sort
|
||||
const availableSlots = [];
|
||||
const availableMinutes = [];
|
||||
dayAvailability.forEach(av => {
|
||||
const startMinutes = av.start_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0);
|
||||
const endMinutes = av.end_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0);
|
||||
|
||||
// Add 30-minute slots
|
||||
for (let m = startMinutes; m < endMinutes; m += 30) {
|
||||
availableMinutes.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort minutes and convert to time strings
|
||||
availableMinutes.sort((a, b) => a - b);
|
||||
availableMinutes.forEach(m => {
|
||||
const hour = Math.floor(m / 60);
|
||||
const minute = m % 60;
|
||||
const timeStr = (hour < 10 ? '0' : '') + hour + ':' + (minute < 10 ? '0' : '') + minute;
|
||||
availableSlots.push(timeStr);
|
||||
});
|
||||
|
||||
// Update start time options (sorted)
|
||||
startTimeSelect.innerHTML = '<option value="">-- Select Start Time --</option>';
|
||||
availableSlots.forEach(slot => {
|
||||
const slotData = TIME_SLOTS.find(s => s.time === slot);
|
||||
if (slotData) {
|
||||
const option = document.createElement('option');
|
||||
option.value = slotData.time;
|
||||
option.textContent = slotData.display;
|
||||
startTimeSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset end time options
|
||||
updateEndTimeOptions();
|
||||
}
|
||||
|
||||
function updateEndTimeOptions() {
|
||||
const dateSelect = document.getElementById('date');
|
||||
const startTimeSelect = document.getElementById('start_time');
|
||||
const endTimeSelect = document.getElementById('end_time');
|
||||
|
||||
const selectedOption = dateSelect.options[dateSelect.selectedIndex];
|
||||
const dayOfWeek = parseInt(selectedOption.getAttribute('data-day'));
|
||||
const selectedStart = startTimeSelect.value;
|
||||
|
||||
if (!selectedStart) {
|
||||
endTimeSelect.innerHTML = '<option value="">-- Select Start Time First --</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get available minutes for this day
|
||||
const dayAvailability = coachAvailability.filter(av => av.day_of_week === dayOfWeek);
|
||||
const availableMinutes = [];
|
||||
dayAvailability.forEach(av => {
|
||||
const startMinutes = av.start_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0);
|
||||
const endMinutes = av.end_time.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0);
|
||||
for (let m = startMinutes; m < endMinutes; m += 30) {
|
||||
availableMinutes.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert selected start to minutes
|
||||
const startMinutesVal = selectedStart.split(':').reduce((acc, val, i) => acc + parseInt(val) * (i === 0 ? 60 : 1), 0);
|
||||
|
||||
// Filter end times that are after start time
|
||||
const validEndTimes = availableMinutes.filter(m => m > startMinutesVal);
|
||||
validEndTimes.sort((a, b) => a - b);
|
||||
|
||||
// Update end time options
|
||||
endTimeSelect.innerHTML = '<option value="">-- Select End Time --</option>';
|
||||
validEndTimes.forEach(m => {
|
||||
const hour = Math.floor(m / 60);
|
||||
const minute = m % 60;
|
||||
const timeStr = (hour < 10 ? '0' : '') + hour + ':' + (minute < 10 ? '0' : '') + minute;
|
||||
const slotData = TIME_SLOTS.find(s => s.time === timeStr);
|
||||
if (slotData) {
|
||||
const option = document.createElement('option');
|
||||
option.value = slotData.time;
|
||||
option.textContent = slotData.display;
|
||||
endTimeSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Personal Notes - TryoutPro{% endblock %}
|
||||
{% block page_title %}Personal Notes{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Personal Notes</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user-friends"></i> Add Personal Note</h3>
|
||||
{% if org_team %}
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.manage_personal_notes') }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player_id">Select Player</label>
|
||||
<select name="player_id" id="player_id" class="form-select" required>
|
||||
<option value="">-- Select a Player --</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}">{{ player.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Note Content</label>
|
||||
<textarea name="content" id="content" class="form-textarea" rows="4" placeholder="Enter personal feedback or coaching tips for this player..." required></textarea>
|
||||
<p class="form-text">These notes will only be visible to the selected player.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Add Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if personal_notes %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-sticky-note"></i> Recent Notes</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-grid">
|
||||
{% for note in personal_notes %}
|
||||
<div class="detail-item full-width mb-4">
|
||||
<span class="detail-label">
|
||||
<i class="fas fa-user"></i> {{ note.player.username if note.player else 'Unknown Player' }} -
|
||||
<span class="text-muted">{{ note.created_at.strftime('%B %d, %Y') if note.created_at else 'Unknown date' }}</span>
|
||||
</span>
|
||||
<span class="detail-value">{{ note.content | nl2br if note.content else '' }}</span>
|
||||
<span class="detail-label text-muted small">From: {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}My Notes - TryoutPro{% endblock %}
|
||||
{% block page_title %}My Notes{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / One on One / My Notes</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-grid">
|
||||
<!-- Personal Notes Section -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user"></i> Personal Notes</h3>
|
||||
{% if org_team %}
|
||||
<span class="badge badge-coach">Team: {{ org_team.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if personal_notes %}
|
||||
{% for note in personal_notes %}
|
||||
<div class="detail-grid mt-4">
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">
|
||||
<i class="fas fa-sticky-note"></i> Note from {{ note.coach.username if note.coach else 'Unknown Coach' }}
|
||||
</span>
|
||||
<span class="detail-value">{{ note.content | nl2br }}</span>
|
||||
<div class="mt-2">
|
||||
{% if note.match_id and note.match %}
|
||||
<span class="badge badge-info" title="From match">
|
||||
<i class="fas fa-futbol"></i> Match: {{ note.match.title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if note.team_id and note.team %}
|
||||
<span class="badge badge-warning" title="From team">
|
||||
<i class="fas fa-users"></i> Team: {{ note.team.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if note.tryout_id and note.tryout %}
|
||||
<span class="badge badge-success" title="From tryout">
|
||||
<i class="fas fa-calendar-alt"></i> Tryout: {{ note.tryout.title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2">Added: {{ note.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No personal notes have been added yet. Your coach may provide individual feedback here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Notes Section -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Team Notes</h3>
|
||||
{% if org_team %}
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if team_notes %}
|
||||
{% for note in team_notes %}
|
||||
<div class="detail-grid mt-4">
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label"><i class="fas fa-user"></i> Coach: {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
|
||||
<span class="detail-value">{{ note.content | nl2br }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2">Updated: {{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No team notes have been added yet. Your coach will post improvement suggestions here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body text-center">
|
||||
<a href="{{ url_for('users.one_on_one') }}" class="btn btn-primary">
|
||||
<i class="fas fa-calendar-check"></i> Request One on One Session
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Players to Evaluate - TryoutPro{% endblock %}
|
||||
{% block page_title %}Players to Evaluate{% 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 %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Contact</th>
|
||||
<th>Attendance</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
<span>{{ entry.player.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ entry.player.email }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.evaluated %}
|
||||
<span class="badge badge-success">Evaluated</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Not Evaluated</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No players registered for this tryout.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,184 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}My Profile - TryoutPro{% endblock %}
|
||||
{% block page_title %}My Profile{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Profile</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('users.list_contracts') }}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-file-contract"></i> Contracts
|
||||
</a>
|
||||
<a href="{{ url_for('users.edit_profile') }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit Profile
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user-circle"></i> Account Information</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="profile-header">
|
||||
<div class="user-avatar avatar-xl">{{ user.username[:2] | upper }}</div>
|
||||
<div class="profile-info">
|
||||
<h2>{{ user.username }}</h2>
|
||||
<span class="text-muted" style="font-size: 1.1em;">{{ user.full_name }}</span>
|
||||
<span class="badge badge-{{ user.role }} badge-lg">{{ user.role | capitalize }}</span>
|
||||
<p class="text-muted">Member since {{ user.created_at.strftime('%B %Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-grid mt-4">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Username</span>
|
||||
<span class="detail-value">{{ user.username }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Email</span>
|
||||
<span class="detail-value">{{ user.email }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Phone</span>
|
||||
<span class="detail-value">{{ user.phone or 'Not provided' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Account Status</span>
|
||||
<span class="detail-value">
|
||||
{% if user.is_active_account %}
|
||||
<span class="badge badge-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger">Inactive</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- E-Sports Profile Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-gamepad"></i> E-Sports Profile</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><i class="fas fa-gamepad"></i> Gamertags</span>
|
||||
<span class="detail-value">
|
||||
{% set games_list = user.get_games_list() %}
|
||||
{% if games_list %}
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
{% for game in games_list %}
|
||||
{% set gt = user.gamertags | selectattr('game', 'equalto', game) | first %}
|
||||
<div>
|
||||
{% if gt and gt.get_trn_url() %}
|
||||
<a href="{{ gt.get_trn_url() }}" target="_blank" rel="noopener noreferrer" class="trn-link">
|
||||
<span class="badge badge-esport" style="margin-right: 8px;">{{ game }}</span>
|
||||
<i class="fas fa-external-link-alt"></i> {{ gt.gamertag }}
|
||||
{% if gt.platform %}<small>({{ gt.platform }})</small>{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="badge badge-esport">{{ game }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">Not specified</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><i class="fab fa-discord"></i> Discord</span>
|
||||
<span class="detail-value">
|
||||
{% if user.discord_username %}
|
||||
{{ user.discord_username }}
|
||||
{% else %}
|
||||
<span class="text-muted">Not connected</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><i class="fas fa-link"></i> League OS</span>
|
||||
<span class="detail-value">
|
||||
{% if user.league_os_profile %}
|
||||
<a href="{{ user.league_os_profile }}" target="_blank" rel="noopener noreferrer" class="trn-link">
|
||||
<i class="fas fa-external-link-alt"></i> {{ user.league_os_profile }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Not connected</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-bar"></i> My Statistics</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if user.role == 'player' %}
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<h4>{{ user.tryout_registrations.count() }}</h4>
|
||||
<p>Tryouts Registered</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h4>{{ user.team_assignments.count() }}</h4>
|
||||
<p>Team Assignments</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif user.can_evaluate() %}
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<h4>{{ user.evaluations_given.count() }}</h4>
|
||||
<p>Evaluations Given</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No statistics available for this role.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contracts Card -->
|
||||
{% if user.role == 'player' %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-file-contract"></i> My Contracts</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if contracts %}
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Contracts Pending</span>
|
||||
<span class="detail-value">
|
||||
{% set pending = contracts | selectattr('status', 'equalto', 'pending') | list %}
|
||||
<span class="badge badge-{{ 'warning' if pending|length > 0 else 'success' }}">{{ pending | length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Contracts Signed</span>
|
||||
<span class="detail-value">
|
||||
{% set signed = contracts | selectattr('status', 'equalto', 'signed') | list %}
|
||||
<span class="badge {% if signed %}badge-success{% else %}badge-secondary{% endif %}">{{ signed | length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('users.list_contracts') }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-folder-open"></i> View All Contracts
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No contracts have been uploaded for you yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Register - TryoutPro{% endblock %}
|
||||
{% block auth_content %}
|
||||
<form method="POST" action="{{ url_for('auth.register') }}" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="full_name"><i class="fas fa-id-card"></i> Full Name</label>
|
||||
<input type="text" id="full_name" name="full_name" placeholder="Enter your full name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username"><i class="fas fa-user"></i> Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Choose a username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email"><i class="fas fa-envelope"></i> Email</label>
|
||||
<input type="email" id="email" name="email" placeholder="Enter your email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone"><i class="fas fa-phone"></i> Phone (Optional)</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="Enter your phone number">
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h4 class="section-title"><i class="fas fa-gamepad"></i> E-Sports Profile</h4>
|
||||
<p class="text-muted small">Set up your competitive gaming profile for tryouts.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label><i class="fas fa-headset"></i> Games You Play</label>
|
||||
<div class="checkbox-grid">
|
||||
{% for game in esport_games %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="games" value="{{ game }}">
|
||||
<span>{{ game }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small class="form-text text-muted">Select all games you're signing in for.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="trn_username"><i class="fas fa-chart-line"></i> TRN (Tracker Network) Username</label>
|
||||
<input type="text" id="trn_username" name="trn_username" placeholder="e.g. YourTrackerGGUsername">
|
||||
<small class="form-text text-muted">Your public Tracker Network profile name. Others can click it to view your stats.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discord_username"><i class="fab fa-discord"></i> Discord Username</label>
|
||||
<input type="text" id="discord_username" name="discord_username" placeholder="e.g. YourName#1234">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="league_os_profile"><i class="fas fa-link"></i> League OS Connection</label>
|
||||
<input type="text" id="league_os_profile" name="league_os_profile" placeholder="League OS profile link or ID">
|
||||
<small class="form-text text-muted">Connect your League OS profile for organized play.</small>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<div class="form-group">
|
||||
<label for="password"><i class="fas fa-lock"></i> Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Create a password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password"><i class="fas fa-check-circle"></i> Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" placeholder="Confirm your password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Account</button>
|
||||
<p class="auth-link">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,149 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{% if match %}Edit{% else %}Schedule{% endif %} Team Match - TryoutPro{% endblock %}
|
||||
{% block page_title %}{% if match %}Edit{% else %}Schedule{% endif %} Team Match{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('team_matches.list_matches') }}">Team Matches</a> / {% if match %}Edit{% else %}New{% endif %}</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="title">Match Title</label>
|
||||
<input type="text" id="title" name="title" class="form-input"
|
||||
value="{{ match.title if match else 'Team Match — ' + team.name }}"
|
||||
placeholder="e.g., Regular Season vs Opponent" required>
|
||||
</div>
|
||||
{% if not is_practice %}
|
||||
<div class="form-group col-6">
|
||||
<label for="opponent">Opponent (optional)</label>
|
||||
<input type="text" id="opponent" name="opponent" class="form-input"
|
||||
value="{{ match.opponent if match else '' }}"
|
||||
placeholder="e.g., University of Toronto">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date" name="date" class="form-input"
|
||||
value="{{ match.date.strftime('%Y-%m-%d') if match else '' }}"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="start_time">Start Time</label>
|
||||
<input type="time" id="start_time" name="start_time" class="form-input"
|
||||
value="{{ match.start_time.strftime('%H:%M') if match and match.start_time else '' }}">
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="end_time">End Time</label>
|
||||
<input type="time" id="end_time" name="end_time" class="form-input"
|
||||
value="{{ match.end_time.strftime('%H:%M') if match and match.end_time else '' }}">
|
||||
<small class="text-muted">Auto-calculated if left empty (start + 30 min)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="location">Location</label>
|
||||
<input type="text" id="location" name="location" class="form-input"
|
||||
value="{{ match.location if match else '' }}"
|
||||
placeholder="e.g., Online, Gym A">
|
||||
</div>
|
||||
{% if match %}
|
||||
<div class="form-group col-6">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status" class="form-select">
|
||||
<option value="scheduled" {% if match.status == 'scheduled' %}selected{% endif %}>Scheduled</option>
|
||||
<option value="completed" {% if match.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="cancelled" {% if match.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<textarea id="description" name="description" class="form-input" rows="3"
|
||||
placeholder="Additional details about the match...">{{ match.description if match else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
{% if not match %}
|
||||
<!-- Player roster (pre-filled, read-only display) -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h4><i class="fas fa-users"></i> Team Roster (auto-included)</h4>
|
||||
<span class="text-muted" style="font-size: 0.85rem;">All {{ team_players | length }} player(s) will be added automatically</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if team_players %}
|
||||
<div class="roster-list" style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
{% for tp in team_players %}
|
||||
<span class="staff-tag">
|
||||
{{ tp.player.username }}
|
||||
{% if tp.status == 'substitute' %}
|
||||
<span class="text-muted" style="font-size: 0.7rem;">(sub)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-3">
|
||||
<i class="fas fa-users-slash"></i> No players on this team. Add players in the Teams page first.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Edit mode: show participants with presence status -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h4><i class="fas fa-users"></i> Participants</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if match.participants %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Presence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in match.participants.all() %}
|
||||
<tr>
|
||||
<td>{{ p.player.username if p.player else 'Unknown' }}</td>
|
||||
<td>
|
||||
{% if p.is_confirmed %}
|
||||
<span class="badge badge-success">✅ Confirmed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">⏳ Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted text-center">No participants recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions mt-4">
|
||||
<a href="{{ url_for('team_matches.list_matches') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {% if match %}Save Changes{% else %}Schedule Match{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,230 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Team Matches - TryoutPro{% endblock %}
|
||||
{% block page_title %}Team Matches{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Team Matches</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
{% if teams %}
|
||||
<div class="header-actions">
|
||||
<select id="teamSelect" class="form-select" style="width:200px;" onchange="window.location.href='/team-matches/' + this.value + '/create'">
|
||||
<option value="">+ Schedule Match</option>
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}">{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if match_data %}
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Match</th>
|
||||
<th>Team</th>
|
||||
<th>Opponent</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Location</th>
|
||||
<th>Presence</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in match_data %}
|
||||
{% set m = item.match %}
|
||||
<tr>
|
||||
<td class="cell-title">{{ m.title }}</td>
|
||||
<td>
|
||||
<span class="badge badge-info">{{ m.org_team.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if m.opponent %}
|
||||
{{ m.opponent }}
|
||||
{% else %}
|
||||
<span class="badge badge-info">Practice</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ m.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td>
|
||||
{% if m.start_time and m.end_time %}
|
||||
{{ m.start_time.strftime('%H:%M') }} - {{ m.end_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
TBD
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ m.location or '—' }}</td>
|
||||
<td>
|
||||
{% if item.total_count > 0 %}
|
||||
<div class="presence-bar" title="{{ item.confirmed_count }} of {{ item.total_count }} confirmed">
|
||||
<div class="presence-progress">
|
||||
{% set pct = (item.confirmed_count / item.total_count * 100) | int %}
|
||||
<div class="presence-fill" style="width: {{ pct }}%;"></div>
|
||||
</div>
|
||||
<span class="presence-text">
|
||||
{% if item.confirmed_count == item.total_count and item.total_count > 0 %}
|
||||
✅ {{ item.confirmed_count }}/{{ item.total_count }}
|
||||
{% elif item.confirmed_count > 0 %}
|
||||
⏳ {{ item.confirmed_count }}/{{ item.total_count }}
|
||||
{% else %}
|
||||
❌ 0/{{ item.total_count }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Player presence details -->
|
||||
<div class="presence-players">
|
||||
{% for p in item.participants %}
|
||||
<span class="presence-player-tag {% if p.is_confirmed %}confirmed{% else %}pending{% endif %}"
|
||||
title="{{ p.player.username }}{% if p.is_confirmed %} - Confirmed{% else %} - Pending{% endif %}">
|
||||
{{ p.player.username[:2] | upper }} {{ p.player.username }}
|
||||
{% if p.is_confirmed %}✅{% else %}⏳{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
||||
</td>
|
||||
<td class="eval-actions">
|
||||
{% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
|
||||
{% if can_manage_this %}
|
||||
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="Edit Match">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" onsubmit="return confirm('Delete this match?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete Match">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<!-- Toggle presence for each participant if user is player -->
|
||||
{% if current_user.role == 'player' %}
|
||||
{% for p in item.participants %}
|
||||
{% if p.player.id == current_user.id %}
|
||||
<button class="btn btn-sm {% if p.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
|
||||
data-match-id="{{ m.id }}"
|
||||
data-participant-id="{{ p.id }}"
|
||||
onclick="togglePresence(this)">
|
||||
{% if p.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-futbol fa-3x text-muted mb-3"></i>
|
||||
<h3>No Team Matches</h3>
|
||||
<p class="text-muted">Regular season matches have not been scheduled yet.</p>
|
||||
{% if teams %}
|
||||
<div class="mt-3">
|
||||
<select id="teamSelectEmpty" class="form-select" style="width:220px; display:inline;" onchange="window.location.href='/team-matches/' + this.value + '/create'">
|
||||
<option value="">-- Schedule a Match --</option>
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}">{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function togglePresence(btn) {
|
||||
var matchId = btn.getAttribute('data-match-id');
|
||||
var participantId = btn.getAttribute('data-participant-id');
|
||||
|
||||
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.is_confirmed) {
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-outline');
|
||||
btn.innerHTML = '✅ Confirmed';
|
||||
} else {
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-outline');
|
||||
btn.innerHTML = 'Confirm';
|
||||
}
|
||||
// Reload to update presence bar
|
||||
location.reload();
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.presence-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.presence-progress {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.presence-fill {
|
||||
height: 100%;
|
||||
background: #10b981;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.presence-text {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.presence-players {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.presence-player-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.presence-player-tag.confirmed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.presence-player-tag.pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.presence-toggle-btn {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Team Notes - TryoutPro{% endblock %}
|
||||
{% block page_title %}Team Notes{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Team Notes</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Team Improvement Notes</h3>
|
||||
{% if org_team %}
|
||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Team Notes Content</label>
|
||||
<textarea name="content" id="content" class="form-textarea" rows="6" placeholder="Enter improvement suggestions and notes for your team...">{{ team_notes[0].content if team_notes else '' }}</textarea>
|
||||
<p class="form-text">These notes will be visible to all players on your team.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add Team Notes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if team_notes %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-history"></i> Note History</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last Updated</th>
|
||||
<th>Content Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for note in team_notes %}
|
||||
<tr>
|
||||
<td>{{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') if note.updated_at else 'Unknown date' }}</td>
|
||||
<td>{{ note.content[:100] if note.content else '' }}{% if note.content and note.content|length > 100 %}...{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,542 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Teams - TryoutPro{% endblock %}
|
||||
{% block page_title %}Teams{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Teams</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
{% if can_manage %}
|
||||
<button class="btn btn-primary" onclick="showCreateForm()">
|
||||
<i class="fas fa-plus"></i> New Team
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if can_manage %}
|
||||
<div id="createTeamForm" class="card mb-4 {% if not form_visible %}hidden{% endif %}">
|
||||
<div class="card-header">
|
||||
<h3>Create New Team</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('teams.create_team') }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-4">
|
||||
<label for="name">Team Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="e.g., Varsity, JV, U14" required>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="coach_id">Assigned Coach</label>
|
||||
<select id="coach_id" name="coach_id" class="form-select">
|
||||
<option value="">-- No coach assigned --</option>
|
||||
{% for coach in coaches %}
|
||||
<option value="{{ coach.id }}">{{ coach.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="manager_id">Assigned Manager</label>
|
||||
<select id="manager_id" name="manager_id" class="form-select">
|
||||
<option value="">-- No manager assigned --</option>
|
||||
{% for manager in managers %}
|
||||
<option value="{{ manager.id }}">{{ manager.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="hideCreateForm()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Team</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for team in teams %}
|
||||
{% set can_manage_team = can_manage or (current_user.role == 'coach' and team.coach_id == current_user.id) %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users-cog"></i> {{ team.name }}</h3>
|
||||
<div class="card-actions">
|
||||
{% if can_manage %}
|
||||
<button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Delete team {{ team.name }}? This will unassign it from any linked tryouts.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'coach' and team.coach_id == current_user.id %}
|
||||
<a href="{{ url_for('users.notes_dashboard') }}" class="btn btn-sm btn-primary" title="Add Notes">
|
||||
<i class="fas fa-sticky-note"></i> Notes
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Full-width staff bar for coaches and managers -->
|
||||
<div class="team-staff-bar">
|
||||
<div class="staff-row">
|
||||
<div class="staff-group">
|
||||
<span class="staff-label"><i class="fas fa-chalkboard-teacher"></i> Coaches</span>
|
||||
<div class="staff-items">
|
||||
{% set team_coaches = team.get_coaches() %}
|
||||
{% if team_coaches %}
|
||||
{% for c in team_coaches %}
|
||||
<span class="staff-tag">
|
||||
{{ c.username }}
|
||||
{% if can_manage %}
|
||||
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove coach {{ c.username }} from {{ team.name }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="coach_id" value="{{ c.id }}"/>
|
||||
<button type="submit" class="btn-icon-sm" title="Remove Coach">×</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted text-sm">None assigned</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="staff-group">
|
||||
<span class="staff-label"><i class="fas fa-user-tie"></i> Managers</span>
|
||||
<div class="staff-items">
|
||||
{% set team_managers = team.get_managers() %}
|
||||
{% if team_managers %}
|
||||
{% for m in team_managers %}
|
||||
<span class="staff-tag manager-tag">
|
||||
{{ m.username }}
|
||||
{% if can_manage %}
|
||||
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove manager {{ m.username }} from {{ team.name }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="manager_id" value="{{ m.id }}"/>
|
||||
<button type="submit" class="btn-icon-sm" title="Remove Manager">×</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted text-sm">None assigned</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="View Team Matches">
|
||||
<i class="fas fa-futbol"></i> Matches
|
||||
</a>
|
||||
{% if current_user.can_schedule_matches() and (current_user.role in ['admin', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %}
|
||||
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="Schedule Team Match">
|
||||
<i class="fas fa-plus"></i> Match
|
||||
</a>
|
||||
<a href="{{ url_for('team_matches.create_match', team_id=team.id, type='practice') }}" class="btn btn-sm btn-info" title="Schedule Practice">
|
||||
<i class="fas fa-dumbbell"></i> Practice
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Status</th>
|
||||
<th>Position / Role</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
{% if can_manage_team %}
|
||||
<th>Actions</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in team.get_players_with_status() %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if can_manage_team %}
|
||||
<button class="btn btn-sm status-toggle-btn {% if entry.status == 'starter' %}btn-success{% else %}btn-warning{% endif %}"
|
||||
data-team-id="{{ team.id }}"
|
||||
data-player-id="{{ entry.player.id }}"
|
||||
onclick="toggleStatus(this)">
|
||||
{{ entry.status | capitalize }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="badge {% if entry.status == 'starter' %}badge-success{% else %}badge-warning{% endif %}">
|
||||
{{ entry.status | capitalize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.player.role | capitalize }}</td>
|
||||
<td>{{ entry.player.email }}</td>
|
||||
<td>{{ entry.player.phone or '-' }}</td>
|
||||
{% if can_manage_team %}
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ entry.player.username }} from {{ team.name }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-user-minus"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{% if can_manage_team %}6{% else %}5{% endif %}" class="text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-users-slash"></i>
|
||||
<h4>No players assigned</h4>
|
||||
{% if can_manage_team %}
|
||||
<p>Add players to this team using the form below.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can_manage_team %}
|
||||
<hr class="my-3">
|
||||
<div class="add-player-section">
|
||||
<h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5>
|
||||
<form method="POST" action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="player_id" class="form-select" required>
|
||||
<option value="">-- Select a player --</option>
|
||||
{% for p in all_players %}
|
||||
{% if p.id not in team.players | map(attribute='id') %}
|
||||
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="status" class="form-select ml-2">
|
||||
<option value="starter">Starter</option>
|
||||
<option value="substitute">Substitute</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary ml-2">
|
||||
<i class="fas fa-plus"></i> Add to Team
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<h3>No teams yet</h3>
|
||||
{% if can_manage %}
|
||||
<p>Create organization teams and assign coaches to manage tryouts.</p>
|
||||
<button class="btn btn-primary" onclick="showCreateForm()">Create Team</button>
|
||||
{% else %}
|
||||
<p>There are no teams to display.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Edit Team Modal -->
|
||||
<div id="editTeamModal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="hideEditForm()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Team</h3>
|
||||
<button class="modal-close" onclick="hideEditForm()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editTeamForm" method="POST" action="" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="sync_staff" value="1"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit_name">Team Name</label>
|
||||
<input type="text" id="edit_name" name="name" required>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
|
||||
<!-- Manage Coaches -->
|
||||
<h5 class="mb-2"><i class="fas fa-chalkboard-teacher"></i> Coaches</h5>
|
||||
<div class="form-group">
|
||||
<select name="coach_ids" id="edit-coach-select" class="form-select" multiple style="min-height: 100px; width: 100%;">
|
||||
{% for coach in coaches %}
|
||||
<option value="{{ coach.id }}" class="coach-option">{{ coach.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text">Hold Ctrl/Cmd to select multiple. Only unassigned coaches shown.</small>
|
||||
</div>
|
||||
|
||||
<!-- Manage Managers -->
|
||||
<h5 class="mb-2"><i class="fas fa-user-tie"></i> Managers</h5>
|
||||
<div class="form-group">
|
||||
<select name="manager_ids" id="edit-manager-select" class="form-select" multiple style="min-height: 100px; width: 100%;">
|
||||
{% for manager in managers %}
|
||||
<option value="{{ manager.id }}" class="manager-option">{{ manager.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text">Hold Ctrl/Cmd to select multiple. Only unassigned managers shown.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="hideEditForm()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showCreateForm() {
|
||||
document.getElementById('createTeamForm').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideCreateForm() {
|
||||
document.getElementById('createTeamForm').classList.add('hidden');
|
||||
}
|
||||
|
||||
function toggleStatus(btn) {
|
||||
var teamId = btn.getAttribute('data-team-id');
|
||||
var playerId = btn.getAttribute('data-player-id');
|
||||
|
||||
fetch('/teams/' + teamId + '/toggle_status/' + playerId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
btn.textContent = data.new_status.charAt(0).toUpperCase() + data.new_status.slice(1);
|
||||
if (data.new_status === 'starter') {
|
||||
btn.classList.remove('btn-warning');
|
||||
btn.classList.add('btn-success');
|
||||
} else {
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-warning');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
var currentEditTeamId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.edit-team-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
currentEditTeamId = this.getAttribute('data-team-id');
|
||||
var teamName = this.getAttribute('data-team-name');
|
||||
document.getElementById('editTeamForm').action = '/teams/' + currentEditTeamId + '/edit';
|
||||
document.getElementById('edit_name').value = teamName;
|
||||
document.getElementById('editTeamModal').classList.remove('hidden');
|
||||
populateEditSelects(currentEditTeamId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getCurrentStaffIds(teamId, type) {
|
||||
// Get currently assigned coach/manager IDs from the staff bar pills
|
||||
var ids = [];
|
||||
var teamCard = document.querySelector('[data-team-id="' + teamId + '"]');
|
||||
if (!teamCard) return ids;
|
||||
|
||||
var staffBar = teamCard.closest('.card').querySelector('.team-staff-bar');
|
||||
if (!staffBar) return ids;
|
||||
|
||||
var groupIndex = type === 'coach' ? 0 : 1;
|
||||
var group = staffBar.querySelectorAll('.staff-group')[groupIndex];
|
||||
if (!group) return ids;
|
||||
|
||||
group.querySelectorAll('.staff-tag form input[name="coach_id"], .staff-tag form input[name="manager_id"]').forEach(function(input) {
|
||||
ids.push(input.value);
|
||||
});
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function populateEditSelects(teamId) {
|
||||
var coachSelect = document.getElementById('edit-coach-select');
|
||||
var managerSelect = document.getElementById('edit-manager-select');
|
||||
|
||||
var currentCoachIds = getCurrentStaffIds(teamId, 'coach');
|
||||
var currentManagerIds = getCurrentStaffIds(teamId, 'manager');
|
||||
|
||||
// Show all coaches, but pre-select current ones and hide non-assigned
|
||||
// Actually: show only currently-assigned options (pre-selected)
|
||||
coachSelect.querySelectorAll('.coach-option').forEach(function(opt) {
|
||||
var isAssigned = currentCoachIds.includes(opt.value);
|
||||
opt.selected = isAssigned;
|
||||
// Always show all options so user can add/remove
|
||||
opt.style.display = '';
|
||||
});
|
||||
|
||||
managerSelect.querySelectorAll('.manager-option').forEach(function(opt) {
|
||||
var isAssigned = currentManagerIds.includes(opt.value);
|
||||
opt.selected = isAssigned;
|
||||
opt.style.display = '';
|
||||
});
|
||||
|
||||
// Also update text to reflect current count
|
||||
document.querySelector('#edit-coach-select + .form-text').textContent =
|
||||
'Currently assigned: ' + currentCoachIds.length + '. Hold Ctrl/Cmd to select multiple.';
|
||||
document.querySelector('#edit-manager-select + .form-text').textContent =
|
||||
'Currently assigned: ' + currentManagerIds.length + '. Hold Ctrl/Cmd to select multiple.';
|
||||
}
|
||||
|
||||
function hideEditForm() {
|
||||
document.getElementById('editTeamModal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* === Full-width staff bar layout === */
|
||||
.team-staff-bar {
|
||||
padding: 10px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.staff-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.staff-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.staff-label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.staff-label i {
|
||||
margin-right: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.staff-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.staff-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
padding: 3px 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.staff-tag.manager-tag {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.staff-tag .btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-left: 1px;
|
||||
}
|
||||
.staff-tag .btn-icon-sm:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
.staff-add-forms {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.staff-add-forms .form-select-sm {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 28px 4px 10px;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
background: #fff;
|
||||
min-width: 150px;
|
||||
}
|
||||
.staff-add-forms .form-select-sm:focus {
|
||||
border-color: #6366f1;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(99,102,241,0.15);
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.status-toggle-btn {
|
||||
cursor: pointer;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
/* === Dark mode overrides for staff bar === */
|
||||
[data-theme="dark"] .team-staff-bar {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
[data-theme="dark"] .staff-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .staff-tag {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
[data-theme="dark"] .staff-tag.manager-tag {
|
||||
background: rgba(229, 169, 57, 0.2);
|
||||
color: #fcd34d;
|
||||
}
|
||||
[data-theme="dark"] .staff-tag .btn-icon-sm {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .staff-tag .btn-icon-sm:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
[data-theme="dark"] .staff-add-forms .form-select-sm {
|
||||
background: var(--input-bg);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
[data-theme="dark"] .staff-add-forms .form-select-sm:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 152, 76, 0.25);
|
||||
}
|
||||
[data-theme="dark"] .staff-add-forms .form-select-sm option {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{% if tryout %}Edit Tryout{% else %}Create Tryout{% endif %} - TryoutPro{% endblock %}
|
||||
{% block page_title %}{% if tryout %}Edit Tryout{% else %}Create Tryout{% endif %}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<span class="breadcrumb">
|
||||
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
|
||||
{% if tryout %}
|
||||
/ <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a>
|
||||
/ Edit
|
||||
{% else %}
|
||||
/ Create
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{% if tryout %}{{ url_for('tryouts.edit_tryout', tryout_id=tryout.id) }}{% else %}{{ url_for('tryouts.create_tryout') }}{% endif %}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-12">
|
||||
<label for="title">Tryout Title</label>
|
||||
<input type="text" id="title" name="title" value="{{ tryout.title if tryout else '' }}" placeholder="e.g., Spring Season Tryouts" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="game">Game</label>
|
||||
<select id="game" name="game" class="form-select" required>
|
||||
<option value="">-- Select a game --</option>
|
||||
{% for game in esport_games %}
|
||||
<option value="{{ game }}" {% if tryout and tryout.game == game %}selected{% endif %}>{{ game }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date" name="date" value="{{ tryout.date.strftime('%Y-%m-%d') if tryout else '' }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="location">Location</label>
|
||||
<input type="text" id="location" name="location" value="{{ tryout.location or '' if tryout else '' }}" placeholder="e.g., Online">
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="max_players">Max Players</label>
|
||||
<input type="number" id="max_players" name="max_players" value="{{ tryout.max_players or '' if tryout else '' }}" placeholder="Leave blank for unlimited" min="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label for="target_org_team_id">Target Team</label>
|
||||
<select id="target_org_team_id" name="target_org_team_id" class="form-select">
|
||||
<option value="">-- No target team --</option>
|
||||
{% for team in org_teams %}
|
||||
<option value="{{ team.id }}" {% if tryout and tryout.target_org_team_id == team.id %}selected{% endif %}>
|
||||
{{ team.name }}{% if team.coach %} (Coach: {{ team.coach.username }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label for="manager_id">Assigned Manager</label>
|
||||
<select id="manager_id" name="manager_id" class="form-select">
|
||||
<option value="">-- No manager assigned --</option>
|
||||
{% for manager in managers %}
|
||||
<option value="{{ manager.id }}" {% if tryout and tryout.manager_id == manager.id %}selected{% endif %}>
|
||||
{{ manager.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-12">
|
||||
<label for="coach_id">Assigned Coach</label>
|
||||
<select id="coach_id" name="coach_id" class="form-select">
|
||||
<option value="">-- No coach assigned --</option>
|
||||
{% for coach in coaches %}
|
||||
<option value="{{ coach.id }}" {% if tryout and tryout.coach_id == coach.id %}selected{% endif %}>
|
||||
{{ coach.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="4" placeholder="Enter any details about the tryout...">{% if tryout %}{{ tryout.description or '' }}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="{% if tryout %}{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}{% else %}{{ url_for('tryouts.list_tryouts') }}{% endif %}" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if tryout %}Save Changes{% else %}Create Tryout{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Tryouts - TryoutPro{% endblock %}
|
||||
{% block page_title %}Tryouts{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Tryouts</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
{% if current_user.can_manage_tryouts() %}
|
||||
<a href="{{ url_for('tryouts.create_tryout') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> New Tryout
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tryouts-grid">
|
||||
{% for tryout in tryouts %}
|
||||
<div class="card tryout-card">
|
||||
<div class="card-header">
|
||||
<h3 class="tryout-title">{{ tryout.title }}</h3>
|
||||
<span class="badge badge-{{ tryout.status }}">{{ tryout.status | replace('_', ' ') | title }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tryout-info-grid">
|
||||
<div class="info-item">
|
||||
<i class="fas fa-gamepad info-icon"></i>
|
||||
<span class="info-label">Game</span>
|
||||
<span class="info-value">{{ tryout.game }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="fas fa-calendar info-icon"></i>
|
||||
<span class="info-label">Date</span>
|
||||
<span class="info-value">{{ tryout.date.strftime('%b %d, %Y') }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="fas fa-map-marker-alt info-icon"></i>
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">{{ tryout.location or 'TBD' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="fas fa-users info-icon"></i>
|
||||
<span class="info-label">Players</span>
|
||||
<span class="info-value">{{ tryout.registrations.count() }}</span>
|
||||
</div>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-clipboard-check info-icon"></i>
|
||||
<span class="info-label">Evaluations</span>
|
||||
<span class="info-value">{{ tryout.evaluations.count() }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tryout.target_org_team %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-flag info-icon"></i>
|
||||
<span class="info-label">Target Team</span>
|
||||
<span class="info-value">{{ tryout.target_org_team.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tryout.max_players %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-user-friends info-icon"></i>
|
||||
<span class="info-label">Max Players</span>
|
||||
<span class="info-value">{{ tryout.max_players }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if tryout.description %}
|
||||
<div class="tryout-description mt-3">
|
||||
<p>{{ tryout.description | truncate(120) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-eye"></i> View Details
|
||||
</a>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-clipboard-check"></i> Evaluate
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-body text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-calendar-times"></i>
|
||||
<h3>No tryouts found</h3>
|
||||
{% if current_user.can_manage_tryouts() %}
|
||||
<p>Get started by creating a new tryout.</p>
|
||||
<a href="{{ url_for('tryouts.create_tryout') }}" class="btn btn-primary">Create Tryout</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Upload Contract - TryoutPro{% endblock %}
|
||||
{% block page_title %}Upload Contract{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_contracts') }}">Contracts</a> / Upload</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-upload"></i> Upload Contract for Player</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.upload_contract') }}" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player_id">Select Player</label>
|
||||
<select id="player_id" name="player_id" class="form-select" required>
|
||||
<option value="">-- Select a player --</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}">{{ player.username }}{% if player.org_team %} - {{ player.org_team.name }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contract_file">Contract File</label>
|
||||
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
|
||||
<small class="form-text text-muted">Accepted formats: PDF, DOC, DOCX, JPG, PNG</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes (Optional)</label>
|
||||
<textarea id="notes" name="notes" rows="3" placeholder="Add any notes about this contract..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('users.list_contracts') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload Contract
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Manage Users - TryoutPro{% endblock %}
|
||||
{% block page_title %}Manage Users{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Users</span>{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="{{ url_for('users.create_user') }}" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i> Add User
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ u.username[:2] | upper }}</div>
|
||||
<span>{{ u.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.email }}</td>
|
||||
<td><span class="badge badge-{{ u.role }}">{{ u.role | capitalize }}</span></td>
|
||||
<td>
|
||||
{% if u.is_active_account %}
|
||||
<span class="badge badge-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ u.created_at.strftime('%m/%d/%Y') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('users.edit_user', user_id=u.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
{% if u.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('users.delete_user', user_id=u.id) }}" class="inline-form" onsubmit="return confirm('Delete {{ u.username }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,578 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ tryout.title }} - TryoutPro{% endblock %}
|
||||
{% block page_title %}{{ tryout.title }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a> / {{ tryout.title }}</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tryout-detail">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3>Tryout Details</h3>
|
||||
<div class="card-actions">
|
||||
{% if can_edit %}
|
||||
<a href="{{ url_for('matches.create_match', tryout_id=tryout.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="fas fa-futbol"></i> Schedule Match
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<form method="POST" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="status" onchange="this.form.submit()" class="form-select">
|
||||
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
|
||||
<option value="in_progress" {% if tryout.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="completed" {% if tryout.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<a href="{{ url_for('tryouts.edit_tryout', tryout_id=tryout.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Game</span>
|
||||
<span class="detail-value">{{ tryout.game }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Date</span>
|
||||
<span class="detail-value">{{ tryout.date.strftime('%A, %B %d, %Y') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Location</span>
|
||||
<span class="detail-value">{{ tryout.location or 'Not specified' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Status</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge badge-{{ tryout.status }}">{{ tryout.status | replace('_', ' ') | title }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Target Team</span>
|
||||
<span class="detail-value">{{ tryout.target_org_team.name if tryout.target_org_team else 'Not specified' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Manager</span>
|
||||
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Coach</span>
|
||||
<span class="detail-value">{{ tryout.coach.username if tryout.coach else 'Not assigned' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Registered Players</span>
|
||||
<span class="detail-value">{{ registered_players | length }}</span>
|
||||
</div>
|
||||
{% if tryout.description %}
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">Description</span>
|
||||
<span class="detail-value">{{ tryout.description }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center">
|
||||
<form method="POST" action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-user-plus"></i> Register for this Tryout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user-plus"></i> Add Player to Tryout</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="player_id" class="form-select" required>
|
||||
<option value="">-- Select a player --</option>
|
||||
{% for p in all_players %}
|
||||
{% set is_already = registrations | selectattr('player_id', 'equalto', p.id) | list | length > 0 %}
|
||||
<option value="{{ p.id }}" {% if is_already %}disabled class="text-muted"{% endif %}>{{ p.username }}{% if is_already %} (already registered){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary ml-2">
|
||||
<i class="fas fa-plus"></i> Register Player
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Registered Players ({{ registered_players | length }})</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Status</th>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<th>Evaluation</th>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<th>Actions</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in registered_players %}
|
||||
{% set reg = registrations | selectattr('player_id', 'equalto', p.id) | first %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ p.username[:2] | upper }}</div>
|
||||
<a href="{{ url_for('users.view_user', user_id=p.id) }}">{{ p.username }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if can_edit %}
|
||||
<form method="POST" action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="status" onchange="this.form.submit()" class="form-select form-select-sm">
|
||||
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>Registered</option>
|
||||
<option value="attended" {% if reg.status == 'attended' %}selected{% endif %}>Attended</option>
|
||||
<option value="no_show" {% if reg.status == 'no_show' %}selected{% endif %}>No Show</option>
|
||||
</select>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="badge badge-{{ reg.status }}">{{ reg.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if current_user.can_evaluate() %}
|
||||
<td>
|
||||
{% if player_eval_status.get(p.id) %}
|
||||
<span class="badge badge-success">Evaluated</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<td class="eval-actions">
|
||||
{% if current_user.can_evaluate() %}
|
||||
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=p.id) }}" class="btn btn-sm btn-primary" title="Evaluate player">
|
||||
<i class="fas fa-edit"></i> {% if player_eval_status.get(p.id) %}Edit{% else %}Evaluate{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}?player_id={{ p.id }}" class="btn btn-sm btn-outline" title="Add note for {{ p.username }}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ p.username }} from this tryout? This will also remove them from all teams and matches within this tryout.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Remove player from tryout">
|
||||
<i class="fas fa-user-minus"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{% if can_edit %}{% if current_user.can_evaluate() %}5{% else %}4{% endif %}{% else %}{% if current_user.can_evaluate() %}4{% else %}2{% endif %}{% endif %}" class="text-center">
|
||||
No players registered yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users-cog"></i> Teams</h3>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-primary" onclick="showCreateTeam()">
|
||||
<i class="fas fa-plus"></i> New Team
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if can_edit %}
|
||||
<div id="createTeamForm" class="hidden mb-3">
|
||||
<form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="text" name="team_name" placeholder="Team name" required class="form-input mr-2">
|
||||
<button type="submit" class="btn btn-sm btn-success">Create</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="hideCreateTeam()">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for team in team_data %}
|
||||
<div class="team-card mb-3">
|
||||
<h4 class="team-name">{{ team.team.name }}</h4>
|
||||
<ul class="team-members">
|
||||
{% for member in team.members %}
|
||||
<li>
|
||||
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
|
||||
<span>{{ member.player.username }}</span>
|
||||
{% if member.position %}
|
||||
<span class="position-tag">{{ member.position }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="text-muted">No players assigned yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if can_edit and registered_players %}
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="player_id" class="form-select" required>
|
||||
<option value="">Select player...</option>
|
||||
{% for p in registered_players %}
|
||||
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
||||
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if positions %}
|
||||
<select name="position" class="form-select mx-2">
|
||||
<option value="">-- Select Position --</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}">{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" name="position" placeholder="Position" class="form-input mx-2">
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center">No teams created yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_view_calendar %}
|
||||
<div class="card tryout-schedule-card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-futbol"></i> Schedule</h3>
|
||||
{% if can_edit %}
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('matches.create_match', tryout_id=tryout.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="fas fa-plus"></i> Schedule Match
|
||||
</a>
|
||||
<a href="{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-sticky-note"></i> Add Note
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if matches %}
|
||||
<!-- Matches Table -->
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Match</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Participants</th>
|
||||
<th>Time</th>
|
||||
<th>Presence</th>
|
||||
<th>Status</th>
|
||||
{% if can_edit %}
|
||||
<th>Actions</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in match_data %}
|
||||
{% set m = item.match %}
|
||||
{% set participants = item.participants %}
|
||||
<tr>
|
||||
<td class="cell-title">{{ m.title }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if m.match_type in ['team_vs_team', 'player_vs_player'] else 'warning' }}">
|
||||
{% if m.match_type == 'team_vs_team' %}
|
||||
Team vs Team
|
||||
{% elif m.match_type == 'player_vs_player' %}
|
||||
Player vs Player
|
||||
{% else %}
|
||||
Player Scrim
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ m.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td>
|
||||
{% if m.start_time and m.end_time %}
|
||||
{{ m.start_time.strftime('%H:%M') }} - {{ m.end_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
TBD
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.total_count > 0 %}
|
||||
<span class="presence-summary" title="{{ item.confirmed_count }} of {{ item.total_count }} confirmed">
|
||||
{% if item.confirmed_count == item.total_count and item.total_count > 0 %}
|
||||
<span class="badge badge-success">✅ {{ item.confirmed_count }}/{{ item.total_count }}</span>
|
||||
{% elif item.confirmed_count > 0 %}
|
||||
<span class="badge badge-warning">✅ {{ item.confirmed_count }}/{{ item.total_count }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">⏳ 0/{{ item.total_count }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<!-- Per-player presence toggles -->
|
||||
{% if can_edit and item.player_presence %}
|
||||
<div class="presence-players" style="margin-top:6px;">
|
||||
{% for pp in item.player_presence %}
|
||||
<button class="btn btn-xs presence-toggle-btn {% if pp.attendance_confirmed %}presence-confirmed-btn{% else %}presence-pending-btn{% endif %}"
|
||||
title="{{ pp.player_name }}"
|
||||
onclick="toggleTryoutPresence({{ m.id }}, {{ pp.participant_id }}, this)">
|
||||
{{ pp.player_name[:2] | upper }} {% if pp.attendance_confirmed %}✅{% else %}⏳{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.match_type == 'team_vs_team' %}
|
||||
<div class="match-teams">
|
||||
<div class="match-team">
|
||||
<span class="team-name">{{ m.team1.name if m.team1 else 'Team 1' }}</span>
|
||||
{% if participants.team1_players %}
|
||||
<ul class="team-players-list">
|
||||
{% for pl in participants.team1_players %}
|
||||
<li>{{ pl.name }}{% if pl.position %} <span class="position-tag">{{ pl.position }}</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="match-vs">vs</div>
|
||||
<div class="match-team">
|
||||
<span class="team-name">{{ m.team2.name if m.team2 else 'Team 2' }}</span>
|
||||
{% if participants.team2_players %}
|
||||
<ul class="team-players-list">
|
||||
{% for pl in participants.team2_players %}
|
||||
<li>{{ pl.name }}{% if pl.position %} <span class="position-tag">{{ pl.position }}</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif m.match_type == 'player_vs_player' %}
|
||||
<div class="match-teams">
|
||||
<div class="match-team">
|
||||
<span class="team-name">Team 1</span>
|
||||
{% if participants.team1_players %}
|
||||
<ul class="team-players-list">
|
||||
{% for pl in participants.team1_players %}
|
||||
<li>{{ pl.name }}{% if pl.position %} <span class="position-tag">{{ pl.position }}</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="match-vs">vs</div>
|
||||
<div class="match-team">
|
||||
<span class="team-name">Team 2</span>
|
||||
{% if participants.team2_players %}
|
||||
<ul class="team-players-list">
|
||||
{% for pl in participants.team2_players %}
|
||||
<li>{{ pl.name }}{% if pl.position %} <span class="position-tag">{{ pl.position }}</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ participants | join(', ') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td>
|
||||
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="Add Note for this Match">
|
||||
<i class="fas fa-sticky-note"></i> Note
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if matches %}
|
||||
<!-- Mini Calendar -->
|
||||
<div id="mini-calendar" style="min-height: 300px; margin-top: 20px;"></div>
|
||||
{% else %}
|
||||
<!-- No matches yet - show message -->
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-calendar-alt fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No matches scheduled yet. Check back later!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.can_evaluate() %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-star"></i> Evaluation Summary</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Evaluator</th>
|
||||
<th>Mecanics</th>
|
||||
<th>Cohesion</th>
|
||||
<th>Communication</th>
|
||||
<th>Gamesense</th>
|
||||
<th>Versatility</th>
|
||||
<th>Discipline</th>
|
||||
<th>Analysis</th>
|
||||
<th>Sport Ethics</th>
|
||||
<th>Mental</th>
|
||||
<th>Overall</th>
|
||||
<th>Recommendation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eval in evaluations %}
|
||||
<tr>
|
||||
<td>{{ eval.player.username }}</td>
|
||||
<td>{{ eval.evaluator.username }}</td>
|
||||
<td>{{ eval.mecanics_score or '-' }}</td>
|
||||
<td>{{ eval.cohesion_score or '-' }}</td>
|
||||
<td>{{ eval.communication_score or '-' }}</td>
|
||||
<td>{{ eval.gamesense_score or '-' }}</td>
|
||||
<td>{{ eval.versatility_score or '-' }}</td>
|
||||
<td>{{ eval.discipline_score or '-' }}</td>
|
||||
<td>{{ eval.analysis_score or '-' }}</td>
|
||||
<td>{{ eval.sport_ethics_score or '-' }}</td>
|
||||
<td>{{ eval.mental_score or '-' }}</td>
|
||||
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
|
||||
<td>{{ eval.position_recommendation or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="13" class="text-center">No evaluations yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.presence-toggle-btn {
|
||||
padding: 2px 7px;
|
||||
font-size: 0.7rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.presence-confirmed-btn {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.presence-pending-btn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
.presence-toggle-btn:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
|
||||
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
||||
|
||||
function toggleTryoutPresence(matchId, participantId, btn) {
|
||||
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.attendance_confirmed) {
|
||||
btn.classList.add('presence-confirmed-btn');
|
||||
btn.classList.remove('presence-pending-btn');
|
||||
btn.innerHTML = btn.innerHTML.replace('⏳', '✅');
|
||||
} else {
|
||||
btn.classList.remove('presence-confirmed-btn');
|
||||
btn.classList.add('presence-pending-btn');
|
||||
btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
|
||||
}
|
||||
location.reload();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error toggling tryout presence:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Mini calendar for matches
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var miniCalendarEl = document.getElementById('mini-calendar');
|
||||
if (miniCalendarEl) {
|
||||
var miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek'
|
||||
},
|
||||
events: '/matches/api/events/{{ tryout.id }}',
|
||||
height: '300px',
|
||||
eventClick: function(info) {
|
||||
if (info.event.extendedProps.match_id) {
|
||||
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
|
||||
}
|
||||
}
|
||||
});
|
||||
miniCalendar.render();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ profile_user.username }} - TryoutPro{% endblock %}
|
||||
{% block page_title %}{{ profile_user.username }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="#" onclick="history.back()">Back</a> / {{ profile_user.username }}</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="user-mini" style="gap: 12px;">
|
||||
<div class="avatar-sm" style="width: 48px; height: 48px; font-size: 1.2rem;">
|
||||
{{ profile_user.username[:2] | upper }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin: 0;">{{ profile_user.username }}</h3>
|
||||
<span class="badge badge-{{ 'info' if profile_user.role == 'player' else 'primary' }}">{{ profile_user.role | capitalize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Username</span>
|
||||
<span class="detail-value">{{ profile_user.username }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Role</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge badge-{{ 'info' if profile_user.role == 'player' else 'primary' }}">{{ profile_user.role | capitalize }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% if profile_user.discord_username %}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Discord</span>
|
||||
<span class="detail-value">{{ profile_user.discord_username }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set gamertags = profile_user.gamertags %}
|
||||
{% if gamertags %}
|
||||
<hr class="my-4">
|
||||
<h4 class="mb-3"><i class="fas fa-gamepad"></i> Gamertags</h4>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Game</th>
|
||||
<th>Gamertag</th>
|
||||
<th>Platform</th>
|
||||
<th>Profile</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for gt in gamertags %}
|
||||
<tr>
|
||||
<td>{{ gt.game }}</td>
|
||||
<td>{{ gt.gamertag }}</td>
|
||||
<td>{{ gt.platform or '-' }}</td>
|
||||
<td>
|
||||
{% if gt.get_trn_url() %}
|
||||
<a href="{{ gt.get_trn_url() }}" target="_blank" class="btn btn-sm btn-outline trn-link">
|
||||
<i class="fas fa-external-link-alt"></i> View Profile
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Input validation schemas for the Team Tryouts application.
|
||||
|
||||
This module provides Marshmallow schemas for validating and sanitizing
|
||||
all user inputs including forms, JSON requests, and file uploads.
|
||||
All validation is centralized here for consistency and maintainability.
|
||||
|
||||
Usage:
|
||||
from validators import LoginSchema
|
||||
schema = LoginSchema()
|
||||
errors = schema.validate(request.form)
|
||||
"""
|
||||
|
||||
import re
|
||||
from marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE
|
||||
from app.models import USER_TYPES
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Custom Validators
|
||||
# =============================================================================
|
||||
|
||||
PASSWORD_POLICY = re.compile(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
|
||||
)
|
||||
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
|
||||
|
||||
|
||||
def validate_password(value):
|
||||
"""Validate password meets strength requirements.
|
||||
|
||||
Requires: minimum 8 characters, at least one uppercase letter,
|
||||
one lowercase letter, and one digit.
|
||||
|
||||
Args:
|
||||
value: The password string to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If password does not meet requirements.
|
||||
"""
|
||||
if not PASSWORD_POLICY.match(value):
|
||||
raise ValidationError(
|
||||
'Password must be at least 8 characters with uppercase, '
|
||||
'lowercase, and a number.'
|
||||
)
|
||||
|
||||
|
||||
def validate_username(value):
|
||||
"""Validate username format.
|
||||
|
||||
Usernames must be 3-30 characters and contain only alphanumeric
|
||||
characters, underscores, and hyphens.
|
||||
|
||||
Args:
|
||||
value: The username string to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If username does not meet requirements.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value):
|
||||
raise ValidationError(
|
||||
'Username must be 3-30 characters (letters, numbers, underscore, hyphen).'
|
||||
)
|
||||
|
||||
|
||||
def validate_discord_username(value):
|
||||
"""Validate Discord username format if provided.
|
||||
|
||||
Accepts empty strings (optional field). Validates that the username
|
||||
matches common Discord username patterns.
|
||||
|
||||
Args:
|
||||
value: The Discord username to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the format is invalid.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if not re.match(r'^[a-zA-Z0-9_.]{2,32}$', value):
|
||||
raise ValidationError('Invalid Discord username format.')
|
||||
|
||||
|
||||
def validate_discord_user_id(value):
|
||||
"""Validate Discord user ID (snowflake) if provided.
|
||||
|
||||
Discord user IDs are 17-20 digit numbers.
|
||||
|
||||
Args:
|
||||
value: The Discord user ID to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the format is invalid.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if not re.match(r'^\d{17,20}$', value):
|
||||
raise ValidationError('Discord User ID must be a 17-20 digit number.')
|
||||
|
||||
|
||||
def validate_phone(value):
|
||||
"""Validate optional phone number format.
|
||||
|
||||
Accepts empty strings. Validates common phone formats.
|
||||
|
||||
Args:
|
||||
value: The phone number to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the format is invalid.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
cleaned = re.sub(r'[\s\-\(\)\.]', '', value)
|
||||
if not re.match(r'^\+?\d{7,15}$', cleaned):
|
||||
raise ValidationError('Invalid phone number format.')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Validation Schemas
|
||||
# =============================================================================
|
||||
|
||||
class StripMixin(Schema):
|
||||
"""Mixin that automatically strips whitespace from all string fields
|
||||
and ignores unknown fields (e.g., csrf_token from Flask-WTF)."""
|
||||
|
||||
class Meta:
|
||||
"""Marshmallow Meta options."""
|
||||
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
|
||||
|
||||
@pre_load
|
||||
def strip_strings(self, data, **kwargs):
|
||||
"""Strip whitespace from all string values in the input data.
|
||||
|
||||
Args:
|
||||
data: The input dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Data with stripped strings.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
|
||||
return data
|
||||
|
||||
|
||||
class LoginSchema(StripMixin):
|
||||
"""Validate login form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars, required.
|
||||
password: Non-empty, required.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=80, error='Username is required.'),
|
||||
)
|
||||
password = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, error='Password is required.'),
|
||||
)
|
||||
|
||||
|
||||
class RegisterSchema(StripMixin):
|
||||
"""Validate player registration form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars alphanumeric, required.
|
||||
email: Valid email, required.
|
||||
password: Meets password policy, required.
|
||||
confirm_password: Must match password, required.
|
||||
full_name: 1-100 chars, required.
|
||||
phone: Optional, valid phone format.
|
||||
games: Optional list.
|
||||
discord_username: Optional, valid format.
|
||||
league_os_profile: Optional URL.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=3, max=80, error='Username must be 3-80 characters.'),
|
||||
validate_username,
|
||||
],
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120, error='Email must be 120 characters or less.'),
|
||||
)
|
||||
password = fields.String(
|
||||
required=True,
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
)
|
||||
confirm_password = fields.String(
|
||||
required=True,
|
||||
load_only=True,
|
||||
)
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
games = fields.List(fields.String(), load_default=[])
|
||||
discord_username = fields.String(
|
||||
validate=validate_discord_username,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
trn_username = fields.String(
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
league_os_profile = fields.String(
|
||||
validate=validate.Length(max=256),
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
|
||||
@validates_schema
|
||||
def validate_password_match(self, data, **kwargs):
|
||||
"""Ensure confirm_password matches password.
|
||||
|
||||
Args:
|
||||
data: The validated data dictionary.
|
||||
|
||||
Raises:
|
||||
ValidationError: If passwords do not match.
|
||||
"""
|
||||
if data.get('password') != data.get('confirm_password'):
|
||||
raise ValidationError('Passwords do not match.', field_name='confirm_password')
|
||||
|
||||
|
||||
class CreateUserSchema(StripMixin):
|
||||
"""Validate president-created user form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars alphanumeric, required.
|
||||
email: Valid email, required.
|
||||
password: Meets password policy, required.
|
||||
full_name: 1-100 chars, required.
|
||||
role: Must be valid role, required.
|
||||
phone: Optional, valid phone format.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=3, max=80, error='Username must be 3-80 characters.'),
|
||||
validate_username,
|
||||
],
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120),
|
||||
)
|
||||
password = fields.String(
|
||||
required=True,
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
)
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
role = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
USER_TYPES,
|
||||
error='Invalid role selected.'
|
||||
),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
|
||||
|
||||
class EditUserSchema(StripMixin):
|
||||
"""Validate president-edited user form input.
|
||||
|
||||
Fields:
|
||||
full_name: 1-100 chars, required.
|
||||
email: Valid email, required.
|
||||
role: Must be valid role, required.
|
||||
is_active_account: Boolean.
|
||||
phone: Optional.
|
||||
password: Optional (only if changing).
|
||||
discord_username: Optional.
|
||||
discord_user_id: Optional.
|
||||
league_os_profile: Optional.
|
||||
games: Optional list.
|
||||
"""
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120),
|
||||
)
|
||||
role = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
USER_TYPES,
|
||||
error='Invalid role selected.'
|
||||
),
|
||||
)
|
||||
is_active_account = fields.Boolean(load_default=True)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
password = fields.String(
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
load_default='',
|
||||
)
|
||||
discord_username = fields.String(
|
||||
validate=validate_discord_username,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
discord_user_id = fields.String(
|
||||
validate=validate_discord_user_id,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
league_os_profile = fields.String(
|
||||
validate=validate.Length(max=256),
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
games = fields.List(fields.String(), load_default=[])
|
||||
|
||||
|
||||
class EditProfileSchema(StripMixin):
|
||||
"""Validate self-edit profile form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars, required.
|
||||
full_name: 1-100 chars, required.
|
||||
email: Valid email, required.
|
||||
phone: Optional.
|
||||
password: Optional (only if changing).
|
||||
discord_username: Optional.
|
||||
discord_user_id: Optional.
|
||||
league_os_profile: Optional.
|
||||
games: Optional list.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=3, max=80),
|
||||
validate_username,
|
||||
],
|
||||
)
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
password = fields.String(
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
load_default='',
|
||||
)
|
||||
discord_username = fields.String(
|
||||
validate=validate_discord_username,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
discord_user_id = fields.String(
|
||||
validate=validate_discord_user_id,
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
league_os_profile = fields.String(
|
||||
validate=validate.Length(max=256),
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
games = fields.List(fields.String(), load_default=[])
|
||||
|
||||
|
||||
class UploadContractSchema(StripMixin):
|
||||
"""Validate contract upload form input.
|
||||
|
||||
Fields:
|
||||
player_id: Integer, required.
|
||||
notes: Optional text.
|
||||
"""
|
||||
player_id = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(min=1, error='Player must be selected.'),
|
||||
)
|
||||
notes = fields.String(
|
||||
validate=validate.Length(max=2000, error='Notes must be 2000 characters or less.'),
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
|
||||
|
||||
class OneOnOneRequestSchema(StripMixin):
|
||||
"""Validate One on One session request form input.
|
||||
|
||||
Fields:
|
||||
date: Date string (YYYY-MM-DD), required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
end_time: Time string (HH:MM), required.
|
||||
points: Optional text.
|
||||
"""
|
||||
date = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{4}-\d{2}-\d{2}$',
|
||||
error='Date must be in YYYY-MM-DD format.'
|
||||
),
|
||||
)
|
||||
start_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error='Start time must be in HH:MM format.'
|
||||
),
|
||||
)
|
||||
end_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error='End time must be in HH:MM format.'
|
||||
),
|
||||
)
|
||||
points = fields.String(
|
||||
validate=validate.Length(max=2000, error='Points must be 2000 characters or less.'),
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
|
||||
|
||||
class DisponibilityAddSchema(StripMixin):
|
||||
"""Validate disponibility block addition.
|
||||
|
||||
Fields:
|
||||
day_of_week: Integer 0-6, required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
"""
|
||||
day_of_week = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(
|
||||
min=0, max=6,
|
||||
error='Day must be 0 (Monday) to 6 (Sunday).'
|
||||
),
|
||||
)
|
||||
start_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error='Start time must be in HH:MM format.'
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user