premiere iteration de la gestion des documents
CI - Security, Lint & Tests / validate (push) Failing after 1m20s
CI - Security, Lint & Tests / validate (push) Failing after 1m20s
This commit is contained in:
+189
-1
@@ -6,6 +6,7 @@ to administrators.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
@@ -17,14 +18,23 @@ from flask import (
|
||||
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.forms import flash_validation_errors
|
||||
from app.models import (
|
||||
DOCUMENT_KIND_ROLES,
|
||||
DOCUMENT_KINDS,
|
||||
Admin,
|
||||
AppSettings,
|
||||
AuditLog,
|
||||
BackupRecord,
|
||||
Document,
|
||||
DocumentAssignment,
|
||||
DocumentVersion,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
@@ -33,12 +43,16 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users._shared import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
|
||||
from app.services.documents import backfill_assignments_for_document
|
||||
from app.storage import GoogleDriveStorageError, open_document, store_master_document
|
||||
from app.supporting_scripts.backup import (
|
||||
BackupError,
|
||||
backup_database,
|
||||
create_backup_dir,
|
||||
parse_database_url,
|
||||
)
|
||||
from app.validators import DocumentCreateSchema
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@@ -211,7 +225,7 @@ def restore_backup(backup_id):
|
||||
|
||||
# Create a safety backup of the current state before restoring.
|
||||
try:
|
||||
safety = _create_backup_record(
|
||||
_create_backup_record(
|
||||
backup_type='pre_restore',
|
||||
notes='Automatic safety backup before restoring ' + record.filename,
|
||||
)
|
||||
@@ -423,3 +437,177 @@ def wipe_teams():
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Documents (canonical document store)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/documents')
|
||||
@login_required
|
||||
def documents():
|
||||
"""List every document with its published flag and completion counts."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
documents = Document.query.order_by(Document.kind, Document.title).all()
|
||||
return render_template(
|
||||
'pages/admin_documents.html',
|
||||
documents=documents,
|
||||
kinds=DOCUMENT_KINDS,
|
||||
kind_roles=DOCUMENT_KIND_ROLES,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/documents/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_document():
|
||||
"""Create a new canonical document (no file yet; publish one next)."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
try:
|
||||
data = DocumentCreateSchema().load(request.form)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('admin.documents'))
|
||||
|
||||
document = Document(
|
||||
title=data['title'],
|
||||
kind=data['kind'],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.commit()
|
||||
|
||||
_log('document_created', f'{data["kind"]}: {data["title"]}')
|
||||
flash(_('Document "%(title)s" created.', title=data['title']), 'success')
|
||||
return respond_after_change(url_for('admin.documents'))
|
||||
|
||||
|
||||
@admin_bp.route('/documents/<int:doc_id>/publish-version', methods=['POST'])
|
||||
@login_required
|
||||
def publish_document_version(doc_id):
|
||||
"""Store a new PDF once and record it as the current version."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
document = db.get_or_404(Document, doc_id)
|
||||
file = request.files.get('version_file')
|
||||
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
|
||||
|
||||
original_filename = secure_filename(file.filename)
|
||||
stored_filename = f'{uuid.uuid4()}.pdf'
|
||||
|
||||
try:
|
||||
stored_path = store_master_document(file, stored_filename)
|
||||
except GoogleDriveStorageError:
|
||||
flash(
|
||||
_('Document storage is temporarily unavailable. Please try again later.'),
|
||||
'danger',
|
||||
)
|
||||
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
|
||||
|
||||
version = DocumentVersion(
|
||||
document_id=document.id,
|
||||
version=document.next_version_number(),
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
file_path=stored_path,
|
||||
notes=request.form.get('notes', '').strip() or None,
|
||||
published_by=current_user.id,
|
||||
)
|
||||
db.session.add(version)
|
||||
db.session.flush()
|
||||
document.current_version_id = version.id
|
||||
db.session.commit()
|
||||
|
||||
backfill_assignments_for_document(document)
|
||||
|
||||
_log('document_version_published', f'{document.title} v{version.version}')
|
||||
flash(
|
||||
_(
|
||||
'Version %(number)s of "%(title)s" published.',
|
||||
number=version.version,
|
||||
title=document.title,
|
||||
),
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
|
||||
|
||||
|
||||
@admin_bp.route('/documents/<int:doc_id>/toggle-published', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_document_published(doc_id):
|
||||
"""Flip whether team members can see and download the document."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
document = db.get_or_404(Document, doc_id)
|
||||
document.is_published = not document.is_published
|
||||
db.session.commit()
|
||||
|
||||
if document.is_published:
|
||||
# Enabling a document must also backfill anyone who is nowadays
|
||||
# eligible but was not when versions were published earlier.
|
||||
created = backfill_assignments_for_document(document)
|
||||
_log('document_published', f'{document.title} (assignments: {created})')
|
||||
flash(
|
||||
_('"%(title)s" is now downloadable by team members.', title=document.title),
|
||||
'success',
|
||||
)
|
||||
else:
|
||||
_log('document_unpublished', document.title)
|
||||
flash(
|
||||
_('"%(title)s" is no longer downloadable.', title=document.title),
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.documents'))
|
||||
|
||||
|
||||
@admin_bp.route('/documents/version/<int:version_id>/download')
|
||||
@login_required
|
||||
def download_document_version(version_id):
|
||||
"""Admin download of a stored document revision."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
version = db.get_or_404(DocumentVersion, version_id)
|
||||
try:
|
||||
document = open_document(version.file_path)
|
||||
except GoogleDriveStorageError:
|
||||
flash(
|
||||
_('Document storage is temporarily unavailable. Please try again later.'),
|
||||
'danger',
|
||||
)
|
||||
return respond_after_change(url_for('admin.documents'))
|
||||
return send_file(document, as_attachment=True, download_name=version.original_filename)
|
||||
|
||||
|
||||
@admin_bp.route('/documents/<int:doc_id>')
|
||||
@login_required
|
||||
def document_detail(doc_id):
|
||||
"""Revisions and per-person completion for one document."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
document = db.get_or_404(Document, doc_id)
|
||||
versions = document.versions.order_by(DocumentVersion.version).all()
|
||||
assignments = (
|
||||
db.session.query(DocumentAssignment, User)
|
||||
.join(User, User.id == DocumentAssignment.user_id)
|
||||
.filter(DocumentAssignment.document_id == document.id)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'pages/admin_document_detail.html',
|
||||
document=document,
|
||||
versions=versions,
|
||||
assignments=assignments,
|
||||
kinds=DOCUMENT_KINDS,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.models import (
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.services.documents import backfill_assignments_for_user
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
@@ -215,6 +216,9 @@ def create_team():
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
for staff_user in (coach, manager):
|
||||
if staff_user:
|
||||
backfill_assignments_for_user(staff_user)
|
||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
@@ -269,6 +273,8 @@ def edit_team(team_id):
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
for staff_user in team.get_coaches() + team.get_managers():
|
||||
backfill_assignments_for_user(staff_user)
|
||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
@@ -368,6 +374,7 @@ def add_coach(team_id):
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
backfill_assignments_for_user(coach)
|
||||
flash(
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||
'success',
|
||||
@@ -411,6 +418,7 @@ def add_manager(team_id):
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
backfill_assignments_for_user(manager)
|
||||
flash(
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||
'success',
|
||||
@@ -507,6 +515,7 @@ def add_player(team_id):
|
||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||
db.session.add(tp)
|
||||
db.session.commit()
|
||||
backfill_assignments_for_user(player)
|
||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.routes.users import (
|
||||
accounts, # noqa: F401,E402
|
||||
availability, # noqa: F401,E402
|
||||
contracts, # noqa: F401,E402
|
||||
documents, # noqa: F401,E402
|
||||
notes, # noqa: F401,E402
|
||||
one_on_one, # noqa: F401,E402
|
||||
profile, # noqa: F401,E402
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user