CI - Security, Lint & Tests / validate (push) Failing after 1m3s
ajout de compteur d'heure disponible ajout du calendrier de dispo dans les profils publics
255 lines
9.7 KiB
Python
255 lines
9.7 KiB
Python
"""Player contracts: upload, sign, download."""
|
|
|
|
import os
|
|
import uuid
|
|
|
|
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.models.admin_settings import AppSettings
|
|
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.storage import (
|
|
CONTRACTS_DIR,
|
|
GoogleDriveStorageError,
|
|
is_google_drive_path,
|
|
open_document,
|
|
store_uploaded_document,
|
|
)
|
|
from app.time_utils import utc_now_naive
|
|
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 []
|
|
)
|
|
# is_active_account: a contract select that still lists people who have
|
|
# left the club invites filing paperwork against them (SEC-16).
|
|
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
|
|
|
|
|
|
def document_uploads_locked():
|
|
"""Return True when document uploads are globally disabled.
|
|
|
|
Admins are always allowed to bypass the lock. Managers, coaches, and
|
|
players are blocked when the toggle is off.
|
|
"""
|
|
if isinstance(current_user, Admin):
|
|
return False
|
|
return not AppSettings.get_bool('document_uploads_enabled', default=True)
|
|
|
|
|
|
@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'))
|
|
|
|
if document_uploads_locked():
|
|
flash(_('Document uploads are currently disabled.'), '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'))
|
|
|
|
player = db.get_or_404(User, player_id)
|
|
player_teams = player.get_org_teams()
|
|
team = player_teams[0] if player_teams else None
|
|
|
|
original_filename = secure_filename(file.filename)
|
|
stored_filename = f"{uuid.uuid4()}.pdf"
|
|
|
|
# Kept relative to the document root, not absolute (see app/storage.py):
|
|
# an absolute path pins the file to the directory the process was
|
|
# started from, which is the one thing a release-directory deploy
|
|
# changes.
|
|
relative_path = os.path.join(CONTRACTS_DIR, stored_filename)
|
|
if team:
|
|
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
|
|
|
try:
|
|
stored_path = store_uploaded_document(file, relative_path)
|
|
except GoogleDriveStorageError:
|
|
flash(
|
|
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
|
|
)
|
|
return redirect(url_for('users.upload_contract'))
|
|
|
|
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=stored_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 = db.get_or_404(Contract, contract_id)
|
|
if not contract.can_upload_signed(current_user):
|
|
flash(_('Only the player can upload their signed contract.'), 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
if document_uploads_locked():
|
|
flash(_('Document uploads are currently disabled.'), '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}"
|
|
# Legacy local records keep their signed copy beside the original. Drive
|
|
# identifiers are opaque rather than filenames, so new Drive records use
|
|
# a fresh logical path and receive a second Drive ID.
|
|
signed_path = (
|
|
os.path.join(CONTRACTS_DIR, signed_filename)
|
|
if is_google_drive_path(contract.file_path)
|
|
else contract.file_path.replace(contract.stored_filename, signed_filename)
|
|
)
|
|
try:
|
|
stored_signed_path = store_uploaded_document(file, signed_path)
|
|
except GoogleDriveStorageError:
|
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
|
|
contract.signed_filename = signed_filename
|
|
contract.signed_file_path = stored_signed_path
|
|
contract.status = 'signed'
|
|
contract.signed_at = utc_now_naive()
|
|
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 = db.get_or_404(Contract, 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'))
|
|
try:
|
|
document = open_document(contract.file_path)
|
|
except GoogleDriveStorageError:
|
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
return send_file(document, 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 = db.get_or_404(Contract, 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'))
|
|
try:
|
|
document = open_document(contract.signed_file_path)
|
|
except GoogleDriveStorageError:
|
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
|
return redirect(url_for('users.list_contracts'))
|
|
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|