QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]>
1700 lines
60 KiB
Python
1700 lines
60 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=[],
|
|
)
|