OBS-001. logging_config.py configurait un fichier auth.log avec rotation,
un logger nomme 'team_tryouts.auth' et un filtre de redaction. Mais
get_auth_logger n'etait importe nulle part : le fichier etait cree et
restait vide. Aucune connexion, aucun echec, aucun verrouillage, aucun
changement de role, aucune suppression de compte ne laissait de trace.
En cas de suspicion de compromission, il n'y avait rien a consulter.
Ajout de log_auth_event(event, **fields), qui emet des paires cle=valeur
ordonnees -- greppable sans dependance de journalisation JSON.
Evenements couverts
authentification login.success, login.failure,
login.failure.unknown_user, login.rejected.locked,
login.rejected.deactivated, account.locked, logout,
account.registered
administration account.created_by_admin, account.updated,
account.role_changed (avec ancien et nouveau role),
account.deleted, account.password_reset_by_admin
libre-service account.password_changed
Le champ ip vient de request.remote_addr, donc de X-Forwarded-For. Tant que
Waitress tourne avec trusted_proxy='*' (SEC-WEB-002), cette valeur est
choisie par l'appelant : c'est une indication, pas une preuve. Le point est
documente dans la docstring.
Un test a fait remonter ARCH-003, jusqu'ici classe comme fragilite latente
Journaliser en fin de edit_user levait DetachedInstanceError : le
changement de role appelle db.session.remove() en plein cycle de requete,
ce qui detache current_user de la session. Le code s'en tirait parce qu'il
redirigeait immediatement sans plus y toucher. L'identite de l'acteur est
desormais capturee en debut de traitement. Le constat est donc confirme
comme reel, et non plus seulement probable -- sa correction de fond reste
au programme.
15 tests, dont 4 sur le filtre de redaction lui-meme : c'est un controle de
securite, il doit etre verifie.
Co-Authored-By: Claude Opus 5 <[email protected]>
1353 lines
54 KiB
Python
1353 lines
54 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 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
|
|
import requests
|
|
|
|
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
|
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
|
|
|
users_bp = Blueprint('users', __name__, url_prefix='/users')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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(f'{field}: {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':
|
|
# Captured up front, on purpose. A role change below calls
|
|
# db.session.remove(), which detaches current_user from the session:
|
|
# any later attribute access on it raises DetachedInstanceError.
|
|
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()
|
|
|
|
# Change role via raw SQL to avoid polymorphic identity corruption.
|
|
# Must discard the entire session because the polymorphic discriminator
|
|
# change invalidates the identity map for this instance and anything
|
|
# that references it via relationships.
|
|
if user.role != role:
|
|
log_auth_event('account.role_changed',
|
|
actor=actor_name, actor_id=actor_id,
|
|
target=user.username, target_id=user.id,
|
|
previous_role=user.role, new_role=role)
|
|
user_id_local = user.id
|
|
db.session.execute(
|
|
db.text("UPDATE users SET role = :role WHERE id = :id"),
|
|
{"role": role, "id": user_id_local}
|
|
)
|
|
db.session.commit()
|
|
db.session.remove() # discard stale session entirely
|
|
user = User.query.get(user_id_local) # fresh session, correct class
|
|
|
|
user.full_name = full_name
|
|
user.email = email
|
|
user.phone = phone
|
|
user.is_active_account = is_active
|
|
user.games = ','.join(selected_games) if selected_games else None
|
|
user.discord_username = discord_username or None
|
|
user.discord_user_id = discord_user_id or None
|
|
user.league_os_profile = league_os_profile or None
|
|
|
|
update_user_gamertags(user, selected_games)
|
|
|
|
# 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)
|
|
log_auth_event('account.password_reset_by_admin',
|
|
actor=actor_name, actor_id=actor_id,
|
|
target=user.username, target_id=user.id)
|
|
|
|
db.session.commit()
|
|
log_auth_event('account.updated',
|
|
actor=actor_name, actor_id=actor_id,
|
|
target=user.username, target_id=user.id, active=is_active)
|
|
flash(f'User {user.username} updated successfully!', 'success')
|
|
return redirect(url_for('users.list_users'))
|
|
|
|
user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
|
|
for gt in user.gamertags}
|
|
return render_template('pages/edit_user.html', user=user, roles=USER_TYPES,
|
|
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
|
|
user_gamertags=user_gamertags)
|
|
|
|
|
|
@users_bp.route('/<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(f'User {deleted_username} has been removed.', 'success')
|
|
return redirect(url_for('users.list_users'))
|
|
|
|
|
|
@users_bp.route('/create', methods=['GET', 'POST'])
|
|
@login_required
|
|
def create_user():
|
|
"""Create a new user (Admin only). Uses the correct polymorphic subclass."""
|
|
if not isinstance(current_user, Admin):
|
|
flash('Only the president can create users.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
if request.method == 'POST':
|
|
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(f'User {full_name} created as {role}!', 'success')
|
|
return redirect(url_for('users.list_users'))
|
|
|
|
return render_template('pages/create_user.html', roles=USER_TYPES)
|
|
|
|
|
|
@users_bp.route('/<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 can_manage_player_contract(user, player_id):
|
|
"""Check if a user can upload contracts for a specific player."""
|
|
if isinstance(user, Admin):
|
|
return True
|
|
if isinstance(user, Manager):
|
|
return True
|
|
if isinstance(user, Coach):
|
|
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
|
if org_team:
|
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=org_team.id).first()
|
|
if tp:
|
|
return True
|
|
return False
|
|
|
|
|
|
@users_bp.route('/contracts')
|
|
@login_required
|
|
def list_contracts():
|
|
"""View contracts for the current user or players they manage."""
|
|
contracts = None
|
|
players = None
|
|
|
|
if isinstance(current_user, Player):
|
|
contracts = Contract.query.filter_by(
|
|
player_id=current_user.id,
|
|
).order_by(Contract.uploaded_at.desc()).all()
|
|
elif isinstance(current_user, (Admin, Manager, Coach)):
|
|
players = []
|
|
if isinstance(current_user, Coach):
|
|
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
|
if org_team:
|
|
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
|
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
|
else:
|
|
players = User.query.filter_by(role='player').all()
|
|
|
|
if players:
|
|
player_ids = [p.id for p in players]
|
|
contracts = Contract.query.filter(
|
|
Contract.player_id.in_(player_ids),
|
|
).order_by(Contract.uploaded_at.desc()).all()
|
|
|
|
return render_template('pages/contracts.html', contracts=contracts, players=players
|
|
if isinstance(current_user, (Admin, Manager, Coach)) else None)
|
|
|
|
|
|
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
|
|
@login_required
|
|
def upload_contract():
|
|
"""Upload a contract for a player."""
|
|
if not isinstance(current_user, (Admin, Manager, Coach)):
|
|
flash('Only presidents, managers, and coaches can upload contracts.', 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
if isinstance(current_user, Coach):
|
|
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
|
if org_team:
|
|
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
|
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
|
else:
|
|
players = []
|
|
else:
|
|
players = User.query.filter_by(role='player').all()
|
|
|
|
if request.method == 'POST':
|
|
contract_schema = UploadContractSchema()
|
|
try:
|
|
validated = contract_schema.load(request.form)
|
|
except ValidationError as err:
|
|
for field, messages in err.messages.items():
|
|
for msg in messages:
|
|
flash(f'{field}: {msg}', 'danger')
|
|
return render_template('pages/upload_contract.html', players=players)
|
|
|
|
player_id = validated['player_id']
|
|
notes = validated.get('notes')
|
|
|
|
if not can_manage_player_contract(current_user, player_id):
|
|
flash('You do not have permission to upload a contract for this player.', 'danger')
|
|
return redirect(url_for('users.upload_contract'))
|
|
|
|
if 'contract_file' not in request.files:
|
|
flash('No file selected.', 'danger')
|
|
return redirect(url_for('users.upload_contract'))
|
|
|
|
file = request.files['contract_file']
|
|
if file.filename == '':
|
|
flash('No file selected.', 'danger')
|
|
return redirect(url_for('users.upload_contract'))
|
|
if not file.filename.lower().endswith('.pdf'):
|
|
flash('Only PDF files are allowed for contracts.', 'danger')
|
|
return redirect(url_for('users.upload_contract'))
|
|
|
|
upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés')
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
player = User.query.get_or_404(player_id)
|
|
player_teams = player.get_org_teams()
|
|
team = player_teams[0] if player_teams else None
|
|
|
|
if team:
|
|
team_folder = os.path.join(upload_dir, secure_filename(team.name))
|
|
os.makedirs(team_folder, exist_ok=True)
|
|
final_dir = team_folder
|
|
else:
|
|
final_dir = upload_dir
|
|
|
|
original_filename = secure_filename(file.filename)
|
|
file_uuid = str(uuid.uuid4())
|
|
stored_filename = f"{file_uuid}.pdf"
|
|
file_path = os.path.join(final_dir, stored_filename)
|
|
file.save(file_path)
|
|
|
|
contract = Contract(
|
|
player_id=player_id, team_id=team.id if team else None,
|
|
uploaded_by_id=current_user.id,
|
|
original_filename=original_filename,
|
|
stored_filename=stored_filename,
|
|
file_path=file_path,
|
|
notes=notes if notes else None,
|
|
)
|
|
db.session.add(contract)
|
|
db.session.commit()
|
|
flash(f'Contract uploaded successfully for {player.username}!', 'success')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
return render_template('pages/upload_contract.html', players=players)
|
|
|
|
|
|
@users_bp.route('/contracts/<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'))
|
|
|
|
if 'signed_file' not in request.files:
|
|
flash('No file selected.', 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
file = request.files['signed_file']
|
|
if file.filename == '':
|
|
flash('No file selected.', 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
signed_filename = f"signed_{contract.stored_filename}"
|
|
file.save(contract.file_path.replace(contract.stored_filename, signed_filename))
|
|
|
|
contract.signed_filename = signed_filename
|
|
contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
|
contract.status = 'signed'
|
|
contract.signed_at = datetime.utcnow()
|
|
db.session.commit()
|
|
flash('Signed contract uploaded successfully!', 'success')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
|
|
@users_bp.route('/contracts/<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
|
|
coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None
|
|
|
|
if not coach:
|
|
flash('You do not have a coach assigned to your team.', 'info')
|
|
|
|
team_notes = []
|
|
if org_team:
|
|
team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all()
|
|
|
|
personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all()
|
|
|
|
coach_availability = []
|
|
if coach:
|
|
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
|
|
coach_availability = [
|
|
{
|
|
'day_of_week': av.day_of_week,
|
|
'start_time': av.start_time.strftime('%H:%M'),
|
|
'end_time': av.end_time.strftime('%H:%M'),
|
|
}
|
|
for av in availabilities
|
|
]
|
|
|
|
if request.method == 'POST':
|
|
date_str = request.form.get('date')
|
|
start_time_str = request.form.get('start_time')
|
|
end_time_str = request.form.get('end_time')
|
|
points = request.form.get('points', '').strip()
|
|
|
|
if not coach:
|
|
flash('Cannot request One on One - no coach assigned.', 'danger')
|
|
return redirect(url_for('users.one_on_one'))
|
|
|
|
try:
|
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
|
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
|
except (ValueError, TypeError):
|
|
flash('Invalid date or time format.', 'danger')
|
|
return redirect(url_for('users.one_on_one'))
|
|
|
|
check_date = datetime.strptime(date_str, '%Y-%m-%d')
|
|
day_of_week = check_date.weekday()
|
|
|
|
is_available = any(
|
|
av['day_of_week'] == day_of_week
|
|
and av['start_time'] <= start_time_str
|
|
and av['end_time'] >= end_time_str
|
|
for av in coach_availability
|
|
)
|
|
|
|
if not is_available:
|
|
flash("The requested time is not within the coach's availability.", 'danger')
|
|
return redirect(url_for('users.one_on_one'))
|
|
|
|
request_obj = OneOnOneRequest(
|
|
player_id=current_user.id, coach_id=coach.id,
|
|
org_team_id=org_team.id if org_team else None,
|
|
date=date_obj, start_time=start_time, end_time=end_time,
|
|
points=points if points else None,
|
|
)
|
|
db.session.add(request_obj)
|
|
db.session.commit()
|
|
|
|
send_discord_notification(
|
|
player_name=current_user.full_name,
|
|
points=points, date_str=date_str,
|
|
start_time_str=start_time_str, end_time_str=end_time_str,
|
|
team_name=org_team.name if org_team else 'Unknown Team',
|
|
coach_name=coach.full_name,
|
|
coach_discord=coach.discord_username or '',
|
|
coach_discord_id=coach.discord_user_id or '',
|
|
request_id=request_obj.id,
|
|
)
|
|
|
|
flash('Your One on One request has been submitted!', 'success')
|
|
return redirect(url_for('users.one_on_one'))
|
|
|
|
# Build list of upcoming dates that have coach availability
|
|
from datetime import date as date_cls, timedelta as td
|
|
today = date_cls.today()
|
|
available_days = {av['day_of_week'] for av in coach_availability}
|
|
dates = []
|
|
for i in range(14): # Next 14 days
|
|
d = today + td(days=i)
|
|
if d.weekday() in available_days:
|
|
dates.append({
|
|
'value': d.strftime('%Y-%m-%d'),
|
|
'day_of_week': d.weekday(),
|
|
'display': d.strftime('%B %d, %Y (%A)'),
|
|
})
|
|
|
|
# Player's own One on One request history
|
|
my_requests = OneOnOneRequest.query.filter_by(
|
|
player_id=current_user.id
|
|
).order_by(OneOnOneRequest.created_at.desc()).all()
|
|
|
|
return render_template('pages/one_on_one.html',
|
|
org_team=org_team, coach=coach,
|
|
team_notes=team_notes, personal_notes=personal_notes,
|
|
coach_availability=coach_availability,
|
|
dates=dates,
|
|
my_requests=my_requests)
|
|
|
|
|
|
@users_bp.route('/one-on-one/<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(f'One on One request from {player.username if player else "Unknown"} has been approved!', 'success')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
|
|
@users_bp.route('/one-on-one/<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(f'One on One request from {player.username if player else "Unknown"} has been rejected.', 'info')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# My Notes (Player)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/my-notes')
|
|
@login_required
|
|
def my_notes():
|
|
"""View personal and team notes for the current player."""
|
|
if not isinstance(current_user, Player):
|
|
flash('This page is for players only.', 'info')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
org_teams = current_user.get_org_teams()
|
|
org_team = org_teams[0] if org_teams else None
|
|
|
|
personal_notes = PersonalNote.query.filter_by(
|
|
player_id=current_user.id,
|
|
).order_by(PersonalNote.created_at.desc()).all()
|
|
|
|
team_notes = []
|
|
if org_team:
|
|
team_notes = TeamNote.query.filter_by(
|
|
org_team_id=org_team.id,
|
|
).order_by(TeamNote.created_at.desc()).all()
|
|
|
|
return render_template('pages/player_personal_notes.html',
|
|
org_team=org_team,
|
|
personal_notes=personal_notes,
|
|
team_notes=team_notes)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Coach Availability
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
|
@login_required
|
|
def manage_coach_availability():
|
|
"""Manage coach availability for One on One sessions."""
|
|
if not isinstance(current_user, Coach):
|
|
flash('Only coaches can manage availability.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
if request.method == 'POST':
|
|
data = request.get_json()
|
|
slots = data.get('slots', []) if data else []
|
|
|
|
# Clear existing availability
|
|
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
|
|
|
# Add new slots
|
|
for slot in slots:
|
|
day_of_week = slot.get('day_of_week')
|
|
start_time_str = slot.get('start_time')
|
|
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
|
continue
|
|
try:
|
|
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
|
end_time = (datetime.combine(datetime.today(), start_time) + timedelta(minutes=30)).time()
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
availability = CoachAvailability(
|
|
coach_id=current_user.id,
|
|
day_of_week=day_of_week,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
)
|
|
db.session.add(availability)
|
|
|
|
db.session.commit()
|
|
return jsonify({'success': True})
|
|
|
|
existing_availability = CoachAvailability.query.filter_by(
|
|
coach_id=current_user.id,
|
|
).all()
|
|
|
|
return render_template('pages/coach_availability.html',
|
|
existing_availability=existing_availability)
|
|
|
|
|
|
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
|
@login_required
|
|
def clear_coach_availability():
|
|
"""Clear all coach availability slots."""
|
|
if not isinstance(current_user, Coach):
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
|
db.session.commit()
|
|
return jsonify({'success': True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notes Dashboard (Coach)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/notes-dashboard')
|
|
@login_required
|
|
def notes_dashboard():
|
|
"""Notes and One on One dashboard for coaches."""
|
|
if not isinstance(current_user, Coach):
|
|
flash('Only coaches can access the notes dashboard.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
|
|
|
players = []
|
|
if org_team:
|
|
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
|
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
|
|
|
team_notes = []
|
|
personal_notes = []
|
|
latest_team_note = None
|
|
if org_team:
|
|
team_notes = TeamNote.query.filter_by(
|
|
org_team_id=org_team.id,
|
|
).order_by(TeamNote.created_at.desc()).all()
|
|
latest_team_note = team_notes[0] if team_notes else None
|
|
|
|
personal_notes = PersonalNote.query.filter_by(
|
|
coach_id=current_user.id,
|
|
).order_by(PersonalNote.created_at.desc()).all()
|
|
|
|
# One on One requests from team players
|
|
one_on_one_requests = []
|
|
if org_team and players:
|
|
player_ids_list = [p.id for p in players]
|
|
one_on_one_requests = OneOnOneRequest.query.filter(
|
|
OneOnOneRequest.player_id.in_(player_ids_list)
|
|
).order_by(OneOnOneRequest.created_at.desc()).all()
|
|
|
|
# For context selectors in the form
|
|
matches = Match.query.filter(
|
|
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
|
).order_by(Match.date.desc()).limit(20).all()
|
|
tryouts = Tryout.query.filter_by(
|
|
created_by=current_user.id,
|
|
).order_by(Tryout.date.desc()).limit(20).all()
|
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
|
|
|
return render_template('pages/notes.html',
|
|
org_team=org_team,
|
|
players=players,
|
|
team_notes=team_notes,
|
|
latest_team_note=latest_team_note,
|
|
personal_notes=personal_notes,
|
|
one_on_one_requests=one_on_one_requests,
|
|
matches=matches,
|
|
tryouts=tryouts,
|
|
teams=teams)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manage Team Notes (POST)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/team-notes/manage', methods=['POST'])
|
|
@login_required
|
|
def manage_team_notes():
|
|
"""Create or update team notes for the coach's org team."""
|
|
if not isinstance(current_user, Coach):
|
|
flash('Only coaches can manage team notes.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
|
if not org_team:
|
|
flash('You are not assigned to a team.', 'danger')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
content = request.form.get('content', '').strip()
|
|
if content:
|
|
note = TeamNote(
|
|
org_team_id=org_team.id,
|
|
coach_id=current_user.id,
|
|
content=content,
|
|
)
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
flash('Team notes saved successfully!', 'success')
|
|
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manage Personal Notes (POST, simple form)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/personal-notes/manage', methods=['POST'])
|
|
@login_required
|
|
def manage_personal_notes():
|
|
"""Create a personal note for a player (coach only, simple form)."""
|
|
if not isinstance(current_user, Coach):
|
|
flash('Only coaches can manage personal notes.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
player_id = request.form.get('player_id', type=int)
|
|
content = request.form.get('content', '').strip()
|
|
|
|
if not player_id or not content:
|
|
flash('Player and content are required.', 'danger')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
player = User.query.get_or_404(player_id)
|
|
if not isinstance(player, Player):
|
|
flash('Can only add notes for players.', 'danger')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
note = PersonalNote(
|
|
player_id=player_id,
|
|
coach_id=current_user.id,
|
|
content=content,
|
|
)
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
flash(f'Note added for {player.username}.', 'success')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Add Personal Note (POST, full form with context)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/personal-notes/add', methods=['POST'])
|
|
@login_required
|
|
def add_personal_note():
|
|
"""Create a personal note for a player with optional context (coach only)."""
|
|
if not isinstance(current_user, Coach):
|
|
flash('Only coaches can add personal notes.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
player_id = request.form.get('player_id', type=int)
|
|
content = request.form.get('content', '').strip()
|
|
match_id = request.form.get('match_id', type=int)
|
|
tryout_id = request.form.get('tryout_id', type=int)
|
|
team_id_str = request.form.get('team_id')
|
|
|
|
if not player_id or not content:
|
|
flash('Player and content are required.', 'danger')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
player = User.query.get_or_404(player_id)
|
|
if not isinstance(player, Player):
|
|
flash('Can only add notes for players.', 'danger')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
note = PersonalNote(
|
|
player_id=player_id,
|
|
coach_id=current_user.id,
|
|
content=content,
|
|
match_id=match_id if match_id else None,
|
|
tryout_id=tryout_id if tryout_id else None,
|
|
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
|
)
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
flash(f'Note added for {player.username}.', 'success')
|
|
return redirect(url_for('users.notes_dashboard'))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Add Note from Tryout context (GET + POST)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@users_bp.route('/personal-notes/tryout/<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))
|
|
|
|
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))
|
|
|
|
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=[])
|