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]>
207 lines
7.4 KiB
Python
207 lines
7.4 KiB
Python
"""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
|
|
)
|