refactor(routes): decouper users.py en paquet, extraire les notifications
ARCH-004 et la moitie NotificationService d ARCH-003. app/routes/users.py faisait 1 699 lignes et couvrait six sujets qui ne partageaient rien d autre qu un prefixe d URL. Il devient un paquet : blueprint.py l objet Blueprint, seul _shared.py helpers de formulaire, gamertags, validation de PDF accounts.py 348 l. liste, creation, edition, suppression, fiche availability.py 233 l. disponibilites joueur et creneaux coach contracts.py 206 l. depot, signature, telechargement notes.py 376 l. notes d equipe et notes nominatives one_on_one.py 259 l. demandes de seance individuelle profile.py 132 l. profil de la personne connectee Aucun fichier ne depasse 400 lignes -- le critere d acceptation de l audit. **Un seul blueprint, pas six.** Les endpoints restent `users.*`. Les renommer aurait touche 137 appels `url_for` dans les gabarits, pour un benefice nul : l objectif est un fichier qu on peut lire, pas une carte d URL a reapprendre. Les 30 endpoints sont identiques avant et apres, verifie sur url_map. send_discord_notification part dans app/services/notifications.py. Elle tirait `requests`, `logging` et le bot Discord dans un module dont le sujet est le traitement HTTP, et se trouvait coincee entre deux definitions de route. Son `except Exception` est conserve et documente : une notification qui n arrive pas ne doit pas annuler la transaction qu elle annoncait. tests/test_route_map.py, nouveau : il parcourt gabarits et code, releve tout endpoint nomme litteralement dans un url_for, et verifie qu il existe dans la carte. C est le mode de defaillance de ce genre de decoupage -- pas une erreur a l import, mais un BuildError chez la premiere personne qui ouvre la page concernee. Un test de garde verifie aussi que le scan trouve quelque chose, sinon le reste serait vide de sens. Piege rencontre, et corrige : test_role_change patchait `app.routes.users.update_user_gamertags`. Apres le decoupage ce nom est un reexport, pas celui qu accounts.py resout -- le patch aurait pu laisser la route appeler la vraie fonction et le test passer sans rien verifier. Ici monkeypatch a echoue bruyamment, mais la cible est desormais explicite et le test enregistre que la doublure a bien ete appelee. 347 tests passent (263 + 84, dont 82 parametres par la carte des routes). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
-1699
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
"""User-facing routes, split by subject.
|
||||
|
||||
Was a single 1 699-line module covering account administration, profiles,
|
||||
availability calendars, contracts, one-on-one sessions and coach notes —
|
||||
six subjects that shared nothing but a URL prefix (ARCH-004).
|
||||
|
||||
Importing this package registers every route on `users_bp`, so app.py
|
||||
keeps its single `from app.routes.users import users_bp`. The blueprint
|
||||
itself lives in blueprint.py to keep that import one-directional.
|
||||
"""
|
||||
|
||||
from app.routes.users.blueprint import users_bp
|
||||
|
||||
# Imported for their side effect: each module attaches its routes to
|
||||
# users_bp. Order does not matter; none of them import each other.
|
||||
from app.routes.users import accounts # noqa: F401,E402
|
||||
from app.routes.users import availability # noqa: F401,E402
|
||||
from app.routes.users import contracts # noqa: F401,E402
|
||||
from app.routes.users import notes # noqa: F401,E402
|
||||
from app.routes.users import one_on_one # noqa: F401,E402
|
||||
from app.routes.users import profile # noqa: F401,E402
|
||||
|
||||
# Re-exported because tests and other modules reach for them by name.
|
||||
from app.routes.users._shared import ( # noqa: F401,E402
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
pdf_upload_error,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ALLOWED_CONTRACT_EXTENSIONS',
|
||||
'ALLOWED_SIGNED_EXTENSIONS',
|
||||
'pdf_upload_error',
|
||||
'users_bp',
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Helpers used by more than one route module in this package.
|
||||
|
||||
Nothing here touches the blueprint: these are plain functions, so a test
|
||||
can call them with a request context and nothing else.
|
||||
"""
|
||||
|
||||
from flask import flash, request
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Admin, Coach, Manager, Player, Scout, GAME_PLATFORMS, UserGamertag
|
||||
|
||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
||||
|
||||
#: Every PDF starts with this. Checking the name alone accepted a file
|
||||
#: called anything.pdf holding anything at all.
|
||||
PDF_SIGNATURE = b'%PDF-'
|
||||
|
||||
#: USER_TYPE → model class, for create_user.
|
||||
USER_CLASS_MAP = {
|
||||
'admin': Admin,
|
||||
'manager': Manager,
|
||||
'coach': Coach,
|
||||
'player': Player,
|
||||
'scout': Scout,
|
||||
}
|
||||
|
||||
|
||||
def pdf_upload_error(file, allowed_extensions):
|
||||
"""Why this upload is not an acceptable PDF, or None if it is.
|
||||
|
||||
upload_signed_contract checked nothing beyond a non-empty filename —
|
||||
ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player
|
||||
could put an arbitrary file on the server under a name the application
|
||||
later hands back for download (SEC-021).
|
||||
|
||||
Args:
|
||||
file: The uploaded FileStorage, or None.
|
||||
allowed_extensions: Extensions to accept, lowercase and without dot.
|
||||
|
||||
Returns:
|
||||
str | None: A message to flash, or None when the file is acceptable.
|
||||
"""
|
||||
if file is None or not file.filename:
|
||||
return _('No file selected.')
|
||||
|
||||
stem, dot, extension = file.filename.rpartition('.')
|
||||
if not (stem and dot) or extension.lower() not in allowed_extensions:
|
||||
return _('Only PDF files are allowed for contracts.')
|
||||
|
||||
head = file.stream.read(len(PDF_SIGNATURE))
|
||||
file.stream.seek(0)
|
||||
if head != PDF_SIGNATURE:
|
||||
return _('That file is not a PDF, whatever its name says.')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def flash_validation_errors(err):
|
||||
"""Surface marshmallow errors the same way auth.py already does."""
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
|
||||
|
||||
def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)):
|
||||
"""Turn the multi-valued request form into a plain dict for marshmallow.
|
||||
|
||||
request.form.to_dict() keeps only the first value of a repeated key, so
|
||||
list fields have to be re-read with getlist(). Unchecked HTML checkboxes
|
||||
are simply absent from the submission, which is not the same as a schema
|
||||
default, so they are injected explicitly. Blank optional fields are
|
||||
dropped rather than sent as '' — an empty password means "leave the
|
||||
current one alone", not "set the password to the empty string".
|
||||
"""
|
||||
payload = request.form.to_dict()
|
||||
for name in list_fields:
|
||||
payload[name] = request.form.getlist(name)
|
||||
for name in checkboxes:
|
||||
payload[name] = name in request.form
|
||||
for name in optional_blank:
|
||||
if not payload.get(name):
|
||||
payload.pop(name, None)
|
||||
return payload
|
||||
|
||||
|
||||
def update_user_gamertags(user, selected_games):
|
||||
"""Update gamertags for a user based on form input."""
|
||||
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||
for game in selected_games:
|
||||
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
||||
platform = (
|
||||
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
||||
)
|
||||
existing = existing_gamertags.get(game)
|
||||
if gamertag:
|
||||
if existing:
|
||||
existing.gamertag = gamertag
|
||||
existing.platform = platform
|
||||
else:
|
||||
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
|
||||
db.session.add(gt)
|
||||
elif existing:
|
||||
db.session.delete(existing)
|
||||
for game in existing_gamertags:
|
||||
if game not in selected_games:
|
||||
db.session.delete(existing_gamertags[game])
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Account administration — the president's view of the user list.
|
||||
|
||||
Creating, editing, deleting and viewing accounts. Everything here is
|
||||
admin-only except view_user, which renders a public profile.
|
||||
"""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db, hash_password
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import (
|
||||
Admin,
|
||||
CoachAvailability,
|
||||
Contract,
|
||||
ESPORT_GAMES,
|
||||
Evaluation,
|
||||
GAME_PLATFORMS,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
PlayerDisponibility,
|
||||
Team,
|
||||
TeamMember,
|
||||
TeamNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
USER_TYPES,
|
||||
User,
|
||||
UserGamertag,
|
||||
)
|
||||
from app.routes.users._shared import (
|
||||
USER_CLASS_MAP,
|
||||
flash_validation_errors,
|
||||
form_payload,
|
||||
update_user_gamertags,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import CreateUserSchema, EditUserSchema
|
||||
|
||||
|
||||
@users_bp.route('')
|
||||
@login_required
|
||||
def list_users():
|
||||
"""List all users for management (Admin only)."""
|
||||
if not isinstance(current_user, Admin):
|
||||
flash(_('Only the president can manage users.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
users = User.query.order_by(User.role, User.username).all()
|
||||
return render_template('pages/users.html', users=users, roles=USER_TYPES)
|
||||
|
||||
|
||||
@users_bp.route('/<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)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""When people are free.
|
||||
|
||||
Two calendars that share a shape without sharing a purpose: a player's
|
||||
weekly availability blocks, and a coach's bookable slots for one-on-one
|
||||
sessions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
|
||||
from app.routes.users.blueprint import users_bp
|
||||
|
||||
|
||||
DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
|
||||
def add_30_minutes(t):
|
||||
return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time()
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities')
|
||||
@login_required
|
||||
def get_disponibilities():
|
||||
"""API endpoint to get all player disponibilities for scheduling."""
|
||||
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
players = (
|
||||
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
result = {}
|
||||
for player in players:
|
||||
disponibilities = list(player.disponibilities)
|
||||
result[player.id] = {
|
||||
'username': player.username,
|
||||
'disponibilities': [
|
||||
{
|
||||
'id': d.id,
|
||||
'day_of_week': d.day_of_week,
|
||||
'day_name': DAY_NAMES[d.day_of_week],
|
||||
'start_time': d.start_time.strftime('%H:%M'),
|
||||
'end_time': d.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for d in disponibilities
|
||||
],
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/my')
|
||||
@login_required
|
||||
def get_my_disponibilities():
|
||||
"""API endpoint for players to get their own disponibilities."""
|
||||
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
|
||||
result = {}
|
||||
for d in disponibilities:
|
||||
day = d.day_of_week
|
||||
if day not in result:
|
||||
result[day] = []
|
||||
result[day].append(
|
||||
{
|
||||
'id': d.id,
|
||||
'day_of_week': d.day_of_week,
|
||||
'day_name': DAY_NAMES[d.day_of_week],
|
||||
'start_time': d.start_time.strftime('%H:%M'),
|
||||
'end_time': d.end_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_disponibility():
|
||||
"""Add a disponibility block for the current player."""
|
||||
day_of_week = request.form.get('day_of_week', type=int)
|
||||
start_time_str = request.form.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
return jsonify({'error': 'Invalid day of week'}), 400
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Invalid time format'}), 400
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': DAY_NAMES[disponibility.day_of_week],
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
'end_time': disponibility.end_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
|
||||
@login_required
|
||||
def add_disponibilities_bulk():
|
||||
"""Add multiple disponibility blocks at once."""
|
||||
data = request.get_json()
|
||||
slots = data.get('slots', [])
|
||||
created = []
|
||||
for slot in slots:
|
||||
day_of_week = slot.get('day_of_week')
|
||||
start_time_str = slot.get('start_time')
|
||||
if day_of_week is None or day_of_week < 0 or day_of_week > 6:
|
||||
continue
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
existing = PlayerDisponibility.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time,
|
||||
).first()
|
||||
if not existing:
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.flush()
|
||||
created.append(
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': DAY_NAMES[disponibility.day_of_week],
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'created': created})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
||||
@login_required
|
||||
def clear_disponibilities():
|
||||
"""Clear all disponibilities for the current player."""
|
||||
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/<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})
|
||||
|
||||
|
||||
@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})
|
||||
@@ -0,0 +1,16 @@
|
||||
"""The `users` blueprint object, on its own.
|
||||
|
||||
Every route module in this package imports it from here rather than from
|
||||
the package __init__, so there is no import cycle to reason about and no
|
||||
ordering constraint between the modules.
|
||||
|
||||
The blueprint stays a *single* blueprint even though the package holds six
|
||||
route modules. Splitting it into `users_accounts`, `users_contracts` and so
|
||||
on would rename 137 endpoints, and every one of them is spelled out in a
|
||||
`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a
|
||||
file you can read, not a URL map you have to relearn.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
users_bp = Blueprint('users', __name__, url_prefix='/users')
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Player contracts: upload, sign, download."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||
from app.routes.users._shared import (
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
pdf_upload_error,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import UploadContractSchema
|
||||
|
||||
|
||||
def manageable_players():
|
||||
"""Players the current user may attach a contract to.
|
||||
|
||||
A coach used to see the squad of one team — the first row matching the
|
||||
legacy coach_id column — so a coach of two teams could file a contract
|
||||
for half of their players and no more, and a coach attached only by the
|
||||
many-to-many relationship for none at all.
|
||||
"""
|
||||
if isinstance(current_user, Coach):
|
||||
player_ids = coach_player_ids(current_user)
|
||||
return (
|
||||
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
|
||||
if player_ids
|
||||
else []
|
||||
)
|
||||
return User.query.filter_by(role='player').order_by(User.username).all()
|
||||
|
||||
|
||||
@users_bp.route('/contracts')
|
||||
@login_required
|
||||
def list_contracts():
|
||||
"""View contracts for the current user or players they manage."""
|
||||
contracts = None
|
||||
players = None
|
||||
|
||||
if isinstance(current_user, Player):
|
||||
contracts = (
|
||||
Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
elif isinstance(current_user, (Admin, Manager, Coach)):
|
||||
players = manageable_players()
|
||||
|
||||
if players:
|
||||
player_ids = [p.id for p in players]
|
||||
contracts = (
|
||||
Contract.query.filter(
|
||||
Contract.player_id.in_(player_ids),
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/contracts.html',
|
||||
contracts=contracts,
|
||||
players=players if isinstance(current_user, (Admin, Manager, Coach)) else None,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_contract():
|
||||
"""Upload a contract for a player."""
|
||||
if not isinstance(current_user, (Admin, Manager, Coach)):
|
||||
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
players = manageable_players()
|
||||
|
||||
if request.method == 'POST':
|
||||
contract_schema = UploadContractSchema()
|
||||
try:
|
||||
validated = contract_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
player_id = validated['player_id']
|
||||
notes = validated.get('notes')
|
||||
|
||||
if not can_manage_player_contract(current_user, player_id):
|
||||
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
file = request.files.get('contract_file')
|
||||
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player_teams = player.get_org_teams()
|
||||
team = player_teams[0] if player_teams else None
|
||||
|
||||
if team:
|
||||
team_folder = os.path.join(upload_dir, secure_filename(team.name))
|
||||
os.makedirs(team_folder, exist_ok=True)
|
||||
final_dir = team_folder
|
||||
else:
|
||||
final_dir = upload_dir
|
||||
|
||||
original_filename = secure_filename(file.filename)
|
||||
file_uuid = str(uuid.uuid4())
|
||||
stored_filename = f"{file_uuid}.pdf"
|
||||
file_path = os.path.join(final_dir, stored_filename)
|
||||
file.save(file_path)
|
||||
|
||||
contract = Contract(
|
||||
player_id=player_id,
|
||||
team_id=team.id if team else None,
|
||||
uploaded_by_id=current_user.id,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=file_path,
|
||||
notes=notes if notes else None,
|
||||
)
|
||||
db.session.add(contract)
|
||||
db.session.commit()
|
||||
flash(
|
||||
_('Contract uploaded successfully for %(username)s!', username=player.username),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
|
||||
@users_bp.route('/contracts/<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
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Notes a coach keeps: about a team, and about individual players.
|
||||
|
||||
The player-facing view of the same notes lives here too — my_notes — since
|
||||
it reads exactly what the coach routes write.
|
||||
"""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Coach,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
TeamNote,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
|
||||
from app.routes.users.blueprint import users_bp
|
||||
|
||||
|
||||
@users_bp.route('/my-notes')
|
||||
@login_required
|
||||
def my_notes():
|
||||
"""View personal and team notes for the current player."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('This page is for players only.'), 'info')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_teams = current_user.get_org_teams()
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
|
||||
personal_notes = (
|
||||
PersonalNote.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
)
|
||||
.order_by(PersonalNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
team_notes = []
|
||||
if org_team:
|
||||
team_notes = (
|
||||
TeamNote.query.filter_by(
|
||||
org_team_id=org_team.id,
|
||||
)
|
||||
.order_by(TeamNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/player_personal_notes.html',
|
||||
org_team=org_team,
|
||||
personal_notes=personal_notes,
|
||||
team_notes=team_notes,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/notes-dashboard')
|
||||
@login_required
|
||||
def notes_dashboard():
|
||||
"""Notes and One on One dashboard for coaches."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can access the notes dashboard.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# The team-notes panel is still written against a single team; the
|
||||
# player list is not, and used to be narrowed to one team's squad while
|
||||
# the POST routes accepted every player the coach works with. The form
|
||||
# offered fewer players than the handler would take.
|
||||
org_teams = coach_org_teams(current_user)
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
|
||||
player_ids = coach_player_ids(current_user)
|
||||
players = (
|
||||
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
|
||||
if player_ids
|
||||
else []
|
||||
)
|
||||
|
||||
team_notes = []
|
||||
latest_team_note = None
|
||||
if org_team:
|
||||
team_notes = (
|
||||
TeamNote.query.filter_by(
|
||||
org_team_id=org_team.id,
|
||||
)
|
||||
.order_by(TeamNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
latest_team_note = team_notes[0] if team_notes else None
|
||||
|
||||
# A coach's own notes belong to them whether or not they hold a team;
|
||||
# this list was gated on org_team and came back empty without one.
|
||||
personal_notes = (
|
||||
PersonalNote.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
)
|
||||
.order_by(PersonalNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
one_on_one_requests = []
|
||||
if player_ids:
|
||||
one_on_one_requests = (
|
||||
OneOnOneRequest.query.filter(OneOnOneRequest.player_id.in_(player_ids))
|
||||
.order_by(OneOnOneRequest.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# For context selectors in the form
|
||||
matches = (
|
||||
Match.query.filter(
|
||||
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
||||
)
|
||||
.order_by(Match.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
tryouts = (
|
||||
Tryout.query.filter_by(
|
||||
created_by=current_user.id,
|
||||
)
|
||||
.order_by(Tryout.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
|
||||
return render_template(
|
||||
'pages/notes.html',
|
||||
org_team=org_team,
|
||||
players=players,
|
||||
team_notes=team_notes,
|
||||
latest_team_note=latest_team_note,
|
||||
personal_notes=personal_notes,
|
||||
one_on_one_requests=one_on_one_requests,
|
||||
matches=matches,
|
||||
tryouts=tryouts,
|
||||
teams=teams,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manage Team Notes (POST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/team-notes/manage', methods=['POST'])
|
||||
@login_required
|
||||
def manage_team_notes():
|
||||
"""Create or update team notes for the coach's org team."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage team notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# Same team the dashboard displays notes for, resolved the same way.
|
||||
org_teams = coach_org_teams(current_user)
|
||||
if not org_teams:
|
||||
flash(_('You are not assigned to a team.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
org_team = org_teams[0]
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(
|
||||
org_team_id=org_team.id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes saved successfully!'), 'success')
|
||||
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manage Personal Notes (POST, simple form)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/personal-notes/manage', methods=['POST'])
|
||||
@login_required
|
||||
def manage_personal_notes():
|
||||
"""Create a personal note for a player (coach only, simple form)."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Add Personal Note (POST, full form with context)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/personal-notes/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_personal_note():
|
||||
"""Create a personal note for a player with optional context (coach only)."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
match_id = request.form.get('match_id', type=int)
|
||||
tryout_id = request.form.get('tryout_id', type=int)
|
||||
team_id_str = request.form.get('team_id')
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
match_id=match_id if match_id else None,
|
||||
tryout_id=tryout_id if tryout_id else None,
|
||||
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Add Note from Tryout context (GET + POST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@users_bp.route('/personal-notes/tryout/<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=[],
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""One-on-one sessions between a player and their coach."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.services.notifications import send_discord_notification
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def one_on_one():
|
||||
"""One on One request page for players."""
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('Only players can request One on One sessions.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_teams = current_user.get_org_teams()
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
# Reading org_team.coach_id directly told every player whose team lists
|
||||
# its coaches through the many-to-many relationship — the newer of the
|
||||
# two ways — that they had no coach, and closed the page to them.
|
||||
# get_coaches() falls back to the legacy column when the list is empty.
|
||||
team_coaches = org_team.get_coaches() if org_team else []
|
||||
coach = team_coaches[0] if team_coaches else None
|
||||
|
||||
if not coach:
|
||||
flash(_('You do not have a coach assigned to your team.'), 'info')
|
||||
|
||||
team_notes = []
|
||||
if org_team:
|
||||
team_notes = (
|
||||
TeamNote.query.filter_by(org_team_id=org_team.id)
|
||||
.order_by(TeamNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
personal_notes = (
|
||||
PersonalNote.query.filter_by(player_id=current_user.id)
|
||||
.order_by(PersonalNote.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
coach_availability = []
|
||||
if coach:
|
||||
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
|
||||
coach_availability = [
|
||||
{
|
||||
'day_of_week': av.day_of_week,
|
||||
'start_time': av.start_time.strftime('%H:%M'),
|
||||
'end_time': av.end_time.strftime('%H:%M'),
|
||||
}
|
||||
for av in availabilities
|
||||
]
|
||||
|
||||
if request.method == 'POST':
|
||||
date_str = request.form.get('date')
|
||||
start_time_str = request.form.get('start_time')
|
||||
end_time_str = request.form.get('end_time')
|
||||
points = request.form.get('points', '').strip()
|
||||
|
||||
if not coach:
|
||||
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid date or time format.'), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
check_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
day_of_week = check_date.weekday()
|
||||
|
||||
is_available = any(
|
||||
av['day_of_week'] == day_of_week
|
||||
and av['start_time'] <= start_time_str
|
||||
and av['end_time'] >= end_time_str
|
||||
for av in coach_availability
|
||||
)
|
||||
|
||||
if not is_available:
|
||||
flash(_("The requested time is not within the coach's availability."), 'danger')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
request_obj = OneOnOneRequest(
|
||||
player_id=current_user.id,
|
||||
coach_id=coach.id,
|
||||
org_team_id=org_team.id if org_team else None,
|
||||
date=date_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
points=points if points else None,
|
||||
)
|
||||
db.session.add(request_obj)
|
||||
db.session.commit()
|
||||
|
||||
send_discord_notification(
|
||||
player_name=current_user.full_name,
|
||||
points=points,
|
||||
date_str=date_str,
|
||||
start_time_str=start_time_str,
|
||||
end_time_str=end_time_str,
|
||||
team_name=org_team.name if org_team else 'Unknown Team',
|
||||
coach_name=coach.full_name,
|
||||
coach_discord=coach.discord_username or '',
|
||||
coach_discord_id=coach.discord_user_id or '',
|
||||
request_id=request_obj.id,
|
||||
)
|
||||
|
||||
flash(_('Your One on One request has been submitted!'), 'success')
|
||||
return redirect(url_for('users.one_on_one'))
|
||||
|
||||
# Build list of upcoming dates that have coach availability
|
||||
from datetime import date as date_cls, timedelta as td
|
||||
|
||||
today = date_cls.today()
|
||||
available_days = {av['day_of_week'] for av in coach_availability}
|
||||
dates = []
|
||||
for i in range(14): # Next 14 days
|
||||
d = today + td(days=i)
|
||||
if d.weekday() in available_days:
|
||||
dates.append(
|
||||
{
|
||||
'value': d.strftime('%Y-%m-%d'),
|
||||
'day_of_week': d.weekday(),
|
||||
'display': d.strftime('%B %d, %Y (%A)'),
|
||||
}
|
||||
)
|
||||
|
||||
# Player's own One on One request history
|
||||
my_requests = (
|
||||
OneOnOneRequest.query.filter_by(player_id=current_user.id)
|
||||
.order_by(OneOnOneRequest.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pages/one_on_one.html',
|
||||
org_team=org_team,
|
||||
coach=coach,
|
||||
team_notes=team_notes,
|
||||
personal_notes=personal_notes,
|
||||
coach_availability=coach_availability,
|
||||
dates=dates,
|
||||
my_requests=my_requests,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/one-on-one/<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'))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The signed-in user's own profile."""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db, hash_password
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import (
|
||||
CoachAvailability,
|
||||
Coach,
|
||||
Contract,
|
||||
ESPORT_GAMES,
|
||||
GAME_PLATFORMS,
|
||||
Player,
|
||||
User,
|
||||
)
|
||||
from app.routes.users._shared import (
|
||||
flash_validation_errors,
|
||||
form_payload,
|
||||
update_user_gamertags,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import EditProfileSchema
|
||||
|
||||
|
||||
@users_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
"""View the current user's profile."""
|
||||
contracts = None
|
||||
if isinstance(current_user, Player):
|
||||
contracts = (
|
||||
Contract.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
)
|
||||
.order_by(Contract.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
existing_availability = None
|
||||
if isinstance(current_user, Coach):
|
||||
existing_availability = CoachAvailability.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).all()
|
||||
|
||||
return render_template(
|
||||
'pages/profile.html',
|
||||
user=current_user,
|
||||
contracts=contracts,
|
||||
existing_availability=existing_availability,
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/profile/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_profile():
|
||||
"""Edit the current user's profile."""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
validated = EditProfileSchema().load(form_payload())
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
username = validated['username']
|
||||
full_name = validated['full_name']
|
||||
email = validated['email']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
discord_username = validated.get('discord_username')
|
||||
discord_user_id = validated.get('discord_user_id')
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if username != current_user.username and User.query.filter_by(username=username).first():
|
||||
flash(_('Username already taken.'), 'danger')
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
if email != current_user.email and User.query.filter_by(email=email).first():
|
||||
flash(_('Email already in use.'), 'danger')
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
|
||||
current_user.username = username
|
||||
current_user.full_name = full_name
|
||||
current_user.email = email
|
||||
current_user.phone = phone
|
||||
current_user.games = ','.join(selected_games) if selected_games else None
|
||||
current_user.discord_username = discord_username or None
|
||||
current_user.discord_user_id = discord_user_id or None
|
||||
current_user.league_os_profile = league_os_profile or None
|
||||
|
||||
update_user_gamertags(current_user, selected_games)
|
||||
|
||||
# Blank means "keep the current password"; anything else has already
|
||||
# been checked against the policy by the schema.
|
||||
password = validated.get('password')
|
||||
if password:
|
||||
current_user.password_hash = hash_password(password)
|
||||
log_auth_event(
|
||||
'account.password_changed', username=current_user.username, user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Profile updated successfully!'), 'success')
|
||||
return redirect(url_for('users.profile'))
|
||||
|
||||
return render_template(
|
||||
'pages/edit_profile.html',
|
||||
user=current_user,
|
||||
esport_games=ESPORT_GAMES,
|
||||
game_platforms=GAME_PLATFORMS,
|
||||
user_gamertags=current_user.get_gamertags(),
|
||||
)
|
||||
Reference in New Issue
Block a user