Files
team-tryouts/app/routes/users.py
T
GGThedandClaude Opus 5 20158a9e7a fix(security): verifier les fichiers televerses et retirer un intent Discord
SEC-021 -- rien ne validait le contrat signe
upload_signed_contract se contentait d un nom de fichier non vide.
ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le
fichier atterrissait sur le disque sous un nom que download_signed_contract
sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre.

Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait
`.pdf` en fin de chaine -- payload.pdf ne dit rien des octets.

pdf_upload_error() couvre les deux routes : extension dans la liste, puis
signature %PDF- en tete de flux. Le flux est rembobine, l appelant
enregistre toujours le fichier entier.

OPS-014 -- intent Discord privilegie inutile
Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni
ne recherche de membre de serveur, les personnes sont jointes par le
discord_user_id enregistre sur leur compte. Retire.

message_content reste : on_raw_reaction_add lit le texte de la reponse d un
coach pour consigner un motif de refus.

CI-003 et CI-005 sont deja appliques (permissions: contents: read,
checkout@v4, exclusions de deploiement). L epinglage par SHA des actions
n est pas fait : ce sont des actions GitHub de premiere partie, et
l epingler sans Dependabot echange une exposition contre une autre.

11 tests, dont deux verifient que signer un contrat marche toujours et
qu un autre joueur ne peut pas le faire.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:09:13 -04:00

1429 lines
58 KiB
Python

"""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('/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_user(user_id):
"""Edit an existing user (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
if request.method == 'POST':
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('/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_user(user_id):
"""Delete a user (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can delete users.'), 'danger')
return redirect(url_for('main.dashboard'))
if current_user.id == user_id:
flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users'))
user = User.query.get_or_404(user_id)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
).delete(synchronize_session=False)
PlayerDisponibility.query.filter_by(player_id=user_id).delete()
CoachAvailability.query.filter_by(coach_id=user_id).delete()
PersonalNote.query.filter(
db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id),
).delete(synchronize_session=False)
TeamNote.query.filter_by(coach_id=user_id).delete()
OneOnOneRequest.query.filter(
db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id),
).delete(synchronize_session=False)
UserGamertag.query.filter_by(user_id=user_id).delete()
Contract.query.filter_by(player_id=user_id).delete()
TryoutRegistration.query.filter_by(player_id=user_id).delete()
TeamPlayer.query.filter_by(player_id=user_id).delete()
TeamMember.query.filter_by(player_id=user_id).delete()
MatchParticipant.query.filter_by(player_id=user_id).delete()
OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None})
OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None})
Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id})
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('/<int:user_id>/view')
@login_required
def view_user(user_id):
"""View a public profile for any user."""
user = User.query.get_or_404(user_id)
return render_template('pages/view_user.html', profile_user=user)
@users_bp.route('/profile')
@login_required
def profile():
"""View the current user's profile."""
contracts = None
if isinstance(current_user, Player):
contracts = Contract.query.filter_by(
player_id=current_user.id,
).order_by(Contract.uploaded_at.desc()).all()
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/<int:disponibility_id>/delete', methods=['POST'])
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
db.session.commit()
return jsonify({'success': True})
# ---------------------------------------------------------------------------
# Contracts
# ---------------------------------------------------------------------------
def _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/<int:contract_id>/upload_signed', methods=['POST'])
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
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/<int:contract_id>/download')
@login_required
def download_contract(contract_id):
"""Download a contract file."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(contract.file_path, as_attachment=True, download_name=contract.original_filename)
@users_bp.route('/contracts/<int:contract_id>/download_signed')
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename)
# ---------------------------------------------------------------------------
# One on One
# ---------------------------------------------------------------------------
DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '')
def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str,
team_name, coach_name, coach_discord, coach_discord_id, request_id=None):
"""Send a Discord notification for a One on One request."""
import logging
logger = logging.getLogger(__name__)
if coach_discord_id:
try:
from app.discord_bot import send_one_on_one_dm
send_one_on_one_dm(
coach_name=coach_name, coach_discord_id=coach_discord_id,
player_name=player_name, team_name=team_name,
date_str=date_str, start_time=start_time_str,
end_time=end_time_str, points=points, request_id=request_id,
)
except Exception as e:
logger.warning(f"Failed to send Discord DM: {e}")
if DISCORD_WEBHOOK_URL:
try:
from app.discord_bot import send_one_on_one_dm
if DISCORD_WEBHOOK_URL.isdigit() and not coach_discord_id:
send_one_on_one_dm(
coach_name=coach_name, coach_discord_id=DISCORD_WEBHOOK_URL,
player_name=player_name, team_name=team_name,
date_str=date_str, start_time=start_time_str,
end_time=end_time_str, points=points,
)
elif not DISCORD_WEBHOOK_URL.isdigit():
embed = {
"embeds": [{
"title": "One on One Request", "color": 3447003,
"fields": [
{"name": "Player", "value": player_name, "inline": True},
{"name": "Team", "value": team_name or "Unknown Team", "inline": True},
{"name": "Date", "value": date_str, "inline": True},
{"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True},
{"name": "Discussion Points", "value": points or "No specific points provided", "inline": False},
],
"footer": {
"text": f"Coach: {coach_name}"
+ (f" (Discord: {coach_discord})" if coach_discord else ""),
},
}],
}
requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5)
except Exception as e:
logger.warning(f"Failed to send Discord notification: {e}")
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@login_required
def one_on_one():
"""One on One request page for players."""
if not isinstance(current_user, Player):
flash(_('Only players can request One on One sessions.'), 'danger')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
# 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/<int:request_id>/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/<int:request_id>/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/<int:tryout_id>', 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/<int:match_id>', 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=[])