diff --git a/app/routes/users.py b/app/routes/users.py deleted file mode 100644 index 5b7fd4f..0000000 --- a/app/routes/users.py +++ /dev/null @@ -1,1699 +0,0 @@ -"""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 flask_babel import gettext as _ -from app.extensions import db, hash_password -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 -from marshmallow import ValidationError -from app.validators import ( - CreateUserSchema, - EditUserSchema, - EditProfileSchema, - UploadContractSchema, -) -from app.logging_config import log_auth_event -from app.permissions import ( - can_manage_player_contract, - coach_can_access_player, - coach_org_teams, - coach_player_ids, -) -import requests - -ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} -ALLOWED_SIGNED_EXTENSIONS = {'pdf'} - -#: Every PDF starts with this. Checking the name alone accepted a file -#: called anything.pdf holding anything at all. -PDF_SIGNATURE = b'%PDF-' - -users_bp = Blueprint('users', __name__, url_prefix='/users') - - -def pdf_upload_error(file, allowed_extensions): - """Why this upload is not an acceptable PDF, or None if it is. - - upload_signed_contract checked nothing beyond a non-empty filename — - ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player - could put an arbitrary file on the server under a name the application - later hands back for download (SEC-021). - - Args: - file: The uploaded FileStorage, or None. - allowed_extensions: Extensions to accept, lowercase and without dot. - - Returns: - str | None: A message to flash, or None when the file is acceptable. - """ - if file is None or not file.filename: - return _('No file selected.') - - stem, dot, extension = file.filename.rpartition('.') - if not (stem and dot) or extension.lower() not in allowed_extensions: - return _('Only PDF files are allowed for contracts.') - - head = file.stream.read(len(PDF_SIGNATURE)) - file.stream.seek(0) - if head != PDF_SIGNATURE: - return _('That file is not a PDF, whatever its name says.') - - return None - - -# --------------------------------------------------------------------------- -# Form helpers shared by the schema-validated routes -# --------------------------------------------------------------------------- - - -def _flash_validation_errors(err): - """Surface marshmallow errors the same way auth.py already does.""" - for field, messages in err.messages.items(): - for msg in messages: - flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger') - - -def _form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)): - """Turn the multi-valued request form into a plain dict for marshmallow. - - request.form.to_dict() keeps only the first value of a repeated key, so - list fields have to be re-read with getlist(). Unchecked HTML checkboxes - are simply absent from the submission, which is not the same as a schema - default, so they are injected explicitly. Blank optional fields are - dropped rather than sent as '' — an empty password means "leave the - current one alone", not "set the password to the empty string". - """ - payload = request.form.to_dict() - for name in list_fields: - payload[name] = request.form.getlist(name) - for name in checkboxes: - payload[name] = name in request.form - for name in optional_blank: - if not payload.get(name): - payload.pop(name, None) - return payload - - -# --------------------------------------------------------------------------- -# 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': - actor_name, actor_id = current_user.username, current_user.id - - def _rerender(): - return render_template( - 'pages/edit_user.html', - user=user, - roles=USER_TYPES, - esport_games=ESPORT_GAMES, - game_platforms=GAME_PLATFORMS, - user_gamertags={ - gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} - for gt in user.gamertags - }, - ) - - try: - validated = EditUserSchema().load(_form_payload(checkboxes=('is_active_account',))) - except ValidationError as err: - _flash_validation_errors(err) - return _rerender() - - full_name = validated['full_name'] - email = validated['email'] - phone = validated.get('phone') - role = validated['role'] - is_active = validated['is_active_account'] - selected_games = validated.get('games', []) - discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') - league_os_profile = validated.get('league_os_profile') - - # Previously absent: the column is unique, so assigning a taken - # address surfaced as an IntegrityError, i.e. a 500. - clash = User.query.filter(User.email == email, User.id != user.id).first() - if clash: - flash(_('Email already in use by another account.'), 'danger') - return _rerender() - - role_changed = user.role != role - previous_role = user.role - - if role_changed: - # Two ways to lock everyone out of administration, neither of - # which any interface can undo afterwards. - if user.id == actor_id: - flash( - _('You cannot change your own role. Ask another president to do it.'), 'danger' - ) - return _rerender() - - if user.role == 'admin': - remaining_admins = User.query.filter( - User.role == 'admin', - User.is_active_account.is_(True), - User.id != user.id, - ).count() - if remaining_admins == 0: - flash( - _( - 'This is the last active president. Promote ' - 'another account before changing this one.' - ), - 'danger', - ) - return _rerender() - - if role_changed: - # The role column is the polymorphic discriminator, and SQLAlchemy - # decides an instance's class when it loads it. Assigning to it - # through the ORM leaves a Player object in the identity map for a - # row that now says 'coach', so every later isinstance() check — - # which is how this application does authorisation — answers with - # the old role. Hence the statement-level UPDATE. - # - # The instance then has to be re-read. This used to call - # db.session.remove(), which throws away the whole session: - # everything the request still held was detached, current_user - # included, and the next attribute access on any of them raised - # DetachedInstanceError. Expunging the one stale instance is - # enough, and it leaves the transaction open — so the role change - # and the rest of the edit now commit together instead of the - # role landing on its own and the remaining fields failing after - # it (ARCH-008). - user_pk = user.id - db.session.execute( - db.text('UPDATE users SET role = :role WHERE id = :id'), - {'role': role, 'id': user_pk}, - ) - db.session.expunge(user) - user = db.session.get(User, user_pk) - - 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) - - # Blank means "keep the current password"; anything else has already - # been checked against the policy by the schema. - password = validated.get('password') - if password: - user.password_hash = hash_password(password) - - db.session.commit() - - # Logged after the commit, not before: the audit trail should record - # what happened, and until this point nothing had. - if role_changed: - log_auth_event( - 'account.role_changed', - actor=actor_name, - actor_id=actor_id, - target=user.username, - target_id=user.id, - previous_role=previous_role, - new_role=role, - ) - if password: - log_auth_event( - 'account.password_reset_by_admin', - actor=actor_name, - actor_id=actor_id, - target=user.username, - target_id=user.id, - ) - log_auth_event( - 'account.updated', - actor=actor_name, - actor_id=actor_id, - target=user.username, - target_id=user.id, - active=is_active, - ) - flash(_('User %(username)s updated successfully!', username=user.username), '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}) - - deleted_username, deleted_role = user.username, user.role - db.session.delete(user) - db.session.commit() - log_auth_event( - 'account.deleted', - actor=current_user.username, - actor_id=current_user.id, - target=deleted_username, - target_id=user_id, - role=deleted_role, - ) - flash( - _('User %(deleted_username)s has been removed.', deleted_username=deleted_username), - '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': - try: - validated = CreateUserSchema().load(request.form) - except ValidationError as err: - _flash_validation_errors(err) - return render_template('pages/create_user.html', roles=USER_TYPES) - - username = validated['username'] - email = validated['email'] - password = validated['password'] - full_name = validated['full_name'] - phone = validated.get('phone') - # The schema constrains role with OneOf(USER_TYPES), so the former - # manual membership check is now redundant. - role = validated['role'] - - 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() - log_auth_event( - 'account.created_by_admin', - actor=current_user.username, - actor_id=current_user.id, - target=user.username, - target_id=user.id, - role=role, - ) - flash( - _('User %(full_name)s created as %(role)s!', full_name=full_name, role=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': - try: - validated = EditProfileSchema().load(_form_payload()) - except ValidationError as err: - _flash_validation_errors(err) - return render_template( - 'pages/edit_profile.html', - user=current_user, - esport_games=ESPORT_GAMES, - game_platforms=GAME_PLATFORMS, - user_gamertags=current_user.get_gamertags(), - ) - - username = validated['username'] - full_name = validated['full_name'] - email = validated['email'] - phone = validated.get('phone') - selected_games = validated.get('games', []) - discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') - league_os_profile = validated.get('league_os_profile') - - 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) - - # Blank means "keep the current password"; anything else has already - # been checked against the policy by the schema. - password = validated.get('password') - if password: - current_user.password_hash = hash_password(password) - log_auth_event( - 'account.password_changed', username=current_user.username, user_id=current_user.id - ) - - db.session.commit() - flash(_('Profile updated successfully!'), 'success') - return redirect(url_for('users.profile')) - - return render_template( - 'pages/edit_profile.html', - user=current_user, - esport_games=ESPORT_GAMES, - game_platforms=GAME_PLATFORMS, - user_gamertags=current_user.get_gamertags(), - ) - - -# --------------------------------------------------------------------------- -# Disponibilities -# --------------------------------------------------------------------------- - -DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] - - -def add_30_minutes(t): - return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time() - - -@users_bp.route('/disponibilities') -@login_required -def get_disponibilities(): - """API endpoint to get all player disponibilities for scheduling.""" - if not current_user.can_manage_teams() and not current_user.can_schedule_matches(): - return jsonify({'error': 'Unauthorized'}), 403 - - players = ( - User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() - ) - result = {} - for player in players: - disponibilities = list(player.disponibilities) - result[player.id] = { - 'username': player.username, - 'disponibilities': [ - { - 'id': d.id, - 'day_of_week': d.day_of_week, - 'day_name': DAY_NAMES[d.day_of_week], - 'start_time': d.start_time.strftime('%H:%M'), - 'end_time': d.end_time.strftime('%H:%M'), - } - for d in disponibilities - ], - } - return jsonify(result) - - -@users_bp.route('/disponibilities/my') -@login_required -def get_my_disponibilities(): - """API endpoint for players to get their own disponibilities.""" - disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all() - result = {} - for d in disponibilities: - day = d.day_of_week - if day not in result: - result[day] = [] - result[day].append( - { - 'id': d.id, - 'day_of_week': d.day_of_week, - 'day_name': DAY_NAMES[d.day_of_week], - 'start_time': d.start_time.strftime('%H:%M'), - 'end_time': d.end_time.strftime('%H:%M'), - } - ) - return jsonify(result) - - -@users_bp.route('/disponibilities/add', methods=['POST']) -@login_required -def add_disponibility(): - """Add a disponibility block for the current player.""" - day_of_week = request.form.get('day_of_week', type=int) - start_time_str = request.form.get('start_time') - if day_of_week is None or day_of_week < 0 or day_of_week > 6: - return jsonify({'error': 'Invalid day of week'}), 400 - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - except (ValueError, TypeError): - return jsonify({'error': 'Invalid time format'}), 400 - - end_time = add_30_minutes(start_time) - disponibility = PlayerDisponibility( - player_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - end_time=end_time, - ) - db.session.add(disponibility) - db.session.commit() - return jsonify( - { - 'id': disponibility.id, - 'day_of_week': disponibility.day_of_week, - 'day_name': DAY_NAMES[disponibility.day_of_week], - 'start_time': disponibility.start_time.strftime('%H:%M'), - 'end_time': disponibility.end_time.strftime('%H:%M'), - } - ) - - -@users_bp.route('/disponibilities/add_bulk', methods=['POST']) -@login_required -def add_disponibilities_bulk(): - """Add multiple disponibility blocks at once.""" - data = request.get_json() - slots = data.get('slots', []) - created = [] - for slot in slots: - day_of_week = slot.get('day_of_week') - start_time_str = slot.get('start_time') - if day_of_week is None or day_of_week < 0 or day_of_week > 6: - continue - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - except (ValueError, TypeError): - continue - - end_time = add_30_minutes(start_time) - existing = PlayerDisponibility.query.filter_by( - player_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - ).first() - if not existing: - disponibility = PlayerDisponibility( - player_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - end_time=end_time, - ) - db.session.add(disponibility) - db.session.flush() - created.append( - { - 'id': disponibility.id, - 'day_of_week': disponibility.day_of_week, - 'day_name': DAY_NAMES[disponibility.day_of_week], - 'start_time': disponibility.start_time.strftime('%H:%M'), - } - ) - db.session.commit() - return jsonify({'success': True, 'created': created}) - - -@users_bp.route('/disponibilities/clear', methods=['POST']) -@login_required -def clear_disponibilities(): - """Clear all disponibilities for the current player.""" - PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() - db.session.commit() - return jsonify({'success': True}) - - -@users_bp.route('/disponibilities//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 _manageable_players(): - """Players the current user may attach a contract to. - - A coach used to see the squad of one team — the first row matching the - legacy coach_id column — so a coach of two teams could file a contract - for half of their players and no more, and a coach attached only by the - many-to-many relationship for none at all. - """ - if isinstance(current_user, Coach): - player_ids = coach_player_ids(current_user) - return ( - User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() - if player_ids - else [] - ) - return User.query.filter_by(role='player').order_by(User.username).all() - - -@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 = _manageable_players() - - 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')) - - players = _manageable_players() - - 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(_('%(field)s: %(msg)s', field=field, msg=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')) - - file = request.files.get('contract_file') - error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) - if error: - flash(error, '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( - _('Contract uploaded successfully for %(username)s!', username=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')) - - file = request.files.get('signed_file') - error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) - if error: - flash(error, '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 - # Reading org_team.coach_id directly told every player whose team lists - # its coaches through the many-to-many relationship — the newer of the - # two ways — that they had no coach, and closed the page to them. - # get_coaches() falls back to the legacy column when the list is empty. - team_coaches = org_team.get_coaches() if org_team else [] - coach = team_coaches[0] if team_coaches 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( - _( - 'One on One request from %(player)s has been approved!', - player=player.username if player else 'Unknown', - ), - '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( - _( - 'One on One request from %(player)s has been rejected.', - player=player.username if player else 'Unknown', - ), - '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')) - - # The team-notes panel is still written against a single team; the - # player list is not, and used to be narrowed to one team's squad while - # the POST routes accepted every player the coach works with. The form - # offered fewer players than the handler would take. - org_teams = coach_org_teams(current_user) - org_team = org_teams[0] if org_teams else None - - player_ids = coach_player_ids(current_user) - players = ( - User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() - if player_ids - else [] - ) - - team_notes = [] - latest_team_note = None - if org_team: - team_notes = ( - TeamNote.query.filter_by( - org_team_id=org_team.id, - ) - .order_by(TeamNote.created_at.desc()) - .all() - ) - latest_team_note = team_notes[0] if team_notes else None - - # A coach's own notes belong to them whether or not they hold a team; - # this list was gated on org_team and came back empty without one. - personal_notes = ( - PersonalNote.query.filter_by( - coach_id=current_user.id, - ) - .order_by(PersonalNote.created_at.desc()) - .all() - ) - - one_on_one_requests = [] - if player_ids: - one_on_one_requests = ( - OneOnOneRequest.query.filter(OneOnOneRequest.player_id.in_(player_ids)) - .order_by(OneOnOneRequest.created_at.desc()) - .all() - ) - - # For context selectors in the form - 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')) - - # Same team the dashboard displays notes for, resolved the same way. - org_teams = coach_org_teams(current_user) - if not org_teams: - flash(_('You are not assigned to a team.'), 'danger') - return redirect(url_for('users.notes_dashboard')) - org_team = org_teams[0] - - 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')) - - if not coach_can_access_player(current_user, player_id): - flash(_('You can only write notes about players you work with.'), '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(_('Note added for %(username)s.', username=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')) - - if not coach_can_access_player(current_user, player_id): - flash(_('You can only write notes about players you work with.'), '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(_('Note added for %(username)s.', username=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)) - - if not coach_can_access_player(current_user, player_id): - flash(_('You can only write notes about players you work with.'), '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)) - - if not coach_can_access_player(current_user, player_id): - flash(_('You can only write notes about players you work with.'), '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/routes/users/__init__.py b/app/routes/users/__init__.py new file mode 100644 index 0000000..9af3bf6 --- /dev/null +++ b/app/routes/users/__init__.py @@ -0,0 +1,35 @@ +"""User-facing routes, split by subject. + +Was a single 1 699-line module covering account administration, profiles, +availability calendars, contracts, one-on-one sessions and coach notes — +six subjects that shared nothing but a URL prefix (ARCH-004). + +Importing this package registers every route on `users_bp`, so app.py +keeps its single `from app.routes.users import users_bp`. The blueprint +itself lives in blueprint.py to keep that import one-directional. +""" + +from app.routes.users.blueprint import users_bp + +# Imported for their side effect: each module attaches its routes to +# users_bp. Order does not matter; none of them import each other. +from app.routes.users import accounts # noqa: F401,E402 +from app.routes.users import availability # noqa: F401,E402 +from app.routes.users import contracts # noqa: F401,E402 +from app.routes.users import notes # noqa: F401,E402 +from app.routes.users import one_on_one # noqa: F401,E402 +from app.routes.users import profile # noqa: F401,E402 + +# Re-exported because tests and other modules reach for them by name. +from app.routes.users._shared import ( # noqa: F401,E402 + ALLOWED_CONTRACT_EXTENSIONS, + ALLOWED_SIGNED_EXTENSIONS, + pdf_upload_error, +) + +__all__ = [ + 'ALLOWED_CONTRACT_EXTENSIONS', + 'ALLOWED_SIGNED_EXTENSIONS', + 'pdf_upload_error', + 'users_bp', +] diff --git a/app/routes/users/_shared.py b/app/routes/users/_shared.py new file mode 100644 index 0000000..7951ea1 --- /dev/null +++ b/app/routes/users/_shared.py @@ -0,0 +1,108 @@ +"""Helpers used by more than one route module in this package. + +Nothing here touches the blueprint: these are plain functions, so a test +can call them with a request context and nothing else. +""" + +from flask import flash, request +from flask_babel import gettext as _ + +from app.extensions import db +from app.models import Admin, Coach, Manager, Player, Scout, GAME_PLATFORMS, UserGamertag + +ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} +ALLOWED_SIGNED_EXTENSIONS = {'pdf'} + +#: Every PDF starts with this. Checking the name alone accepted a file +#: called anything.pdf holding anything at all. +PDF_SIGNATURE = b'%PDF-' + +#: USER_TYPE → model class, for create_user. +USER_CLASS_MAP = { + 'admin': Admin, + 'manager': Manager, + 'coach': Coach, + 'player': Player, + 'scout': Scout, +} + + +def pdf_upload_error(file, allowed_extensions): + """Why this upload is not an acceptable PDF, or None if it is. + + upload_signed_contract checked nothing beyond a non-empty filename — + ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player + could put an arbitrary file on the server under a name the application + later hands back for download (SEC-021). + + Args: + file: The uploaded FileStorage, or None. + allowed_extensions: Extensions to accept, lowercase and without dot. + + Returns: + str | None: A message to flash, or None when the file is acceptable. + """ + if file is None or not file.filename: + return _('No file selected.') + + stem, dot, extension = file.filename.rpartition('.') + if not (stem and dot) or extension.lower() not in allowed_extensions: + return _('Only PDF files are allowed for contracts.') + + head = file.stream.read(len(PDF_SIGNATURE)) + file.stream.seek(0) + if head != PDF_SIGNATURE: + return _('That file is not a PDF, whatever its name says.') + + return None + + +def flash_validation_errors(err): + """Surface marshmallow errors the same way auth.py already does.""" + for field, messages in err.messages.items(): + for msg in messages: + flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger') + + +def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)): + """Turn the multi-valued request form into a plain dict for marshmallow. + + request.form.to_dict() keeps only the first value of a repeated key, so + list fields have to be re-read with getlist(). Unchecked HTML checkboxes + are simply absent from the submission, which is not the same as a schema + default, so they are injected explicitly. Blank optional fields are + dropped rather than sent as '' — an empty password means "leave the + current one alone", not "set the password to the empty string". + """ + payload = request.form.to_dict() + for name in list_fields: + payload[name] = request.form.getlist(name) + for name in checkboxes: + payload[name] = name in request.form + for name in optional_blank: + if not payload.get(name): + payload.pop(name, None) + return payload + + +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]) diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py new file mode 100644 index 0000000..5bb4caa --- /dev/null +++ b/app/routes/users/accounts.py @@ -0,0 +1,348 @@ +"""Account administration — the president's view of the user list. + +Creating, editing, deleting and viewing accounts. Everything here is +admin-only except view_user, which renders a public profile. +""" + +from flask import 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 app.extensions import db, hash_password +from app.logging_config import log_auth_event +from app.models import ( + Admin, + CoachAvailability, + Contract, + ESPORT_GAMES, + Evaluation, + GAME_PLATFORMS, + Match, + MatchParticipant, + OneOnOneRequest, + OrgTeam, + PersonalNote, + Player, + PlayerDisponibility, + Team, + TeamMember, + TeamNote, + TeamPlayer, + Tryout, + TryoutRegistration, + USER_TYPES, + User, + UserGamertag, +) +from app.routes.users._shared import ( + USER_CLASS_MAP, + flash_validation_errors, + form_payload, + update_user_gamertags, +) +from app.routes.users.blueprint import users_bp +from app.validators import CreateUserSchema, EditUserSchema + + +@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': + actor_name, actor_id = current_user.username, current_user.id + + def _rerender(): + return render_template( + 'pages/edit_user.html', + user=user, + roles=USER_TYPES, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags={ + gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} + for gt in user.gamertags + }, + ) + + try: + validated = EditUserSchema().load(form_payload(checkboxes=('is_active_account',))) + except ValidationError as err: + flash_validation_errors(err) + return _rerender() + + full_name = validated['full_name'] + email = validated['email'] + phone = validated.get('phone') + role = validated['role'] + is_active = validated['is_active_account'] + selected_games = validated.get('games', []) + discord_username = validated.get('discord_username') + discord_user_id = validated.get('discord_user_id') + league_os_profile = validated.get('league_os_profile') + + # Previously absent: the column is unique, so assigning a taken + # address surfaced as an IntegrityError, i.e. a 500. + clash = User.query.filter(User.email == email, User.id != user.id).first() + if clash: + flash(_('Email already in use by another account.'), 'danger') + return _rerender() + + role_changed = user.role != role + previous_role = user.role + + if role_changed: + # Two ways to lock everyone out of administration, neither of + # which any interface can undo afterwards. + if user.id == actor_id: + flash( + _('You cannot change your own role. Ask another president to do it.'), 'danger' + ) + return _rerender() + + if user.role == 'admin': + remaining_admins = User.query.filter( + User.role == 'admin', + User.is_active_account.is_(True), + User.id != user.id, + ).count() + if remaining_admins == 0: + flash( + _( + 'This is the last active president. Promote ' + 'another account before changing this one.' + ), + 'danger', + ) + return _rerender() + + if role_changed: + # The role column is the polymorphic discriminator, and SQLAlchemy + # decides an instance's class when it loads it. Assigning to it + # through the ORM leaves a Player object in the identity map for a + # row that now says 'coach', so every later isinstance() check — + # which is how this application does authorisation — answers with + # the old role. Hence the statement-level UPDATE. + # + # The instance then has to be re-read. This used to call + # db.session.remove(), which throws away the whole session: + # everything the request still held was detached, current_user + # included, and the next attribute access on any of them raised + # DetachedInstanceError. Expunging the one stale instance is + # enough, and it leaves the transaction open — so the role change + # and the rest of the edit now commit together instead of the + # role landing on its own and the remaining fields failing after + # it (ARCH-008). + user_pk = user.id + db.session.execute( + db.text('UPDATE users SET role = :role WHERE id = :id'), + {'role': role, 'id': user_pk}, + ) + db.session.expunge(user) + user = db.session.get(User, user_pk) + + 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) + + # Blank means "keep the current password"; anything else has already + # been checked against the policy by the schema. + password = validated.get('password') + if password: + user.password_hash = hash_password(password) + + db.session.commit() + + # Logged after the commit, not before: the audit trail should record + # what happened, and until this point nothing had. + if role_changed: + log_auth_event( + 'account.role_changed', + actor=actor_name, + actor_id=actor_id, + target=user.username, + target_id=user.id, + previous_role=previous_role, + new_role=role, + ) + if password: + log_auth_event( + 'account.password_reset_by_admin', + actor=actor_name, + actor_id=actor_id, + target=user.username, + target_id=user.id, + ) + log_auth_event( + 'account.updated', + actor=actor_name, + actor_id=actor_id, + target=user.username, + target_id=user.id, + active=is_active, + ) + flash(_('User %(username)s updated successfully!', username=user.username), '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}) + + deleted_username, deleted_role = user.username, user.role + db.session.delete(user) + db.session.commit() + log_auth_event( + 'account.deleted', + actor=current_user.username, + actor_id=current_user.id, + target=deleted_username, + target_id=user_id, + role=deleted_role, + ) + flash( + _('User %(deleted_username)s has been removed.', deleted_username=deleted_username), + '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': + try: + validated = CreateUserSchema().load(request.form) + except ValidationError as err: + flash_validation_errors(err) + return render_template('pages/create_user.html', roles=USER_TYPES) + + username = validated['username'] + email = validated['email'] + password = validated['password'] + full_name = validated['full_name'] + phone = validated.get('phone') + # The schema constrains role with OneOf(USER_TYPES), so the former + # manual membership check is now redundant. + role = validated['role'] + + 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() + log_auth_event( + 'account.created_by_admin', + actor=current_user.username, + actor_id=current_user.id, + target=user.username, + target_id=user.id, + role=role, + ) + flash( + _('User %(full_name)s created as %(role)s!', full_name=full_name, role=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) diff --git a/app/routes/users/availability.py b/app/routes/users/availability.py new file mode 100644 index 0000000..9ec1aa4 --- /dev/null +++ b/app/routes/users/availability.py @@ -0,0 +1,233 @@ +"""When people are free. + +Two calendars that share a shape without sharing a purpose: a player's +weekly availability blocks, and a coach's bookable slots for one-on-one +sessions. +""" + +from datetime import datetime, timedelta + +from flask import flash, jsonify, redirect, render_template, request, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required + +from app.extensions import db +from app.models import Coach, CoachAvailability, PlayerDisponibility, User +from app.routes.users.blueprint import users_bp + + +DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + + +def add_30_minutes(t): + return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time() + + +@users_bp.route('/disponibilities') +@login_required +def get_disponibilities(): + """API endpoint to get all player disponibilities for scheduling.""" + if not current_user.can_manage_teams() and not current_user.can_schedule_matches(): + return jsonify({'error': 'Unauthorized'}), 403 + + players = ( + User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() + ) + result = {} + for player in players: + disponibilities = list(player.disponibilities) + result[player.id] = { + 'username': player.username, + 'disponibilities': [ + { + 'id': d.id, + 'day_of_week': d.day_of_week, + 'day_name': DAY_NAMES[d.day_of_week], + 'start_time': d.start_time.strftime('%H:%M'), + 'end_time': d.end_time.strftime('%H:%M'), + } + for d in disponibilities + ], + } + return jsonify(result) + + +@users_bp.route('/disponibilities/my') +@login_required +def get_my_disponibilities(): + """API endpoint for players to get their own disponibilities.""" + disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all() + result = {} + for d in disponibilities: + day = d.day_of_week + if day not in result: + result[day] = [] + result[day].append( + { + 'id': d.id, + 'day_of_week': d.day_of_week, + 'day_name': DAY_NAMES[d.day_of_week], + 'start_time': d.start_time.strftime('%H:%M'), + 'end_time': d.end_time.strftime('%H:%M'), + } + ) + return jsonify(result) + + +@users_bp.route('/disponibilities/add', methods=['POST']) +@login_required +def add_disponibility(): + """Add a disponibility block for the current player.""" + day_of_week = request.form.get('day_of_week', type=int) + start_time_str = request.form.get('start_time') + if day_of_week is None or day_of_week < 0 or day_of_week > 6: + return jsonify({'error': 'Invalid day of week'}), 400 + try: + start_time = datetime.strptime(start_time_str, '%H:%M').time() + except (ValueError, TypeError): + return jsonify({'error': 'Invalid time format'}), 400 + + end_time = add_30_minutes(start_time) + disponibility = PlayerDisponibility( + player_id=current_user.id, + day_of_week=day_of_week, + start_time=start_time, + end_time=end_time, + ) + db.session.add(disponibility) + db.session.commit() + return jsonify( + { + 'id': disponibility.id, + 'day_of_week': disponibility.day_of_week, + 'day_name': DAY_NAMES[disponibility.day_of_week], + 'start_time': disponibility.start_time.strftime('%H:%M'), + 'end_time': disponibility.end_time.strftime('%H:%M'), + } + ) + + +@users_bp.route('/disponibilities/add_bulk', methods=['POST']) +@login_required +def add_disponibilities_bulk(): + """Add multiple disponibility blocks at once.""" + data = request.get_json() + slots = data.get('slots', []) + created = [] + for slot in slots: + day_of_week = slot.get('day_of_week') + start_time_str = slot.get('start_time') + if day_of_week is None or day_of_week < 0 or day_of_week > 6: + continue + try: + start_time = datetime.strptime(start_time_str, '%H:%M').time() + except (ValueError, TypeError): + continue + + end_time = add_30_minutes(start_time) + existing = PlayerDisponibility.query.filter_by( + player_id=current_user.id, + day_of_week=day_of_week, + start_time=start_time, + ).first() + if not existing: + disponibility = PlayerDisponibility( + player_id=current_user.id, + day_of_week=day_of_week, + start_time=start_time, + end_time=end_time, + ) + db.session.add(disponibility) + db.session.flush() + created.append( + { + 'id': disponibility.id, + 'day_of_week': disponibility.day_of_week, + 'day_name': DAY_NAMES[disponibility.day_of_week], + 'start_time': disponibility.start_time.strftime('%H:%M'), + } + ) + db.session.commit() + return jsonify({'success': True, 'created': created}) + + +@users_bp.route('/disponibilities/clear', methods=['POST']) +@login_required +def clear_disponibilities(): + """Clear all disponibilities for the current player.""" + PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() + db.session.commit() + return jsonify({'success': True}) + + +@users_bp.route('/disponibilities//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}) + + +@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}) diff --git a/app/routes/users/blueprint.py b/app/routes/users/blueprint.py new file mode 100644 index 0000000..57e7231 --- /dev/null +++ b/app/routes/users/blueprint.py @@ -0,0 +1,16 @@ +"""The `users` blueprint object, on its own. + +Every route module in this package imports it from here rather than from +the package __init__, so there is no import cycle to reason about and no +ordering constraint between the modules. + +The blueprint stays a *single* blueprint even though the package holds six +route modules. Splitting it into `users_accounts`, `users_contracts` and so +on would rename 137 endpoints, and every one of them is spelled out in a +`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a +file you can read, not a URL map you have to relearn. +""" + +from flask import Blueprint + +users_bp = Blueprint('users', __name__, url_prefix='/users') diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py new file mode 100644 index 0000000..31888f6 --- /dev/null +++ b/app/routes/users/contracts.py @@ -0,0 +1,206 @@ +"""Player contracts: upload, sign, download.""" + +import os +import uuid +from datetime import datetime + +from flask import flash, redirect, render_template, request, send_file, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required +from marshmallow import ValidationError +from werkzeug.utils import secure_filename + +from app.extensions import db +from app.models import Admin, Coach, Contract, Manager, Player, User +from app.permissions import can_manage_player_contract, coach_player_ids +from app.routes.users._shared import ( + ALLOWED_CONTRACT_EXTENSIONS, + ALLOWED_SIGNED_EXTENSIONS, + pdf_upload_error, +) +from app.routes.users.blueprint import users_bp +from app.validators import UploadContractSchema + + +def manageable_players(): + """Players the current user may attach a contract to. + + A coach used to see the squad of one team — the first row matching the + legacy coach_id column — so a coach of two teams could file a contract + for half of their players and no more, and a coach attached only by the + many-to-many relationship for none at all. + """ + if isinstance(current_user, Coach): + player_ids = coach_player_ids(current_user) + return ( + User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() + if player_ids + else [] + ) + return User.query.filter_by(role='player').order_by(User.username).all() + + +@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 = manageable_players() + + 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')) + + players = manageable_players() + + 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(_('%(field)s: %(msg)s', field=field, msg=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')) + + file = request.files.get('contract_file') + error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) + if error: + flash(error, '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( + _('Contract uploaded successfully for %(username)s!', username=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')) + + file = request.files.get('signed_file') + error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) + if error: + flash(error, '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 + ) diff --git a/app/routes/users/notes.py b/app/routes/users/notes.py new file mode 100644 index 0000000..be4a295 --- /dev/null +++ b/app/routes/users/notes.py @@ -0,0 +1,376 @@ +"""Notes a coach keeps: about a team, and about individual players. + +The player-facing view of the same notes lives here too — my_notes — since +it reads exactly what the coach routes write. +""" + +from flask import flash, redirect, render_template, request, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required + +from app.extensions import db +from app.models import ( + Coach, + Match, + MatchParticipant, + OneOnOneRequest, + OrgTeam, + PersonalNote, + Player, + TeamNote, + Tryout, + TryoutRegistration, + User, +) +from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids +from app.routes.users.blueprint import users_bp + + +@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, + ) + + +@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')) + + # The team-notes panel is still written against a single team; the + # player list is not, and used to be narrowed to one team's squad while + # the POST routes accepted every player the coach works with. The form + # offered fewer players than the handler would take. + org_teams = coach_org_teams(current_user) + org_team = org_teams[0] if org_teams else None + + player_ids = coach_player_ids(current_user) + players = ( + User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() + if player_ids + else [] + ) + + team_notes = [] + latest_team_note = None + if org_team: + team_notes = ( + TeamNote.query.filter_by( + org_team_id=org_team.id, + ) + .order_by(TeamNote.created_at.desc()) + .all() + ) + latest_team_note = team_notes[0] if team_notes else None + + # A coach's own notes belong to them whether or not they hold a team; + # this list was gated on org_team and came back empty without one. + personal_notes = ( + PersonalNote.query.filter_by( + coach_id=current_user.id, + ) + .order_by(PersonalNote.created_at.desc()) + .all() + ) + + one_on_one_requests = [] + if player_ids: + one_on_one_requests = ( + OneOnOneRequest.query.filter(OneOnOneRequest.player_id.in_(player_ids)) + .order_by(OneOnOneRequest.created_at.desc()) + .all() + ) + + # For context selectors in the form + 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')) + + # Same team the dashboard displays notes for, resolved the same way. + org_teams = coach_org_teams(current_user) + if not org_teams: + flash(_('You are not assigned to a team.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + org_team = org_teams[0] + + 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')) + + if not coach_can_access_player(current_user, player_id): + flash(_('You can only write notes about players you work with.'), '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(_('Note added for %(username)s.', username=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')) + + if not coach_can_access_player(current_user, player_id): + flash(_('You can only write notes about players you work with.'), '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(_('Note added for %(username)s.', username=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)) + + if not coach_can_access_player(current_user, player_id): + flash(_('You can only write notes about players you work with.'), '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)) + + if not coach_can_access_player(current_user, player_id): + flash(_('You can only write notes about players you work with.'), '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/routes/users/one_on_one.py b/app/routes/users/one_on_one.py new file mode 100644 index 0000000..1f76024 --- /dev/null +++ b/app/routes/users/one_on_one.py @@ -0,0 +1,259 @@ +"""One-on-one sessions between a player and their coach.""" + +from datetime import datetime + +from flask import flash, redirect, render_template, request, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required + +from app.extensions import db +from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote +from app.routes.users.blueprint import users_bp +from app.services.notifications import send_discord_notification + + +@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 + # Reading org_team.coach_id directly told every player whose team lists + # its coaches through the many-to-many relationship — the newer of the + # two ways — that they had no coach, and closed the page to them. + # get_coaches() falls back to the legacy column when the list is empty. + team_coaches = org_team.get_coaches() if org_team else [] + coach = team_coaches[0] if team_coaches 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( + _( + 'One on One request from %(player)s has been approved!', + player=player.username if player else 'Unknown', + ), + '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( + _( + 'One on One request from %(player)s has been rejected.', + player=player.username if player else 'Unknown', + ), + 'info', + ) + return redirect(url_for('users.notes_dashboard')) diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py new file mode 100644 index 0000000..69c4f33 --- /dev/null +++ b/app/routes/users/profile.py @@ -0,0 +1,132 @@ +"""The signed-in user's own profile.""" + +from flask import 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 app.extensions import db, hash_password +from app.logging_config import log_auth_event +from app.models import ( + CoachAvailability, + Coach, + Contract, + ESPORT_GAMES, + GAME_PLATFORMS, + Player, + User, +) +from app.routes.users._shared import ( + flash_validation_errors, + form_payload, + update_user_gamertags, +) +from app.routes.users.blueprint import users_bp +from app.validators import EditProfileSchema + + +@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': + try: + validated = EditProfileSchema().load(form_payload()) + except ValidationError as err: + flash_validation_errors(err) + return render_template( + 'pages/edit_profile.html', + user=current_user, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags(), + ) + + username = validated['username'] + full_name = validated['full_name'] + email = validated['email'] + phone = validated.get('phone') + selected_games = validated.get('games', []) + discord_username = validated.get('discord_username') + discord_user_id = validated.get('discord_user_id') + league_os_profile = validated.get('league_os_profile') + + 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) + + # Blank means "keep the current password"; anything else has already + # been checked against the policy by the schema. + password = validated.get('password') + if password: + current_user.password_hash = hash_password(password) + log_auth_event( + 'account.password_changed', username=current_user.username, user_id=current_user.id + ) + + 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(), + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..dc5ee5e --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Business services: work that is neither a route nor a model.""" diff --git a/app/services/notifications.py b/app/services/notifications.py new file mode 100644 index 0000000..72166f2 --- /dev/null +++ b/app/services/notifications.py @@ -0,0 +1,116 @@ +"""Outbound notifications. + +Extracted from app/routes/users.py, where it sat between two route +definitions and pulled `requests`, `logging` and the Discord bot into a +module whose subject is HTTP handlers (ARCH-003). + +Every failure here is swallowed and logged on purpose: a notification that +does not reach Discord must not roll back the session it was announcing. +That is the one place in this codebase where `except Exception` is the +right answer rather than an oversight. +""" + +import logging +import os + +import requests + +logger = logging.getLogger(__name__) + +#: Either a webhook URL, or a bare Discord user id to DM instead. +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. + + Args: + player_name: Who is asking. + points: Free-text discussion points, possibly empty. + date_str, start_time_str, end_time_str: Already formatted for display. + team_name: The player's team, or None. + coach_name: Who is being asked. + coach_discord: The coach's Discord handle, for the webhook footer. + coach_discord_id: The coach's Discord snowflake, for a direct message. + request_id: OneOnOneRequest primary key, so reactions can find it back. + """ + + 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}") diff --git a/tests/test_role_change.py b/tests/test_role_change.py index 21467fd..d3607d1 100644 --- a/tests/test_role_change.py +++ b/tests/test_role_change.py @@ -97,18 +97,28 @@ class TestAtomicity: ): """The role used to be committed on its own, before the rest of the edit ran. A failure afterwards left an account promoted, and no - interface reported it.""" + interface reported it. + + The patch target is the name *as accounts.py resolved it*. Patching + a re-export instead would leave the route calling the real function, + the request would succeed, and the assertions below would pass + without testing anything — so `called` records that the stand-in + actually ran. + """ target_id = make_user('player') as_role('admin') + called = [] def _explode(user, selected_games): + called.append(True) raise RuntimeError('storage unavailable') - monkeypatch.setattr('app.routes.users.update_user_gamertags', _explode) + monkeypatch.setattr('app.routes.users.accounts.update_user_gamertags', _explode) with pytest.raises(RuntimeError): _edit(client, target_id) + assert called, 'the patched function was never reached' with app.app_context(): user = db.session.get(User, target_id) assert isinstance(user, Player) diff --git a/tests/test_route_map.py b/tests/test_route_map.py new file mode 100644 index 0000000..21f2fcc --- /dev/null +++ b/tests/test_route_map.py @@ -0,0 +1,110 @@ +"""Every endpoint the templates name still exists — ARCH-004. + +Splitting app/routes/users.py into a package moved thirty route functions +between modules. The failure mode of that kind of change is not a crash at +import time: it is a `url_for('users.something')` in a template that nobody +opens during the test run, raising BuildError for the one person who does. + +So this walks the templates, collects every endpoint spelled out as a +literal, and checks it against the real URL map. It costs one test and +covers all seven blueprints, not just the one that moved. +""" + +import os +import re + +import pytest + +#: url_for('blueprint.endpoint' — literal first argument only. Calls that +#: compute the endpoint are rare here and cannot be checked statically. +_URL_FOR = re.compile(r"""url_for\(\s*['"]([a-zA-Z_][\w.]*)['"]""") + +_TEMPLATE_ROOT = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates' +) +_CODE_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app') + + +def _referenced_endpoints(root, suffixes): + """Endpoints named in files under `root`, mapped to where they appear.""" + found = {} + for directory, _dirs, files in os.walk(root): + if '__pycache__' in directory or 'translations' in directory: + continue + for name in files: + if not name.endswith(suffixes): + continue + path = os.path.join(directory, name) + with open(path, encoding='utf-8') as handle: + for endpoint in _URL_FOR.findall(handle.read()): + found.setdefault(endpoint, set()).add(os.path.relpath(path, root)) + return found + + +TEMPLATE_ENDPOINTS = _referenced_endpoints(_TEMPLATE_ROOT, ('.html',)) +CODE_ENDPOINTS = _referenced_endpoints(_CODE_ROOT, ('.py',)) + + +def test_the_scan_found_something(): + """A regex that silently matches nothing would make the rest vacuous.""" + assert len(TEMPLATE_ENDPOINTS) > 30, TEMPLATE_ENDPOINTS + + +@pytest.mark.parametrize('endpoint', sorted(TEMPLATE_ENDPOINTS)) +def test_a_template_endpoint_exists(app, endpoint): + known = {rule.endpoint for rule in app.url_map.iter_rules()} + assert endpoint in known, ( + f'{endpoint} is named in {sorted(TEMPLATE_ENDPOINTS[endpoint])} but no route provides it' + ) + + +@pytest.mark.parametrize('endpoint', sorted(CODE_ENDPOINTS)) +def test_a_redirect_target_exists(app, endpoint): + known = {rule.endpoint for rule in app.url_map.iter_rules()} + assert endpoint in known, ( + f'{endpoint} is named in {sorted(CODE_ENDPOINTS[endpoint])} but no route provides it' + ) + + +class TestTheUsersPackage: + """The split had to preserve the endpoint names exactly: 137 url_for + calls spell them out, and a renamed blueprint would break every one.""" + + EXPECTED = { + 'users.accept_one_on_one', + 'users.add_disponibilities_bulk', + 'users.add_disponibility', + 'users.add_note_from_match', + 'users.add_note_from_tryout', + 'users.add_personal_note', + 'users.clear_coach_availability', + 'users.clear_disponibilities', + 'users.create_user', + 'users.delete_disponibility', + 'users.delete_user', + 'users.download_contract', + 'users.download_signed_contract', + 'users.edit_profile', + 'users.edit_user', + 'users.get_disponibilities', + 'users.get_my_disponibilities', + 'users.list_contracts', + 'users.list_users', + 'users.manage_coach_availability', + 'users.manage_personal_notes', + 'users.manage_team_notes', + 'users.my_notes', + 'users.notes_dashboard', + 'users.one_on_one', + 'users.profile', + 'users.reject_one_on_one', + 'users.upload_contract', + 'users.upload_signed_contract', + 'users.view_user', + } + + def test_the_same_thirty_routes_are_registered(self, app): + registered = { + rule.endpoint for rule in app.url_map.iter_rules() if rule.endpoint.startswith('users.') + } + assert registered == self.EXPECTED