"""User management routes for profiles, disponibilities, and contracts. This module handles user CRUD operations, profile editing, player availability, and contract management. """ import os from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file from flask_login import login_required, current_user from extensions import db, hash_password from models import User, ROLES, 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 import requests users_bp = Blueprint('users', __name__, url_prefix='/users') def update_user_gamertags(user, selected_games): """Update gamertags for a user based on form input. ++++++++++++++++++++++++++++++ Handles creating, updating, and deleting gamertag records for the specified games. Used by both edit_user and edit_profile routes to avoid code duplication. Args: user: The User object to update gamertags for. selected_games: List of game names that were selected in the form. """ # Get all existing gamertags for this user 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) # Remove gamertags for games that are no longer selected for game in existing_gamertags: if game not in selected_games: db.session.delete(existing_gamertags[game]) @users_bp.route('') @login_required def list_users(): """List all users for management (president only). Displays all users ordered by role and name. Only accessible to presidents. Returns: Response: Rendered users list template or redirect to dashboard. """ if current_user.role != 'president': 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=ROLES) @users_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit_user(user_id): """Edit an existing user (president only). GET: Render the user edit form. POST: Update user details including gamertags and password. Args: user_id: The ID of the user to edit. Returns: Response: Edit form or redirect to users list. """ if current_user.role != 'president': 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 ROLES: flash('Invalid role selected.', 'danger') return render_template('pages/edit_user.html', user=user, roles=ROLES, 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 gamertags using shared function 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=ROLES, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags=user_gamertags) @users_bp.route('//delete', methods=['POST']) @login_required def delete_user(user_id): """Delete a user (president only). Args: user_id: The ID of the user to delete. Returns: Response: Redirect to users list with status message. """ if current_user.role != 'president': 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) # Clean up related records before deleting the user # 1. Delete evaluations involving this user Evaluation.query.filter( db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id) ).delete(synchronize_session=False) # 2. Delete availability/disponibility records PlayerDisponibility.query.filter_by(player_id=user_id).delete() CoachAvailability.query.filter_by(coach_id=user_id).delete() # 3. Delete personal notes involving this user PersonalNote.query.filter( db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id) ).delete(synchronize_session=False) # 4. Delete team notes authored by this user TeamNote.query.filter_by(coach_id=user_id).delete() # 5. Delete one-on-one requests involving this user OneOnOneRequest.query.filter( db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id) ).delete(synchronize_session=False) # 6. Delete gamertags UserGamertag.query.filter_by(user_id=user_id).delete() # 7. Delete contracts for this player (and any they uploaded as manager/coach) Contract.query.filter_by(player_id=user_id).delete() # 8. Delete tryout registrations TryoutRegistration.query.filter_by(player_id=user_id).delete() # 9. Delete team placements TeamPlayer.query.filter_by(player_id=user_id).delete() # 10. Delete team memberships in tryout teams TeamMember.query.filter_by(player_id=user_id).delete() # 11. Delete match participants MatchParticipant.query.filter_by(player_id=user_id).delete() # 12. Nullify org team references so the user can be deleted OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None}) OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None}) # 13. Handle records this user created (tryouts, matches, teams, org teams, contracts) # Reassign created_by to the current president (deleting user) to avoid FK violations 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 (president only). GET: Render the user creation form. POST: Create a new user with the provided details. Returns: Response: Create form or redirect to users list. """ if current_user.role != 'president': 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 ROLES: flash('Invalid role selected.', 'danger') return render_template('pages/create_user.html', roles=ROLES) if User.query.filter_by(username=username).first(): flash('Username already exists.', 'danger') return render_template('pages/create_user.html', roles=ROLES) if User.query.filter_by(email=email).first(): flash('Email already registered.', 'danger') return render_template('pages/create_user.html', roles=ROLES) hashed_password = hash_password(password) user = User( 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=ROLES) @users_bp.route('/profile') @login_required def profile(): """View the current user's profile. Players see their contracts along with their profile information. Returns: Response: Rendered profile template. """ # Get contracts ordered by uploaded_at desc for the current user contracts = None if current_user.role == '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. GET: Render the profile edit form. POST: Update profile details including gamertags and password. Returns: Response: Edit form or redirect to 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={gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in current_user.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={gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in current_user.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 gamertags using shared function 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')) user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in current_user.gamertags} return render_template('pages/edit_profile.html', user=current_user, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags=user_gamertags) # Day names for disponibility display DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] def add_30_minutes(t): """Add 30 minutes to a time object. Args: t (datetime.time): The time object to add 30 minutes to. Returns: datetime.time: New time 30 minutes later. """ 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. Only accessible to managers, coaches, and scouts. Used for match scheduling. Returns: Response: JSON with disponibility data for all players. """ # Only managers and above can view 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. Returns: Response: JSON with disponibility data grouped by day. """ disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all() # Group by day for easier display 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. Returns: Response: JSON with the created disponibility data. """ 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 (for grid selection). Used for the disponibility grid UI where players can select multiple time slots at once. Returns: Response: JSON with success status and created slots. """ data = request.get_json() slots = data.get('slots', []) # List of {day_of_week, start_time} 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) # Check if this slot already exists for this player 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'), 'end_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 (for resetting). Returns: Response: JSON with success status. """ PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() db.session.commit() return jsonify({'success': True}) @users_bp.route('/disponibilities//delete', methods=['POST']) @login_required def delete_disponibility(disponibility_id): """Delete a disponibility block. Args: disponibility_id: The ID of the disponibility to delete. Returns: Response: JSON with success status or error. """ disponibility = PlayerDisponibility.query.get_or_404(disponibility_id) # Only the owner can delete their disponibility if disponibility.player_id != current_user.id: return jsonify({'error': 'Unauthorized'}), 403 db.session.delete(disponibility) db.session.commit() return jsonify({'success': True}) # Contract Management Functions def can_manage_player_contract(user, player_id): """Check if a user can upload contracts for a specific player. Presidents and managers can manage all contracts. Coaches can only manage contracts for players on their team. Args: user: The User requesting to manage contracts. player_id: The ID of the player whose contract is being managed. Returns: bool: True if user has permission to manage the contract. """ # President can manage all contracts if user.role == 'president': return True # Manager can upload contracts for any player if user.role == 'manager': return True # Coach can upload contracts only for players on their team if user.role == '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 (player) or players they manage. Players see their own contracts. Coaches/managers see contracts for players on their teams. Presidents see all contracts. Returns: Response: Rendered contracts list template. """ contracts = None if current_user.role == 'player': # Players see their own contracts contracts = Contract.query.filter_by(player_id=current_user.id).order_by(Contract.uploaded_at.desc()).all() elif current_user.role in ['president', 'manager', 'coach']: # Superiors see contracts for players on their teams players = [] if current_user.role == '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: # president or manager 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 current_user.role in ['president', 'manager', 'coach'] else None) @users_bp.route('/contracts/upload', methods=['GET', 'POST']) @login_required def upload_contract(): """Upload a contract for a player. GET: Render the contract upload form. POST: Save the uploaded contract file and create database record. Returns: Response: Upload form or redirect to contracts list. """ if current_user.role not in ['president', 'manager', 'coach']: flash('Only presidents, managers, and coaches can upload contracts.', 'danger') return redirect(url_for('users.list_contracts')) # Get players this user can manage if current_user.role == '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': player_id = request.form.get('player_id', type=int) notes = request.form.get('notes', '').strip() 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')) # Create upload directory upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés') os.makedirs(upload_dir, exist_ok=True) # Get player info for folder structure player = User.query.get_or_404(player_id) player_teams = player.get_org_teams() team = player_teams[0] if player_teams else None # Create team folder if team exists 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 # Generate unique filename original_filename = secure_filename(file.filename) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') stored_filename = f"{secure_filename(player.username)}_{timestamp}_{original_filename}" stored_filename = stored_filename.replace(' ', '_') # Save file file_path = os.path.join(final_dir, stored_filename) file.save(file_path) # Create database record 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//upload_signed', methods=['POST']) @login_required def upload_signed_contract(contract_id): """Upload a signed contract (player only). Args: contract_id: The ID of the contract to upload signed version for. Returns: Response: Redirect to contracts list with status message. """ 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')) # Generate unique filename for signed contract original_filename = secure_filename(file.filename) signed_filename = f"signed_{contract.stored_filename}" # Save file file.save(contract.file_path.replace(contract.stored_filename, signed_filename)) # Update contract record 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//download') @login_required def download_contract(contract_id): """Download a contract file. Args: contract_id: The ID of the contract to download. Returns: Response: File download response. """ 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//download_signed') @login_required def download_signed_contract(contract_id): """Download a signed contract file. Args: contract_id: The ID of the contract to download the signed version for. Returns: Response: File download response. """ 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 Functions 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. Sends a DM to the coach via Discord bot if their Discord User ID is configured, otherwise falls back to webhook notification. Args: player_name: Name of the player making the request. points: Discussion points from the player. date_str: Date of the requested session. start_time_str: Start time of the requested session. end_time_str: End time of the requested session. team_name: Name of the player's team. coach_name: Name of the coach. coach_discord: Coach's Discord username. coach_discord_id: Coach's Discord User ID (for DMs). """ import logging logger = logging.getLogger(__name__) # Try to send DM via Discord bot first (requires coach's Discord User ID) if coach_discord_id: try: from 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}") # Also send to webhook as backup/fallback if DISCORD_WEBHOOK_URL: try: from discord_bot import send_one_on_one_dm # Try to send DM to webhook URL if it's a user ID 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(): # Legacy webhook URL - send traditional webhook embed 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. Players can view team notes, personal notes, and request One on One sessions. Returns: Response: Rendered One on One page. """ if current_user.role != 'player': flash('Only players can request One on One sessions.', 'danger') return redirect(url_for('main.dashboard')) # Get player's teams and coach (use the first team the player is on) 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') # Get team notes for this player's team team_notes = [] if org_team: team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() # Get personal notes for this player personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all() # Get coach's availability for the next 7 days coach_availability = [] if coach: coach_availability = CoachAvailability.query.filter_by(coach_id=coach.id).all() if request.method == 'POST': # Handle One on One request submission 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')) # Validate date and time 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 if the requested slot is within coach's availability date_parts = date_str.split('-') check_date = datetime(int(date_parts[0]), int(date_parts[1]), int(date_parts[2])) # Python weekday: Monday=0, Sunday=6 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')) # Create the request 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 send_discord_notification( player_name=current_user.username, 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 None, coach_name=coach.username, coach_discord=coach.discord_username, coach_discord_id=coach.discord_user_id, request_id=request_obj.id ) flash('One on One request sent to your coach!', 'success') return redirect(url_for('users.one_on_one')) # Calculate dates for the next week (starting from today) dates = [] for i in range(7): d = date_type.today() + timedelta(days=i) dates.append({ 'value': d.strftime('%Y-%m-%d'), 'display': d.strftime('%A, %b %d'), 'day_of_week': d.weekday() }) # Serialize coach availability for JavaScript coach_availability_serialized = [ { 'id': av.id, 'day_of_week': av.day_of_week, 'start_time': av.start_time.strftime('%H:%M'), 'end_time': av.end_time.strftime('%H:%M') } for av in coach_availability ] 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_serialized, dates=dates) @users_bp.route('/coach-availability', methods=['GET', 'POST']) @login_required def manage_coach_availability(): """Manage coach availability (for coaches). GET: Render the availability management page. POST: Add availability slots via bulk. Returns: Response: Rendered management page or redirect. """ if current_user.role != 'coach': flash('Only coaches can manage availability.', 'danger') return redirect(url_for('main.dashboard')) if request.method == 'POST': 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) # Check if this slot already exists for this coach existing = CoachAvailability.query.filter_by( coach_id=current_user.id, day_of_week=day_of_week, start_time=start_time ).first() if not existing: availability = CoachAvailability( coach_id=current_user.id, day_of_week=day_of_week, start_time=start_time, end_time=end_time ) db.session.add(availability) db.session.flush() created.append({ 'id': availability.id, 'day_of_week': availability.day_of_week, 'day_name': DAY_NAMES[availability.day_of_week], 'start_time': availability.start_time.strftime('%H:%M'), 'end_time': availability.end_time.strftime('%H:%M') }) db.session.commit() return jsonify({'success': True, 'created': created}) # Get existing availability existing_availability = CoachAvailability.query.filter_by(coach_id=current_user.id).all() return render_template('pages/coach_availability.html', existing_availability=existing_availability) @users_bp.route('/coach-availability/clear', methods=['POST']) @login_required def clear_coach_availability(): """Clear all coach availability slots. Returns: Response: JSON with success status. """ if current_user.role != 'coach': return jsonify({'error': 'Unauthorized'}), 403 CoachAvailability.query.filter_by(coach_id=current_user.id).delete() db.session.commit() return jsonify({'success': True}) @users_bp.route('/coach-availability//delete', methods=['POST']) @login_required def delete_coach_availability(availability_id): """Delete a coach availability slot. Args: availability_id: The ID of the availability slot to delete. Returns: Response: JSON with success status or error. """ if current_user.role != 'coach': return jsonify({'error': 'Unauthorized'}), 403 availability = CoachAvailability.query.get_or_404(availability_id) if availability.coach_id != current_user.id: return jsonify({'error': 'Unauthorized'}), 403 db.session.delete(availability) db.session.commit() return jsonify({'success': True}) @users_bp.route('/api/coach-availability/') @login_required def api_get_coach_availability(coach_id): """API endpoint to get coach availability. Args: coach_id: The ID of the coach. Returns: Response: JSON with availability data. """ if current_user.role != 'player': return jsonify({'error': 'Unauthorized'}), 403 # Players can only view their own coach's availability player_teams = current_user.get_org_teams() org_team = player_teams[0] if player_teams else None if org_team and org_team.coach_id != coach_id: return jsonify({'error': 'Unauthorized'}), 403 availability = CoachAvailability.query.filter_by(coach_id=coach_id).all() result = {} for av in availability: if av.day_of_week not in result: result[av.day_of_week] = [] result[av.day_of_week].append({ 'id': av.id, 'start_time': av.start_time.strftime('%H:%M'), 'end_time': av.end_time.strftime('%H:%M') }) return jsonify(result) @users_bp.route('/team-notes', methods=['GET', 'POST']) @login_required def manage_team_notes(): """Manage team improvement notes (for coaches). GET: Render the team notes management page. POST: Create or update team notes. Returns: Response: Rendered management page or redirect. """ if current_user.role != 'coach': flash('Only coaches can manage team notes.', 'danger') return redirect(url_for('main.dashboard')) # Get the coach's team org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() if not org_team: flash('You are not assigned to coach any team.', 'danger') return redirect(url_for('main.dashboard')) # Get existing team notes team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() if request.method == 'POST': content = request.form.get('content', '').strip() if content: # Create new team note entry (keeps history) note = TeamNote( org_team_id=org_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('users.manage_team_notes')) return render_template('pages/team_notes.html', org_team=org_team, team_notes=team_notes) @users_bp.route('/personal-notes', methods=['GET', 'POST']) @login_required def manage_personal_notes(): """Manage personal notes for players (for coaches). GET: Render the personal notes management page. POST: Create a personal note for a player. Returns: Response: Rendered management page or redirect. """ if current_user.role != 'coach': flash('Only coaches can manage personal notes.', 'danger') return redirect(url_for('main.dashboard')) # Get players on the coach's team org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() players = [] 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)).order_by(User.username).all() if player_ids else [] if request.method == 'POST': player_id = request.form.get('player_id', type=int) content = request.form.get('content', '').strip() if not player_id or not content: flash('Please select a player and enter note content.', 'danger') return redirect(url_for('users.manage_personal_notes')) # Verify player is on coach's team if org_team and player_id not in [p.id for p in players]: flash('You can only add notes for players on your team.', 'danger') return redirect(url_for('users.manage_personal_notes')) note = PersonalNote( player_id=player_id, coach_id=current_user.id, content=content ) db.session.add(note) db.session.commit() flash('Personal note added successfully!', 'success') return redirect(url_for('users.manage_personal_notes')) # Get all personal notes for players on this team personal_notes = [] if org_team: personal_notes = PersonalNote.query.filter( PersonalNote.player_id.in_([p.id for p in players]) ).order_by(PersonalNote.created_at.desc()).all() return render_template('pages/personal_notes.html', players=players, personal_notes=personal_notes, org_team=org_team) @users_bp.route('/notes', methods=['GET']) @login_required def notes_dashboard(): """Unified notes dashboard for coaches. GET: Render the combined notes management page with both team and personal notes forms. Returns: Response: Rendered notes dashboard template. """ if current_user.role != 'coach': flash('Only coaches can manage notes.', 'danger') return redirect(url_for('main.dashboard')) # Get the coach's team org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() players = [] 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)).order_by(User.username).all() if player_ids else [] # Get existing team notes team_notes = [] latest_team_note = None if org_team: team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() latest_team_note = team_notes[0] if team_notes else None # Get all personal notes for players on this team personal_notes = [] if org_team: personal_notes = PersonalNote.query.filter( PersonalNote.player_id.in_([p.id for p in players]) ).order_by(PersonalNote.created_at.desc()).all() # Get available matches and tryouts for context matches = [] tryouts = [] teams = [] if current_user.role == 'coach' and org_team: tryouts = Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date.desc()).all() matches = Match.query.join(Tryout).filter(Tryout.target_org_team_id == org_team.id).order_by(Match.date.desc()).all() teams = Team.query.order_by(Team.name).all() return render_template('pages/notes.html', org_team=org_team, players=players, team_notes=team_notes, latest_team_note=latest_team_note, personal_notes=personal_notes, matches=matches, tryouts=tryouts, teams=teams) # Note source types for filtering NOTE_SOURCE_MATCH = 'match' NOTE_SOURCE_TRYOUT = 'tryout' NOTE_SOURCE_TEAM = 'team' @users_bp.route('/my-notes') @login_required def my_notes(): """View all notes for the current player. Shows both personal notes and team notes that the player has received. Personal notes are grouped by source (match, tryout, team). Returns: Response: Rendered my notes template. """ if current_user.role != 'player': flash('Only players can view their notes.', 'danger') return redirect(url_for('main.dashboard')) # Get player's teams and coach (use the first team the player is on) player_teams = current_user.get_org_teams() org_team = player_teams[0] if player_teams else None # Get all personal notes for this player with eager loading for relationships personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all() # Get team notes for this player's team team_notes = [] if org_team: team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() return render_template('pages/player_personal_notes.html', org_team=org_team, personal_notes=personal_notes, team_notes=team_notes) @users_bp.route('/notes/add', methods=['GET', 'POST']) @login_required def add_personal_note(): """Add a personal note with optional context (for coaches and managers). GET: Render the note creation form. POST: Create a personal note, optionally linked to match/tryout/team. Returns: Response: Create form or redirect to notes view. """ if current_user.role not in ['coach', 'manager', 'president']: flash('Only coaches and managers can add notes.', 'danger') return redirect(url_for('main.dashboard')) # Get players this user can manage players = [] org_team = None if current_user.role == '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)).order_by(User.username).all() if player_ids else [] elif current_user.role in ['president', 'manager']: players = User.query.filter_by(role='player').order_by(User.username).all() # Get available matches and tryouts for context matches = [] tryouts = [] teams = [] if current_user.role == 'coach' and org_team: tryouts = Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date.desc()).all() matches = Match.query.join(Tryout).filter(Tryout.target_org_team_id == org_team.id).order_by(Match.date.desc()).all() elif current_user.role in ['president', 'manager']: tryouts = Tryout.query.order_by(Tryout.date.desc()).all() matches = Match.query.order_by(Match.date.desc()).all() teams = Team.query.order_by(Team.name).all() if request.method == 'POST': player_id = request.form.get('player_id', type=int) content = request.form.get('content', '').strip() match_id = request.form.get('match_id', type=int) tryout_id = request.form.get('tryout_id', type=int) team_id = request.form.get('team_id', type=int) if not player_id or not content: flash('Please select a player and enter note content.', 'danger') return redirect(url_for('users.add_personal_note')) # Verify player is on coach's team (if coach) if current_user.role == 'coach' and org_team and player_id not in [p.id for p in players]: flash('You can only add notes for players on your team.', 'danger') return redirect(url_for('users.add_personal_note')) # Validate context - ensure coach can access the match/tryout/team if match_id: match = Match.query.get(match_id) if match: match_tryout = Tryout.query.get(match.tryout_id) # For coaches, verify the match is in their team's tryout if current_user.role == 'coach' and org_team: if match_tryout and match_tryout.target_org_team_id and match_tryout.target_org_team_id != org_team.id: flash('You can only add notes for matches in your team\'s tryouts.', 'danger') return redirect(url_for('users.add_personal_note')) if tryout_id: tryout = Tryout.query.get(tryout_id) # For coaches, verify the tryout targets their team if current_user.role == 'coach' and org_team: if tryout and tryout.target_org_team_id and tryout.target_org_team_id != org_team.id: flash('You can only add notes for your team\'s tryouts.', 'danger') return redirect(url_for('users.add_personal_note')) if team_id: team = Team.query.get(team_id) if team: team_tryout = Tryout.query.get(team.tryout_id) # For coaches, verify the team is in their tryout if current_user.role == 'coach' and org_team: if team_tryout and team_tryout.target_org_team_id and team_tryout.target_org_team_id != org_team.id: flash('You can only add notes for your team\'s tryouts.', 'danger') return redirect(url_for('users.add_personal_note')) note = PersonalNote( player_id=player_id, coach_id=current_user.id, content=content, match_id=match_id if match_id else None, team_id=team_id if team_id else None, tryout_id=tryout_id if tryout_id else None ) db.session.add(note) db.session.commit() flash('Personal note added successfully!', 'success') return redirect(url_for('users.notes_dashboard')) return render_template('pages/add_note.html', players=players, matches=matches, tryouts=tryouts, teams=teams, org_team=org_team, context_type='general') @users_bp.route('/match//add-note', methods=['GET', 'POST']) @login_required def add_note_from_match(match_id): """Add a personal note from a match view context (for coaches). GET: Render the note creation form pre-filled with match info. POST: Create a personal note linked to the match. Args: match_id: The ID of the match to create note for. Returns: Response: Create form or redirect. """ if current_user.role not in ['coach', 'manager', 'president']: flash('Only coaches can add notes from matches.', 'danger') return redirect(url_for('main.dashboard')) match = Match.query.get_or_404(match_id) tryout = Tryout.query.get(match.tryout_id) # Check permissions if current_user.role == 'coach': org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() if not org_team or (tryout.target_org_team_id and tryout.target_org_team_id != org_team.id): flash('You do not have permission for this match.', 'danger') return redirect(url_for('matches.calendar')) # Get all participants for this match participant_ids = [] if match.match_type == 'team_vs_team': if match.team1_id: team_members = TeamMember.query.filter_by(team_id=match.team1_id).all() participant_ids.extend([m.player_id for m in team_members]) if match.team2_id: team_members = TeamMember.query.filter_by(team_id=match.team2_id).all() participant_ids.extend([m.player_id for m in team_members]) else: participants = MatchParticipant.query.filter_by(match_id=match_id).all() participant_ids = [p.player_id for p in participants] players = User.query.filter(User.id.in_(participant_ids)).order_by(User.username).all() if participant_ids else [] # Get team notes for context team_notes = [] if tryout and tryout.target_org_team_id: team_notes = TeamNote.query.filter_by(org_team_id=tryout.target_org_team_id).order_by(TeamNote.created_at.desc()).all() if request.method == 'POST': player_id = request.form.get('player_id', type=int) content = request.form.get('content', '').strip() if not player_id or not content: flash('Please select a player and enter note content.', 'danger') elif player_id not in participant_ids: flash('Selected player is not in this match.', 'danger') else: note = PersonalNote( player_id=player_id, coach_id=current_user.id, content=content, match_id=match_id ) db.session.add(note) db.session.commit() flash('Personal note added successfully!', 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return render_template('pages/add_note.html', match=match, tryout=tryout, players=players, team_notes=team_notes, context_type='match') @users_bp.route('/tryout//add-note', methods=['GET', 'POST']) @login_required def add_note_from_tryout(tryout_id): """Add a personal note from a tryout view context (for coaches). GET: Render the note creation form pre-filled with tryout info. POST: Create a personal note linked to the tryout. Args: tryout_id: The ID of the tryout to create note for. Returns: Response: Create form or redirect. """ if current_user.role not in ['coach', 'manager', 'president']: flash('Only coaches can add notes from tryouts.', 'danger') return redirect(url_for('main.dashboard')) tryout = Tryout.query.get_or_404(tryout_id) # Check permissions org_team = None if current_user.role == 'coach': org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() if not org_team or (tryout.target_org_team_id and tryout.target_org_team_id != org_team.id): flash('You do not have permission for this tryout.', 'danger') return redirect(url_for('tryouts.list_tryouts')) # Get all registered players in this tryout registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() player_ids = [r.player_id for r in registrations] # If coach, filter to only their team players; otherwise include all if org_team: 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), User.id.in_(team_player_ids)).order_by(User.username).all() else: players = User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() # Get team notes for context team_notes = [] if tryout.target_org_team_id: team_notes = TeamNote.query.filter_by(org_team_id=tryout.target_org_team_id).order_by(TeamNote.created_at.desc()).all() if request.method == 'POST': player_id = request.form.get('player_id', type=int) content = request.form.get('content', '').strip() if not player_id or not content: flash('Please select a player and enter note content.', 'danger') elif player_id not in [p.id for p in players]: flash('Selected player is not registered for this tryout.', 'danger') else: note = PersonalNote( player_id=player_id, coach_id=current_user.id, content=content, tryout_id=tryout_id ) db.session.add(note) db.session.commit() flash('Personal note added successfully!', 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # Allow pre-selecting a player via query parameter preselected_player_id = request.args.get('player_id', type=int) return render_template('pages/add_note.html', tryout=tryout, players=players, team_notes=team_notes, preselected_player_id=preselected_player_id, context_type='tryout')