diff --git a/app/models/__init__.py b/app/models/__init__.py index e285720..7e699bb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -20,6 +20,7 @@ from app.models._constants import ( GAME_PLATFORMS, PLATFORM_CODES, TRN_URLS, + EVALUATION_CRITERIA, ) # ========================================================================= diff --git a/app/models/_constants.py b/app/models/_constants.py index a0ce394..30a8208 100644 --- a/app/models/_constants.py +++ b/app/models/_constants.py @@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates. USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout'] +# Ordered list of (field_name, human_label) pairs for the player evaluation +# score criteria. Kept in a single place so the evaluation forms, batch +# evaluation page, and any future reporting all stay in sync. +EVALUATION_CRITERIA = [ + ('mecanics_score', 'Mecanics'), + ('cohesion_score', 'Cohesion'), + ('communication_score', 'Communication'), + ('gamesense_score', 'Gamesense'), + ('versatility_score', 'Versatility'), + ('discipline_score', 'Discipline'), + ('analysis_score', 'Analysis'), + ('sport_ethics_score', 'Sport Ethics'), + ('mental_score', 'Mental'), +] + ESPORT_GAMES = [ 'Valorant', 'League of Legends', diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index a6aa2cc..67233c4 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_required, current_user +from app.extensions import db +from app.models import ( + Admin, Coach, Manager, Player, + User, Tryout, Evaluation, TryoutRegistration, + OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA, +) from sqlalchemy import func from sqlalchemy.orm import aliased @@ -27,6 +35,34 @@ from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') +def validate_score(score_value): + """Validate that a score is between 1 and 10.""" + if score_value is None: + return None + try: + score = int(score_value) + if 1 <= score <= 10: + return score + return None + except (ValueError, TypeError): + return None + + +def compute_overall(scores): + """Average the non-None scores, or return None if there are none.""" + valid = [s for s in scores if s is not None] + return sum(valid) / len(valid) if valid else None + + +def _apply_evaluation(evaluation, scores, comments, position): + """Write validated scores/comments/position onto an Evaluation instance.""" + for field_name, _ in EVALUATION_CRITERIA: + setattr(evaluation, field_name, scores[field_name]) + evaluation.overall_score = compute_overall(list(scores.values())) + evaluation.comments = comments + evaluation.position_recommendation = position + + @evaluations_bp.route('') @login_required def list_evaluations(): @@ -228,3 +264,94 @@ def players_to_evaluate(tryout_id): players.append({'player': p, 'evaluated': existing is not None, 'registration': reg}) return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) + + +@evaluations_bp.route('//batch', methods=['GET', 'POST']) +@login_required +def batch_evaluate(tryout_id): + """Evaluate multiple players at once in a tryout. + + GET renders a single form listing every selected player with their + evaluation criteria. POST saves (creates or updates) all of them. + """ + if not current_user.can_evaluate(): + flash('You do not have permission to evaluate players.', 'danger') + return redirect(url_for('main.dashboard')) + + tryout = Tryout.query.get_or_404(tryout_id) + if not current_user.can_manage_this_tryout(tryout): + flash('You do not have permission to evaluate players in this tryout.', 'danger') + return redirect(url_for('tryouts.list_tryouts')) + + # Resolve selected player ids (query string on GET, hidden fields on POST). + player_ids = [] + for raw in request.values.getlist('player_ids'): + try: + pid = int(raw) + except (ValueError, TypeError): + continue + if pid not in player_ids: + player_ids.append(pid) + + if not player_ids: + flash('Please select at least one player to evaluate.', 'warning') + return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id)) + + players = [] + for pid in player_ids: + player = User.query.get(pid) + if not player or not isinstance(player, Player): + continue + is_registered = TryoutRegistration.query.filter_by( + tryout_id=tryout_id, player_id=pid, + ).first() is not None + if not is_registered: + continue + existing = Evaluation.query.filter_by( + tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id, + ).first() + existing_scores = { + field_name: getattr(existing, field_name) if existing else None + for field_name, _ in EVALUATION_CRITERIA + } + players.append({ + 'player': player, + 'existing': existing, + 'existing_scores': existing_scores, + }) + + if not players: + flash('No valid players selected for evaluation.', 'danger') + return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id)) + + if request.method == 'POST': + saved = 0 + for entry in players: + pid = entry['player'].id + scores = { + field_name: validate_score(request.form.get(f'{field_name}_{pid}')) + for field_name, _ in EVALUATION_CRITERIA + } + comments = request.form.get(f'comments_{pid}') + position = request.form.get(f'position_recommendation_{pid}') + + existing = entry['existing'] + if existing: + _apply_evaluation(existing, scores, comments, position) + else: + evaluation = Evaluation( + tryout_id=tryout_id, player_id=pid, + evaluator_id=current_user.id, + ) + _apply_evaluation(evaluation, scores, comments, position) + db.session.add(evaluation) + saved += 1 + + db.session.commit() + flash(f'Saved evaluations for {saved} player(s).', 'success') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + return render_template('pages/batch_evaluate.html', + tryout=tryout, players=players, + evaluation_criteria=EVALUATION_CRITERIA, + game_positions=GAME_POSITIONS) diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..0e3294d --- /dev/null +++ b/app/routes/users.py @@ -0,0 +1,1270 @@ +"""User management routes for profiles, disponibilities, and contracts. + +Uses polymorphic isinstance checks instead of role-string comparisons. +""" + +import os +import uuid +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file +from flask_login import login_required, current_user +from app.extensions import db, hash_password, csrf +from app.models import ( + Admin, Manager, Coach, Player, Scout, + User, USER_TYPES, ESPORT_GAMES, + PlayerDisponibility, UserGamertag, GAME_PLATFORMS, + Contract, OrgTeam, CoachAvailability, + TeamNote, PersonalNote, OneOnOneRequest, + Evaluation, Match, Team, TeamMember, + MatchParticipant, Tryout, TryoutRegistration, TeamPlayer, +) +from werkzeug.utils import secure_filename +from datetime import datetime, timedelta, date as date_type +from marshmallow import ValidationError +from app.validators import ( + CreateUserSchema, EditUserSchema, EditProfileSchema, + UploadContractSchema, OneOnOneRequestSchema, +) +import requests + +ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} +ALLOWED_SIGNED_EXTENSIONS = {'pdf'} + +users_bp = Blueprint('users', __name__, url_prefix='/users') + +# --------------------------------------------------------------------------- +# Gamertag helper (shared) +# --------------------------------------------------------------------------- + +def update_user_gamertags(user, selected_games): + """Update gamertags for a user based on form input.""" + existing_gamertags = {gt.game: gt for gt in user.gamertags} + for game in selected_games: + gamertag = request.form.get(f'gamertag_{game}', '').strip() + platform = request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None + existing = existing_gamertags.get(game) + if gamertag: + if existing: + existing.gamertag = gamertag + existing.platform = platform + else: + gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform) + db.session.add(gt) + elif existing: + db.session.delete(existing) + for game in existing_gamertags: + if game not in selected_games: + db.session.delete(existing_gamertags[game]) + + +# --------------------------------------------------------------------------- +# USER_TYPE → Model mapping for create_user +# --------------------------------------------------------------------------- + +_USER_CLASS_MAP = { + 'admin': Admin, + 'manager': Manager, + 'coach': Coach, + 'player': Player, + 'scout': Scout, +} + + +# =========================================================================== +# ROUTES +# =========================================================================== + +@users_bp.route('') +@login_required +def list_users(): + """List all users for management (Admin only).""" + if not isinstance(current_user, Admin): + flash('Only the president can manage users.', 'danger') + return redirect(url_for('main.dashboard')) + + users = User.query.order_by(User.role, User.username).all() + return render_template('pages/users.html', users=users, roles=USER_TYPES) + + +@users_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit_user(user_id): + """Edit an existing user (Admin only).""" + if not isinstance(current_user, Admin): + flash('Only the president can edit users.', 'danger') + return redirect(url_for('main.dashboard')) + + user = User.query.get_or_404(user_id) + + if request.method == 'POST': + full_name = request.form.get('full_name') + email = request.form.get('email') + phone = request.form.get('phone') + role = request.form.get('role') + is_active = request.form.get('is_active_account') == 'on' + + if role not in USER_TYPES: + flash('Invalid role selected.', 'danger') + return render_template('pages/edit_user.html', user=user, roles=USER_TYPES, + esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS) + + selected_games = request.form.getlist('games') + discord_username = request.form.get('discord_username', '').strip() + discord_user_id = request.form.get('discord_user_id', '').strip() + league_os_profile = request.form.get('league_os_profile', '').strip() + + # Change role via raw SQL to avoid polymorphic identity corruption. + # Must discard the entire session because the polymorphic discriminator + # change invalidates the identity map for this instance and anything + # that references it via relationships. + if user.role != role: + user_id_local = user.id + db.session.execute( + db.text("UPDATE users SET role = :role WHERE id = :id"), + {"role": role, "id": user_id_local} + ) + db.session.commit() + db.session.remove() # discard stale session entirely + user = User.query.get(user_id_local) # fresh session, correct class + + user.full_name = full_name + user.email = email + user.phone = phone + user.is_active_account = is_active + user.games = ','.join(selected_games) if selected_games else None + user.discord_username = discord_username or None + user.discord_user_id = discord_user_id or None + user.league_os_profile = league_os_profile or None + + update_user_gamertags(user, selected_games) + + password = request.form.get('password') + if password: + user.password_hash = hash_password(password) + + db.session.commit() + flash(f'User {user.username} updated successfully!', 'success') + return redirect(url_for('users.list_users')) + + user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} + for gt in user.gamertags} + return render_template('pages/edit_user.html', user=user, roles=USER_TYPES, + esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, + user_gamertags=user_gamertags) + + +@users_bp.route('//delete', methods=['POST']) +@login_required +def delete_user(user_id): + """Delete a user (Admin only).""" + if not isinstance(current_user, Admin): + flash('Only the president can delete users.', 'danger') + return redirect(url_for('main.dashboard')) + + if current_user.id == user_id: + flash('You cannot delete your own account.', 'danger') + return redirect(url_for('users.list_users')) + + user = User.query.get_or_404(user_id) + + Evaluation.query.filter( + db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id), + ).delete(synchronize_session=False) + PlayerDisponibility.query.filter_by(player_id=user_id).delete() + CoachAvailability.query.filter_by(coach_id=user_id).delete() + PersonalNote.query.filter( + db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id), + ).delete(synchronize_session=False) + TeamNote.query.filter_by(coach_id=user_id).delete() + OneOnOneRequest.query.filter( + db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id), + ).delete(synchronize_session=False) + UserGamertag.query.filter_by(user_id=user_id).delete() + Contract.query.filter_by(player_id=user_id).delete() + TryoutRegistration.query.filter_by(player_id=user_id).delete() + TeamPlayer.query.filter_by(player_id=user_id).delete() + TeamMember.query.filter_by(player_id=user_id).delete() + MatchParticipant.query.filter_by(player_id=user_id).delete() + OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None}) + OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None}) + Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) + Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) + Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) + OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) + Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id}) + + db.session.delete(user) + db.session.commit() + flash(f'User {user.username} has been removed.', 'success') + return redirect(url_for('users.list_users')) + + +@users_bp.route('/create', methods=['GET', 'POST']) +@login_required +def create_user(): + """Create a new user (Admin only). Uses the correct polymorphic subclass.""" + if not isinstance(current_user, Admin): + flash('Only the president can create users.', 'danger') + return redirect(url_for('main.dashboard')) + + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + full_name = request.form.get('full_name') + phone = request.form.get('phone') + role = request.form.get('role') + + if role not in USER_TYPES: + flash('Invalid role selected.', 'danger') + return render_template('pages/create_user.html', roles=USER_TYPES) + + if User.query.filter_by(username=username).first(): + flash('Username already exists.', 'danger') + return render_template('pages/create_user.html', roles=USER_TYPES) + + if User.query.filter_by(email=email).first(): + flash('Email already registered.', 'danger') + return render_template('pages/create_user.html', roles=USER_TYPES) + + hashed_password = hash_password(password) + user_cls = _USER_CLASS_MAP.get(role, Player) + user = user_cls( + username=username, password_hash=hashed_password, + role=role, full_name=full_name, + email=email, phone=phone, + ) + db.session.add(user) + db.session.commit() + flash(f'User {full_name} created as {role}!', 'success') + return redirect(url_for('users.list_users')) + + return render_template('pages/create_user.html', roles=USER_TYPES) + + +@users_bp.route('//view') +@login_required +def view_user(user_id): + """View a public profile for any user.""" + user = User.query.get_or_404(user_id) + return render_template('pages/view_user.html', profile_user=user) + + +@users_bp.route('/profile') +@login_required +def profile(): + """View the current user's profile.""" + contracts = None + if isinstance(current_user, Player): + contracts = Contract.query.filter_by( + player_id=current_user.id, + ).order_by(Contract.uploaded_at.desc()).all() + + existing_availability = None + if isinstance(current_user, Coach): + existing_availability = CoachAvailability.query.filter_by( + coach_id=current_user.id, + ).all() + + return render_template('pages/profile.html', user=current_user, contracts=contracts, + existing_availability=existing_availability) + + +@users_bp.route('/profile/edit', methods=['GET', 'POST']) +@login_required +def edit_profile(): + """Edit the current user's profile.""" + if request.method == 'POST': + username = request.form.get('username') + full_name = request.form.get('full_name') + email = request.form.get('email') + phone = request.form.get('phone') + + selected_games = request.form.getlist('games') + discord_username = request.form.get('discord_username', '').strip() + discord_user_id = request.form.get('discord_user_id', '').strip() + league_os_profile = request.form.get('league_os_profile', '').strip() + + if username != current_user.username and User.query.filter_by(username=username).first(): + flash('Username already taken.', 'danger') + return render_template('pages/edit_profile.html', user=current_user, + esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags()) + + if email != current_user.email and User.query.filter_by(email=email).first(): + flash('Email already in use.', 'danger') + return render_template('pages/edit_profile.html', user=current_user, + esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags()) + + current_user.username = username + current_user.full_name = full_name + current_user.email = email + current_user.phone = phone + current_user.games = ','.join(selected_games) if selected_games else None + current_user.discord_username = discord_username or None + current_user.discord_user_id = discord_user_id or None + current_user.league_os_profile = league_os_profile or None + + update_user_gamertags(current_user, selected_games) + + password = request.form.get('password') + if password: + current_user.password_hash = hash_password(password) + + db.session.commit() + flash('Profile updated successfully!', 'success') + return redirect(url_for('users.profile')) + + return render_template('pages/edit_profile.html', user=current_user, + esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags()) + + +# --------------------------------------------------------------------------- +# Disponibilities +# --------------------------------------------------------------------------- + +DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + + +def add_30_minutes(t): + return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time() + + +@users_bp.route('/disponibilities') +@login_required +def get_disponibilities(): + """API endpoint to get all player disponibilities for scheduling.""" + if not current_user.can_manage_teams() and not current_user.can_schedule_matches(): + return jsonify({'error': 'Unauthorized'}), 403 + + players = User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() + result = {} + for player in players: + disponibilities = list(player.disponibilities) + result[player.id] = { + 'username': player.username, + 'disponibilities': [ + { + 'id': d.id, 'day_of_week': d.day_of_week, + 'day_name': DAY_NAMES[d.day_of_week], + 'start_time': d.start_time.strftime('%H:%M'), + 'end_time': d.end_time.strftime('%H:%M'), + } + for d in disponibilities + ], + } + return jsonify(result) + + +@users_bp.route('/disponibilities/my') +@login_required +def get_my_disponibilities(): + """API endpoint for players to get their own disponibilities.""" + disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all() + result = {} + for d in disponibilities: + day = d.day_of_week + if day not in result: + result[day] = [] + result[day].append({ + 'id': d.id, 'day_of_week': d.day_of_week, + 'day_name': DAY_NAMES[d.day_of_week], + 'start_time': d.start_time.strftime('%H:%M'), + 'end_time': d.end_time.strftime('%H:%M'), + }) + return jsonify(result) + + +@users_bp.route('/disponibilities/add', methods=['POST']) +@login_required +def add_disponibility(): + """Add a disponibility block for the current player.""" + day_of_week = request.form.get('day_of_week', type=int) + start_time_str = request.form.get('start_time') + if day_of_week is None or day_of_week < 0 or day_of_week > 6: + return jsonify({'error': 'Invalid day of week'}), 400 + try: + start_time = datetime.strptime(start_time_str, '%H:%M').time() + except (ValueError, TypeError): + return jsonify({'error': 'Invalid time format'}), 400 + + end_time = add_30_minutes(start_time) + disponibility = PlayerDisponibility( + player_id=current_user.id, day_of_week=day_of_week, + start_time=start_time, end_time=end_time, + ) + db.session.add(disponibility) + db.session.commit() + return jsonify({ + 'id': disponibility.id, 'day_of_week': disponibility.day_of_week, + 'day_name': DAY_NAMES[disponibility.day_of_week], + 'start_time': disponibility.start_time.strftime('%H:%M'), + 'end_time': disponibility.end_time.strftime('%H:%M'), + }) + + +@users_bp.route('/disponibilities/add_bulk', methods=['POST']) +@login_required +def add_disponibilities_bulk(): + """Replace the player's disponibilities with the submitted slots. + + Performs a full replace (delete existing + insert submitted) so that + deselected slots are correctly removed, mirroring the coach availability + flow. This keeps the auto-save idempotent and correct. + """ + data = request.get_json() + slots = data.get('slots', []) if data else [] + + # Clear existing disponibilities for the current player. + PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() + + 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) + disponibility = PlayerDisponibility( + player_id=current_user.id, day_of_week=day_of_week, + start_time=start_time, end_time=end_time, + ) + db.session.add(disponibility) + db.session.flush() + created.append({ + 'id': disponibility.id, 'day_of_week': disponibility.day_of_week, + 'day_name': DAY_NAMES[disponibility.day_of_week], + 'start_time': disponibility.start_time.strftime('%H:%M'), + }) + db.session.commit() + return jsonify({'success': True, 'created': created}) + + +@users_bp.route('/disponibilities/clear', methods=['POST']) +@login_required +def clear_disponibilities(): + """Clear all disponibilities for the current player.""" + PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() + db.session.commit() + return jsonify({'success': True}) + + +@users_bp.route('/disponibilities//delete', methods=['POST']) +@login_required +def delete_disponibility(disponibility_id): + """Delete a disponibility block.""" + disponibility = PlayerDisponibility.query.get_or_404(disponibility_id) + if disponibility.player_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + db.session.delete(disponibility) + db.session.commit() + return jsonify({'success': True}) + + +# --------------------------------------------------------------------------- +# Contracts +# --------------------------------------------------------------------------- + +def can_manage_player_contract(user, player_id): + """Check if a user can upload contracts for a specific player.""" + if isinstance(user, Admin): + return True + if isinstance(user, Manager): + return True + if isinstance(user, Coach): + org_team = OrgTeam.query.filter_by(coach_id=user.id).first() + if org_team: + tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=org_team.id).first() + if tp: + return True + return False + + +@users_bp.route('/contracts') +@login_required +def list_contracts(): + """View contracts for the current user or players they manage.""" + contracts = None + players = None + + if isinstance(current_user, Player): + contracts = Contract.query.filter_by( + player_id=current_user.id, + ).order_by(Contract.uploaded_at.desc()).all() + elif isinstance(current_user, (Admin, Manager, Coach)): + players = [] + if isinstance(current_user, Coach): + org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() + if org_team: + player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] + players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else [] + else: + players = User.query.filter_by(role='player').all() + + if players: + player_ids = [p.id for p in players] + contracts = Contract.query.filter( + Contract.player_id.in_(player_ids), + ).order_by(Contract.uploaded_at.desc()).all() + + return render_template('pages/contracts.html', contracts=contracts, players=players + if isinstance(current_user, (Admin, Manager, Coach)) else None) + + +@users_bp.route('/contracts/upload', methods=['GET', 'POST']) +@login_required +def upload_contract(): + """Upload a contract for a player.""" + if not isinstance(current_user, (Admin, Manager, Coach)): + flash('Only presidents, managers, and coaches can upload contracts.', 'danger') + return redirect(url_for('users.list_contracts')) + + if isinstance(current_user, Coach): + org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() + if org_team: + player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] + players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else [] + else: + players = [] + else: + players = User.query.filter_by(role='player').all() + + if request.method == 'POST': + contract_schema = UploadContractSchema() + try: + validated = contract_schema.load(request.form) + except ValidationError as err: + for field, messages in err.messages.items(): + for msg in messages: + flash(f'{field}: {msg}', 'danger') + return render_template('pages/upload_contract.html', players=players) + + player_id = validated['player_id'] + notes = validated.get('notes') + + if not can_manage_player_contract(current_user, player_id): + flash('You do not have permission to upload a contract for this player.', 'danger') + return redirect(url_for('users.upload_contract')) + + if 'contract_file' not in request.files: + flash('No file selected.', 'danger') + return redirect(url_for('users.upload_contract')) + + file = request.files['contract_file'] + if file.filename == '': + flash('No file selected.', 'danger') + return redirect(url_for('users.upload_contract')) + if not file.filename.lower().endswith('.pdf'): + flash('Only PDF files are allowed for contracts.', 'danger') + return redirect(url_for('users.upload_contract')) + + upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés') + os.makedirs(upload_dir, exist_ok=True) + + player = User.query.get_or_404(player_id) + player_teams = player.get_org_teams() + team = player_teams[0] if player_teams else None + + if team: + team_folder = os.path.join(upload_dir, secure_filename(team.name)) + os.makedirs(team_folder, exist_ok=True) + final_dir = team_folder + else: + final_dir = upload_dir + + original_filename = secure_filename(file.filename) + file_uuid = str(uuid.uuid4()) + stored_filename = f"{file_uuid}.pdf" + file_path = os.path.join(final_dir, stored_filename) + file.save(file_path) + + contract = Contract( + player_id=player_id, team_id=team.id if team else None, + uploaded_by_id=current_user.id, + original_filename=original_filename, + stored_filename=stored_filename, + file_path=file_path, + notes=notes if notes else None, + ) + db.session.add(contract) + db.session.commit() + flash(f'Contract uploaded successfully for {player.username}!', 'success') + return redirect(url_for('users.list_contracts')) + + return render_template('pages/upload_contract.html', players=players) + + +@users_bp.route('/contracts//upload_signed', methods=['POST']) +@login_required +def upload_signed_contract(contract_id): + """Upload a signed contract (player only).""" + contract = Contract.query.get_or_404(contract_id) + if not contract.can_upload_signed(current_user): + flash('Only the player can upload their signed contract.', 'danger') + return redirect(url_for('users.list_contracts')) + + if 'signed_file' not in request.files: + flash('No file selected.', 'danger') + return redirect(url_for('users.list_contracts')) + + file = request.files['signed_file'] + if file.filename == '': + flash('No file selected.', 'danger') + return redirect(url_for('users.list_contracts')) + + signed_filename = f"signed_{contract.stored_filename}" + file.save(contract.file_path.replace(contract.stored_filename, signed_filename)) + + contract.signed_filename = signed_filename + contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename) + contract.status = 'signed' + contract.signed_at = datetime.utcnow() + db.session.commit() + flash('Signed contract uploaded successfully!', 'success') + return redirect(url_for('users.list_contracts')) + + +@users_bp.route('/contracts//download') +@login_required +def download_contract(contract_id): + """Download a contract file.""" + contract = Contract.query.get_or_404(contract_id) + if not contract.can_view(current_user): + flash('You do not have permission to download this contract.', 'danger') + return redirect(url_for('users.list_contracts')) + return send_file(contract.file_path, as_attachment=True, download_name=contract.original_filename) + + +@users_bp.route('/contracts//download_signed') +@login_required +def download_signed_contract(contract_id): + """Download a signed contract file.""" + contract = Contract.query.get_or_404(contract_id) + if not contract.can_view(current_user): + flash('You do not have permission to download this contract.', 'danger') + return redirect(url_for('users.list_contracts')) + if not contract.signed_file_path: + flash('No signed contract available.', 'danger') + return redirect(url_for('users.list_contracts')) + return send_file(contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename) + + +# --------------------------------------------------------------------------- +# One on One +# --------------------------------------------------------------------------- + +DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '') + + +def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str, + team_name, coach_name, coach_discord, coach_discord_id, request_id=None): + """Send a Discord notification for a One on One request.""" + import logging + logger = logging.getLogger(__name__) + + if coach_discord_id: + try: + from app.discord_bot import send_one_on_one_dm + send_one_on_one_dm( + coach_name=coach_name, coach_discord_id=coach_discord_id, + player_name=player_name, team_name=team_name, + date_str=date_str, start_time=start_time_str, + end_time=end_time_str, points=points, request_id=request_id, + ) + except Exception as e: + logger.warning(f"Failed to send Discord DM: {e}") + + if DISCORD_WEBHOOK_URL: + try: + from app.discord_bot import send_one_on_one_dm + if DISCORD_WEBHOOK_URL.isdigit() and not coach_discord_id: + send_one_on_one_dm( + coach_name=coach_name, coach_discord_id=DISCORD_WEBHOOK_URL, + player_name=player_name, team_name=team_name, + date_str=date_str, start_time=start_time_str, + end_time=end_time_str, points=points, + ) + elif not DISCORD_WEBHOOK_URL.isdigit(): + embed = { + "embeds": [{ + "title": "One on One Request", "color": 3447003, + "fields": [ + {"name": "Player", "value": player_name, "inline": True}, + {"name": "Team", "value": team_name or "Unknown Team", "inline": True}, + {"name": "Date", "value": date_str, "inline": True}, + {"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True}, + {"name": "Discussion Points", "value": points or "No specific points provided", "inline": False}, + ], + "footer": { + "text": f"Coach: {coach_name}" + + (f" (Discord: {coach_discord})" if coach_discord else ""), + }, + }], + } + requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5) + except Exception as e: + logger.warning(f"Failed to send Discord notification: {e}") + + +@users_bp.route('/one-on-one', methods=['GET', 'POST']) +@login_required +def one_on_one(): + """One on One request page for players.""" + if not isinstance(current_user, Player): + flash('Only players can request One on One sessions.', 'danger') + return redirect(url_for('main.dashboard')) + + org_teams = current_user.get_org_teams() + org_team = org_teams[0] if org_teams else None + coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None + + if not coach: + flash('You do not have a coach assigned to your team.', 'info') + + team_notes = [] + if org_team: + team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() + + personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all() + + coach_availability = [] + if coach: + availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all() + coach_availability = [ + { + 'day_of_week': av.day_of_week, + 'start_time': av.start_time.strftime('%H:%M'), + 'end_time': av.end_time.strftime('%H:%M'), + } + for av in availabilities + ] + + if request.method == 'POST': + date_str = request.form.get('date') + start_time_str = request.form.get('start_time') + end_time_str = request.form.get('end_time') + points = request.form.get('points', '').strip() + + if not coach: + flash('Cannot request One on One - no coach assigned.', 'danger') + return redirect(url_for('users.one_on_one')) + + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + start_time = datetime.strptime(start_time_str, '%H:%M').time() + end_time = datetime.strptime(end_time_str, '%H:%M').time() + except (ValueError, TypeError): + flash('Invalid date or time format.', 'danger') + return redirect(url_for('users.one_on_one')) + + check_date = datetime.strptime(date_str, '%Y-%m-%d') + day_of_week = check_date.weekday() + + is_available = any( + av['day_of_week'] == day_of_week + and av['start_time'] <= start_time_str + and av['end_time'] >= end_time_str + for av in coach_availability + ) + + if not is_available: + flash("The requested time is not within the coach's availability.", 'danger') + return redirect(url_for('users.one_on_one')) + + request_obj = OneOnOneRequest( + player_id=current_user.id, coach_id=coach.id, + org_team_id=org_team.id if org_team else None, + date=date_obj, start_time=start_time, end_time=end_time, + points=points if points else None, + ) + db.session.add(request_obj) + db.session.commit() + + send_discord_notification( + player_name=current_user.full_name, + points=points, date_str=date_str, + start_time_str=start_time_str, end_time_str=end_time_str, + team_name=org_team.name if org_team else 'Unknown Team', + coach_name=coach.full_name, + coach_discord=coach.discord_username or '', + coach_discord_id=coach.discord_user_id or '', + request_id=request_obj.id, + ) + + flash('Your One on One request has been submitted!', 'success') + return redirect(url_for('users.one_on_one')) + + # Build list of upcoming dates that have coach availability + from datetime import date as date_cls, timedelta as td + today = date_cls.today() + available_days = {av['day_of_week'] for av in coach_availability} + dates = [] + for i in range(14): # Next 14 days + d = today + td(days=i) + if d.weekday() in available_days: + dates.append({ + 'value': d.strftime('%Y-%m-%d'), + 'day_of_week': d.weekday(), + 'display': d.strftime('%B %d, %Y (%A)'), + }) + + # Player's own One on One request history + my_requests = OneOnOneRequest.query.filter_by( + player_id=current_user.id + ).order_by(OneOnOneRequest.created_at.desc()).all() + + return render_template('pages/one_on_one.html', + org_team=org_team, coach=coach, + team_notes=team_notes, personal_notes=personal_notes, + coach_availability=coach_availability, + dates=dates, + my_requests=my_requests) + + +@users_bp.route('/one-on-one//accept', methods=['POST']) +@login_required +def accept_one_on_one(request_id): + """Coach accepts a One on One request.""" + if not isinstance(current_user, Coach): + flash('Only coaches can accept One on One requests.', 'danger') + return redirect(url_for('main.dashboard')) + + request_obj = OneOnOneRequest.query.get_or_404(request_id) + + if request_obj.coach_id != current_user.id: + flash('This request is not for you.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + if request_obj.status != 'pending': + flash('This request has already been processed.', 'info') + return redirect(url_for('users.notes_dashboard')) + + player = request_obj.player + request_obj.status = 'approved' + request_obj.responded_at = datetime.utcnow() + db.session.commit() + + # Notify player via Discord (same message as if approved through Discord reactions) + if player and player.discord_user_id: + from app.discord_bot import send_one_on_one_response + send_one_on_one_response( + player_discord_id=player.discord_user_id, + player_full_name=player.full_name, + coach_full_name=current_user.full_name, + date_str=request_obj.date.strftime('%A, %B %d, %Y'), + start_time=request_obj.start_time.strftime('%I:%M %p') if request_obj.start_time else 'TBD', + end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD', + points=request_obj.points or 'No specific points provided', + approved=True, + ) + + flash(f'One on One request from {player.username if player else "Unknown"} has been approved!', 'success') + return redirect(url_for('users.notes_dashboard')) + + +@users_bp.route('/one-on-one//reject', methods=['POST']) +@login_required +def reject_one_on_one(request_id): + """Coach rejects a One on One request.""" + if not isinstance(current_user, Coach): + flash('Only coaches can reject One on One requests.', 'danger') + return redirect(url_for('main.dashboard')) + + request_obj = OneOnOneRequest.query.get_or_404(request_id) + + if request_obj.coach_id != current_user.id: + flash('This request is not for you.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + if request_obj.status != 'pending': + flash('This request has already been processed.', 'info') + return redirect(url_for('users.notes_dashboard')) + + rejection_reason = request.form.get('rejection_reason', '').strip() + player = request_obj.player + + request_obj.status = 'rejected' + request_obj.responded_at = datetime.utcnow() + if rejection_reason: + request_obj.coach_rejection_message = rejection_reason + db.session.commit() + + # Notify player via Discord (same message as if rejected through Discord reactions) + if player and player.discord_user_id: + from app.discord_bot import send_one_on_one_response + send_one_on_one_response( + player_discord_id=player.discord_user_id, + player_full_name=player.full_name, + coach_full_name=current_user.full_name, + date_str=request_obj.date.strftime('%A, %B %d, %Y'), + start_time=request_obj.start_time.strftime('%I:%M %p') if request_obj.start_time else 'TBD', + end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD', + points=request_obj.points or 'No specific points provided', + approved=False, + refusal_note=rejection_reason or None, + ) + + flash(f'One on One request from {player.username if player else "Unknown"} has been rejected.', 'info') + return redirect(url_for('users.notes_dashboard')) + + +# --------------------------------------------------------------------------- +# My Notes (Player) +# --------------------------------------------------------------------------- + +@users_bp.route('/my-notes') +@login_required +def my_notes(): + """View personal and team notes for the current player.""" + if not isinstance(current_user, Player): + flash('This page is for players only.', 'info') + return redirect(url_for('main.dashboard')) + + org_teams = current_user.get_org_teams() + org_team = org_teams[0] if org_teams else None + + personal_notes = PersonalNote.query.filter_by( + player_id=current_user.id, + ).order_by(PersonalNote.created_at.desc()).all() + + 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) + + +# --------------------------------------------------------------------------- +# Coach Availability +# --------------------------------------------------------------------------- + +@users_bp.route('/coach-availability', methods=['GET', 'POST']) +@login_required +def manage_coach_availability(): + """Manage coach availability for One on One sessions.""" + if not isinstance(current_user, 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', []) if data else [] + + # Clear existing availability + CoachAvailability.query.filter_by(coach_id=current_user.id).delete() + + # Add new slots + 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() + end_time = (datetime.combine(datetime.today(), start_time) + timedelta(minutes=30)).time() + except (ValueError, TypeError): + continue + + 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.commit() + return jsonify({'success': True}) + + existing_availability = CoachAvailability.query.filter_by( + coach_id=current_user.id, + ).all() + + return render_template('pages/coach_availability.html', + existing_availability=existing_availability) + + +@users_bp.route('/coach-availability/clear', methods=['POST']) +@login_required +def clear_coach_availability(): + """Clear all coach availability slots.""" + if not isinstance(current_user, Coach): + return jsonify({'error': 'Unauthorized'}), 403 + + CoachAvailability.query.filter_by(coach_id=current_user.id).delete() + db.session.commit() + return jsonify({'success': True}) + + +# --------------------------------------------------------------------------- +# Notes Dashboard (Coach) +# --------------------------------------------------------------------------- + +@users_bp.route('/notes-dashboard') +@login_required +def notes_dashboard(): + """Notes and One on One dashboard for coaches.""" + if not isinstance(current_user, Coach): + flash('Only coaches can access the notes dashboard.', 'danger') + return redirect(url_for('main.dashboard')) + + 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)).all() if player_ids else [] + + team_notes = [] + personal_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 + + personal_notes = PersonalNote.query.filter_by( + coach_id=current_user.id, + ).order_by(PersonalNote.created_at.desc()).all() + + # One on One requests from team players + one_on_one_requests = [] + if org_team and players: + player_ids_list = [p.id for p in players] + one_on_one_requests = OneOnOneRequest.query.filter( + OneOnOneRequest.player_id.in_(player_ids_list) + ).order_by(OneOnOneRequest.created_at.desc()).all() + + # For context selectors in the form + from app.models import Team as MatchTeam + matches = Match.query.filter( + db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'), + ).order_by(Match.date.desc()).limit(20).all() + tryouts = Tryout.query.filter_by( + created_by=current_user.id, + ).order_by(Tryout.date.desc()).limit(20).all() + teams = OrgTeam.query.order_by(OrgTeam.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, + one_on_one_requests=one_on_one_requests, + matches=matches, + tryouts=tryouts, + teams=teams) + + +# --------------------------------------------------------------------------- +# Manage Team Notes (POST) +# --------------------------------------------------------------------------- + +@users_bp.route('/team-notes/manage', methods=['POST']) +@login_required +def manage_team_notes(): + """Create or update team notes for the coach's org team.""" + if not isinstance(current_user, Coach): + flash('Only coaches can manage team notes.', 'danger') + return redirect(url_for('main.dashboard')) + + org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() + if not org_team: + flash('You are not assigned to a team.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + content = request.form.get('content', '').strip() + if content: + 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 saved successfully!', 'success') + + return redirect(url_for('users.notes_dashboard')) + + +# --------------------------------------------------------------------------- +# Manage Personal Notes (POST, simple form) +# --------------------------------------------------------------------------- + +@users_bp.route('/personal-notes/manage', methods=['POST']) +@login_required +def manage_personal_notes(): + """Create a personal note for a player (coach only, simple form).""" + if not isinstance(current_user, Coach): + flash('Only coaches can manage personal notes.', 'danger') + return redirect(url_for('main.dashboard')) + + player_id = request.form.get('player_id', type=int) + content = request.form.get('content', '').strip() + + if not player_id or not content: + flash('Player and content are required.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + player = User.query.get_or_404(player_id) + if not isinstance(player, Player): + flash('Can only add notes for players.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + note = PersonalNote( + player_id=player_id, + coach_id=current_user.id, + content=content, + ) + db.session.add(note) + db.session.commit() + flash(f'Note added for {player.username}.', 'success') + return redirect(url_for('users.notes_dashboard')) + + +# --------------------------------------------------------------------------- +# Add Personal Note (POST, full form with context) +# --------------------------------------------------------------------------- + +@users_bp.route('/personal-notes/add', methods=['POST']) +@login_required +def add_personal_note(): + """Create a personal note for a player with optional context (coach only).""" + if not isinstance(current_user, Coach): + flash('Only coaches can add personal notes.', 'danger') + return redirect(url_for('main.dashboard')) + + 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_str = request.form.get('team_id') + + if not player_id or not content: + flash('Player and content are required.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + player = User.query.get_or_404(player_id) + if not isinstance(player, Player): + flash('Can only add notes for players.', 'danger') + return redirect(url_for('users.notes_dashboard')) + + note = PersonalNote( + player_id=player_id, + coach_id=current_user.id, + content=content, + match_id=match_id if match_id else None, + tryout_id=tryout_id if tryout_id else None, + team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None, + ) + db.session.add(note) + db.session.commit() + flash(f'Note added for {player.username}.', 'success') + return redirect(url_for('users.notes_dashboard')) + + +# --------------------------------------------------------------------------- +# Add Note from Tryout context (GET + POST) +# --------------------------------------------------------------------------- + +@users_bp.route('/personal-notes/tryout/', methods=['GET', 'POST']) +@login_required +def add_note_from_tryout(tryout_id): + """Add a personal note for a player in the context of a tryout.""" + if not isinstance(current_user, Coach): + flash('Only coaches can add personal notes.', 'danger') + return redirect(url_for('main.dashboard')) + + tryout = Tryout.query.get_or_404(tryout_id) + preselected_player_id = request.args.get('player_id', type=int) + + # Get registrations as players for the select list + registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() + players = [r.player for r in registrations if r.player] + + 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('Player and content are required.', 'danger') + return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) + + 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('Note added successfully.', 'success') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + return render_template('pages/add_note.html', + context_type='tryout', + tryout=tryout, + players=players, + preselected_player_id=preselected_player_id, + team_notes=[]) + + +# --------------------------------------------------------------------------- +# Add Note from Match context (GET + POST) +# --------------------------------------------------------------------------- + +@users_bp.route('/personal-notes/match/', methods=['GET', 'POST']) +@login_required +def add_note_from_match(match_id): + """Add a personal note for a player in the context of a match.""" + if not isinstance(current_user, Coach): + flash('Only coaches can add personal notes.', 'danger') + return redirect(url_for('main.dashboard')) + + match_obj = Match.query.get_or_404(match_id) + + # Get participants as players for the select list + participants = MatchParticipant.query.filter_by(match_id=match_id).all() + players = [p.player for p in participants if p.player] + + preselected_player_id = request.args.get('player_id', type=int) + + 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('Player and content are required.', 'danger') + return redirect(url_for('users.add_note_from_match', match_id=match_id)) + + 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('Note added successfully.', 'success') + return redirect(url_for('tryouts.view_tryout', tryout_id=match_obj.tryout_id)) + + return render_template('pages/add_note.html', + context_type='match', + tryout=match_obj, + match=match_obj, + players=players, + preselected_player_id=preselected_player_id, + team_notes=[]) diff --git a/app/static/css/style.css b/app/static/css/style.css index 3031142..bf182ed 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2037,3 +2037,30 @@ a:hover { color: var(--primary-dark); } .honeypot { display: none; } + +/* Batch Evaluation - 2 cards wide layout */ +.batch-eval-form { + max-width: none; +} + +.batch-eval-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + margin-bottom: 20px; +} + +.batch-eval-grid .card { + margin-bottom: 0; +} + +/* Allow criteria rows to wrap for a 3x3 grid inside each card */ +.batch-eval-grid .form-row { + flex-wrap: wrap; +} + +@media (max-width: 1100px) { + .batch-eval-grid { + grid-template-columns: 1fr; + } +} diff --git a/app/templates/pages/batch_evaluate.html b/app/templates/pages/batch_evaluate.html new file mode 100644 index 0000000..815e763 --- /dev/null +++ b/app/templates/pages/batch_evaluate.html @@ -0,0 +1,85 @@ +{% extends "layouts/base.html" %} +{% block title %}Evaluate Players - UdeS team manager{% endblock %} +{% block page_title %}Evaluate Players{% endblock %} +{% block breadcrumb %}Home / {{ tryout.title }} / Evaluate{% endblock %} + +{% block content %} +
+ + {% for entry in players %} + + {% endfor %} + + {% set positions = game_positions.get(tryout.game, []) %} + +
+ {% for entry in players %} +
+
+

+ {{ entry.player.username }} + {% if entry.existing %} + Already Evaluated + {% else %} + Not Evaluated + {% endif %} +

+
+
+
+
{{ entry.player.username[:2] | upper }}
+
+

{{ entry.player.username }}

+

{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}

+
+
+ + {% set pid = entry.player.id %} + {% set existing = entry.existing %} + {% set existing_scores = entry.existing_scores %} + +
+ {% for field_name, label in evaluation_criteria %} +
+ +
+ + {{ existing_scores[field_name] or 5 }} +
+
+ {% endfor %} +
+ +
+
+ + {% if positions %} + + {% else %} + + {% endif %} +
+
+ +
+ + +
+
+
+ {% endfor %} +
+ +
+ Back + +
+
+{% endblock %} diff --git a/app/templates/pages/players_to_evaluate.html b/app/templates/pages/players_to_evaluate.html index 3c588c1..93c8048 100644 --- a/app/templates/pages/players_to_evaluate.html +++ b/app/templates/pages/players_to_evaluate.html @@ -6,54 +6,76 @@ {% block content %}
-

Players in {{ tryout.title }}

+

Select players to evaluate in {{ tryout.title }}

-
- - - - - - - - - - - - {% for entry in players %} - - + + + + + + {% else %} + + + + {% endfor %} + +
{{ _('Player') }}{{ _('Contact') }}{{ _('Attendance') }}{{ _('Status') }}{{ _('Actions') }}
-
+

Choose the players you want to evaluate, then load all of them on a single page.

+
+
+ + + + + + + + + + + + + {% for entry in players %} + + + - - - - - - {% else %} - - - - {% endfor %} - -
PlayerContactAttendanceStatusActions
+ + +
{{ entry.player.username[:2] | upper }}
{{ entry.player.username }} -
-
{{ entry.player.email }} - {{ entry.registration.status }} - - {% if entry.evaluated %} - Evaluated - {% else %} - Not Evaluated - {% endif %} - - - {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %} - -
{{ _('No players registered for this tryout.') }}
-
+
+
{{ entry.player.email }} + {{ entry.registration.status }} + + {% if entry.evaluated %} + Evaluated + {% else %} + Not Evaluated + {% endif %} + + + {% if entry.evaluated %}View/Edit{% else %}Single{% endif %} + +
No players registered for this tryout.
+
+
+ Cancel + +
+
+ + {% endblock %} diff --git a/app/templates/pages/profile.html b/app/templates/pages/profile.html index 4b71bc1..89092a2 100644 --- a/app/templates/pages/profile.html +++ b/app/templates/pages/profile.html @@ -193,11 +193,8 @@

{{ _('Loading...') }}

- -
@@ -369,7 +366,7 @@ function saveCoachAvailability() { const msg = document.createElement('div'); msg.className = 'alert alert-success'; msg.style.marginTop = '10px'; - msg.innerHTML = ' Availability saved!'; + msg.innerHTML = ' Availability saved!'; document.getElementById('availability-grid').appendChild(msg); setTimeout(() => msg.remove(), 3000); } @@ -571,7 +568,7 @@ function saveDisponibilities() { var msg = document.createElement('div'); msg.className = 'alert alert-success'; msg.style.marginTop = '10px'; - msg.innerHTML = ' Disponibilities saved successfully!'; + msg.innerHTML = ' Disponibilities saved successfully!'; document.getElementById('disponibilities-grid').appendChild(msg); setTimeout(function() { msg.remove(); }, 3000); }