"""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, Match, Team, TeamMember, MatchParticipant, Tryout 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.full_name).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() 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.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.full_name} 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) db.session.delete(user) db.session.commit() flash(f'User {user.full_name} 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': 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() league_os_profile = request.form.get('league_os_profile', '').strip() 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) 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.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.full_name).all() result = {} for player in players: disponibilities = list(player.disponibilities) result[player.id] = { 'full_name': player.full_name, '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: player = User.query.get(player_id) if player and player.team_id == org_team.id: 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: players = User.query.filter_by(role='player', team_id=org_team.id).all() 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() players = User.query.filter_by(role='player', team_id=org_team.id).all() if org_team else [] 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) team = OrgTeam.query.get(player.team_id) if player.team_id 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.full_name)}_{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=player.team_id, 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.full_name}!', '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): """Send a Discord webhook notification for a One on One request. 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. """ if not DISCORD_WEBHOOK_URL: return # No webhook configured, skip notification embed = { "embeds": [{ "title": "One on One Request", "color": 3447003, # Blue color "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 "") } }] } try: requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5) except Exception: pass # Silently fail if webhook doesn't work @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 team and coach org_team = OrgTeam.query.get(current_user.team_id) if current_user.team_id 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.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 None, coach_name=coach.full_name, coach_discord=coach.discord_username ) 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 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: players = User.query.filter_by(role='player', team_id=org_team.id).order_by(User.full_name).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') 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) # 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 team and coach org_team = OrgTeam.query.get(current_user.team_id) if current_user.team_id 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: players = User.query.filter_by(role='player', team_id=org_team.id).order_by(User.full_name).all() elif current_user.role in ['president', 'manager']: players = User.query.filter_by(role='player').order_by(User.full_name).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) match_tryout = None if match: match_tryout = Tryout.query.get(match.tryout_id) 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 and org_team: tryout = Tryout.query.get(tryout_id) 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 and org_team: team = Team.query.get(team_id) if team: tryout = Tryout.query.get(team.tryout_id) 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')) 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.my_notes')) return render_template('pages/add_personal_note.html', players=players, matches=matches, tryouts=tryouts, teams=teams, org_team=org_team) @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.full_name).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_from_match.html', match=match, tryout=tryout, players=players, team_notes=team_notes) @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 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 if current_user.role == 'coach' and org_team: players = User.query.filter(User.id.in_(player_ids), User.team_id == org_team.id).order_by(User.full_name).all() else: players = User.query.filter(User.id.in_(player_ids)).order_by(User.full_name).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)) return render_template('pages/add_note_from_tryout.html', tryout=tryout, players=players, team_notes=team_notes)