211 lines
8.3 KiB
Python
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)
|