Files
cedrick2711 abf2f06642
CI - Security, Lint & Tests / validate (push) Failing after 1m20s
premiere iteration de la gestion des documents
2026-09-11 09:51:48 -04:00

211 lines
8.3 KiB
Python

"""Member-facing document centre.
Players, coaches and managers see the published documents they must sign
or acknowledge, complete them, and download the file drawn from the single
canonical copy in storage. Coaches and managers additionally see progress
for the members of the teams they are attached to.
"""
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 app.extensions import db
from app.models import (
DOCUMENT_KIND_ROLES,
Admin,
Coach,
Document,
DocumentAssignment,
DocumentVersion,
Manager,
)
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.routes.users._shared import ALLOWED_SIGNED_EXTENSIONS, pdf_upload_error
from app.routes.users.blueprint import users_bp
from app.services.documents import (
backfill_assignments_for_user,
primary_team_for_user,
user_eligible_for_kind,
user_has_any_team,
)
from app.storage import GoogleDriveStorageError, open_document, store_signed_document
from app.time_utils import utc_now_naive
def _team_members(team):
"""Active players, coaches and managers of one team, de-duplicated."""
members = {}
for player in team.players:
if player.is_active_account:
members[player.id] = player
for staff in list(team.get_coaches()) + list(team.get_managers()):
if staff.is_active_account:
members[staff.id] = staff
return sorted(members.values(), key=lambda user: user.username.lower())
@users_bp.route('/documents')
@login_required
def list_my_documents():
"""The current member's obligations, plus team progress for staff."""
backfill_assignments_for_user(current_user)
if isinstance(current_user, Admin):
return redirect(url_for('admin.documents'))
if not user_has_any_team(current_user):
return render_template('pages/my_documents.html', documents=[], teams_progress=None)
published = (
Document.query.filter(Document.is_published.is_(True))
.order_by(Document.kind, Document.title)
.all()
)
documents = []
for doc in published:
if current_user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
assignment = DocumentAssignment.query.filter_by(
document_id=doc.id, user_id=current_user.id
).first()
documents.append({'document': doc, 'assignment': assignment})
teams_progress = None
if isinstance(current_user, (Coach, Manager)):
teams_progress = []
for team in visible_org_teams(current_user):
rows = []
for member in _team_members(team):
statuses = {}
for doc in published:
if member.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
assignment = DocumentAssignment.query.filter_by(
document_id=doc.id, user_id=member.id
).first()
statuses[doc.id] = assignment.status if assignment else None
rows.append({'user': member, 'statuses': statuses})
teams_progress.append({'team': team, 'members': rows, 'documents': published})
return render_template(
'pages/my_documents.html',
documents=documents,
teams_progress=teams_progress,
)
@users_bp.route('/documents/assignments/<int:assignment_id>/sign', methods=['POST'])
@login_required
def sign_document(assignment_id):
"""Upload the signed copy of a pending signature document."""
assignment = db.get_or_404(DocumentAssignment, assignment_id)
if assignment.user_id != current_user.id:
flash(_('You do not have permission to sign this document.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.completed:
flash(_('This document has already been completed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.requires_signature:
flash(_('This document is acknowledged, not signed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.current_version:
flash(_('This document has no version published yet.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
flash(error, 'danger')
return respond_after_change(url_for('users.list_my_documents'))
team = primary_team_for_user(current_user)
if team is None:
flash(_('You must be part of a team before signing documents.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
version = assignment.document.current_version
# The kind prefix keeps separate documents from colliding: a player signs
# both the player contract and the team agreement, each at version 1.
signed_filename = f'{assignment.document.kind}-v{version.version}.pdf'
try:
stored_path = store_signed_document(
file,
team_name=team.name,
role=current_user.role,
username=current_user.username,
filename=signed_filename,
)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('users.list_my_documents'))
assignment.status = 'signed'
assignment.signed_filename = signed_filename
assignment.signed_file_path = stored_path
assignment.completed_version_id = version.id
assignment.completed_at = utc_now_naive()
assignment.filed_team_id = team.id
db.session.commit()
flash(_('Document signed successfully!'), 'success')
return respond_after_change(url_for('users.list_my_documents'))
@users_bp.route('/documents/assignments/<int:assignment_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge_document(assignment_id):
"""Mark a read-and-acknowledge document (code of conduct, ethics)."""
assignment = db.get_or_404(DocumentAssignment, assignment_id)
if assignment.user_id != current_user.id:
flash(_('You do not have permission to acknowledge this document.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.completed:
flash(_('This document has already been completed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.document.requires_signature:
flash(_('This document must be signed, not acknowledged.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.current_version:
flash(_('This document has no version published yet.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
assignment.status = 'acknowledged'
assignment.completed_version_id = assignment.document.current_version.id
assignment.completed_at = utc_now_naive()
db.session.commit()
flash(_('Document acknowledged.'), 'success')
return respond_after_change(url_for('users.list_my_documents'))
@users_bp.route('/documents/version/<int:version_id>/download')
@login_required
def download_document(version_id):
"""Download the canonical file, gated on the admin's published flag."""
version = db.get_or_404(DocumentVersion, version_id)
allowed = isinstance(current_user, Admin) or (
version.document.is_published
and user_eligible_for_kind(current_user, version.document.kind)
)
if not allowed:
flash(_('This document is not available to you.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
try:
file_object = open_document(version.file_path)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('users.list_my_documents'))
return send_file(file_object, as_attachment=True, download_name=version.original_filename)