"""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//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//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//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)