Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5249331661 | ||
|
|
abf2f06642 |
+68
-3
@@ -28,6 +28,59 @@ def _setting(name):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def root_folder_id():
|
||||||
|
"""The configured Drive folder that holds every application document.
|
||||||
|
|
||||||
|
This is the folder the owner set up (the ``signés`` folder); new
|
||||||
|
documents land directly in it and signed copies are filed in nested
|
||||||
|
folders below it.
|
||||||
|
"""
|
||||||
|
return _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_folder(name, parent_id):
|
||||||
|
"""Return the Drive folder named *name* under *parent_id*, creating it.
|
||||||
|
|
||||||
|
Drive has no ``mkdir -p``; creating a folder is a write, so a signed
|
||||||
|
copy filed under ``<team>/<role>/<username>`` walks the chain once per
|
||||||
|
upload. A folder already present is reused rather than duplicated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Folder name to find or create.
|
||||||
|
parent_id: Drive id of the parent folder.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Drive id of the folder.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = _drive_service()
|
||||||
|
query = (
|
||||||
|
f"name='{name}' and mimeType='application/vnd.google-apps.folder' "
|
||||||
|
f"and '{parent_id}' in parents and trashed=false"
|
||||||
|
)
|
||||||
|
response = service.files().list(q=query, fields='files(id, name)', pageSize=10).execute()
|
||||||
|
existing = response.get('files', [])
|
||||||
|
if existing:
|
||||||
|
return existing[0]['id']
|
||||||
|
folder = (
|
||||||
|
service.files()
|
||||||
|
.create(
|
||||||
|
body={
|
||||||
|
'name': name,
|
||||||
|
'mimeType': 'application/vnd.google-apps.folder',
|
||||||
|
'parents': [parent_id],
|
||||||
|
},
|
||||||
|
fields='id',
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return folder['id']
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # Google client exceptions share no stable base class.
|
||||||
|
raise GoogleDriveStorageError('Google Drive could not create the document folder.') from exc
|
||||||
|
|
||||||
|
|
||||||
def _drive_service():
|
def _drive_service():
|
||||||
"""Build an authorized Drive client from the owner's refresh token."""
|
"""Build an authorized Drive client from the owner's refresh token."""
|
||||||
try:
|
try:
|
||||||
@@ -86,8 +139,17 @@ def list_folder_files():
|
|||||||
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
||||||
|
|
||||||
|
|
||||||
def upload_file(*, stream, filename, mimetype):
|
def upload_file(*, stream, filename, mimetype, parents=None):
|
||||||
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
"""Upload a PDF to the configured owner folder and return its Drive ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream: The binary stream to upload.
|
||||||
|
filename: Name used for the uploaded file.
|
||||||
|
mimetype: MIME type of the file.
|
||||||
|
parents: Drive ids of the parent folders. Defaults to the root
|
||||||
|
folder when omitted, which is how the legacy contract flow
|
||||||
|
still works.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from googleapiclient.http import MediaIoBaseUpload
|
from googleapiclient.http import MediaIoBaseUpload
|
||||||
|
|
||||||
@@ -97,7 +159,10 @@ def upload_file(*, stream, filename, mimetype):
|
|||||||
_drive_service()
|
_drive_service()
|
||||||
.files()
|
.files()
|
||||||
.create(
|
.create(
|
||||||
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
body={
|
||||||
|
'name': filename,
|
||||||
|
'parents': parents or [root_folder_id()],
|
||||||
|
},
|
||||||
media_body=media,
|
media_body=media,
|
||||||
fields='id',
|
fields='id',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,3 +97,17 @@ from app.models.one_on_one_request import OneOnOneRequest
|
|||||||
from app.models.admin_settings import AppSettings
|
from app.models.admin_settings import AppSettings
|
||||||
from app.models.backup_record import BackupRecord
|
from app.models.backup_record import BackupRecord
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Layer 12: documents (canonical document + versions + assignments)
|
||||||
|
# =========================================================================
|
||||||
|
from app.models.document import (
|
||||||
|
ACKNOWLEDGMENT_KINDS,
|
||||||
|
ASSIGNMENT_STATUSES,
|
||||||
|
DOCUMENT_KIND_ROLES,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
SIGNATURE_KINDS,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
DocumentVersion,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Documents, versions, and per-user assignments.
|
||||||
|
|
||||||
|
The old contract flow stored one uploaded file per player even when the
|
||||||
|
file was identical for everyone, and Drive received no team/role structure
|
||||||
|
at all. This module replaces that with three two-level concepts:
|
||||||
|
|
||||||
|
- ``Document`` the canonical document (e.g. ``Contrat joueur``).
|
||||||
|
One row per kind; the file is stored once, never
|
||||||
|
once per person.
|
||||||
|
- ``DocumentVersion`` an immutable published revision. Uploading a new
|
||||||
|
PDF is publishing a new version, which is the twice
|
||||||
|
a year update cycle.
|
||||||
|
- ``DocumentAssignment`` a per-person obligation. ``UNIQUE(document_id,
|
||||||
|
user_id)`` is what guarantees a player signs a
|
||||||
|
document exactly once, whatever the number of teams
|
||||||
|
they sit on.
|
||||||
|
|
||||||
|
Signing obligations are role-scoped, not team-scoped: the team folder in
|
||||||
|
Drive is a *filing* concern for the signed copy; the obligation itself is
|
||||||
|
created once per eligible person.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
#: Every document kind this application can hold.
|
||||||
|
DOCUMENT_KINDS = (
|
||||||
|
'player',
|
||||||
|
'coach',
|
||||||
|
'manager',
|
||||||
|
'team',
|
||||||
|
'code_of_conduct',
|
||||||
|
'ethics_code',
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Kinds whose completion is an uploaded signed PDF.
|
||||||
|
SIGNATURE_KINDS = ('player', 'coach', 'manager', 'team')
|
||||||
|
|
||||||
|
#: Kinds whose completion is read-and-acknowledge only (no file).
|
||||||
|
ACKNOWLEDGMENT_KINDS = ('code_of_conduct', 'ethics_code')
|
||||||
|
|
||||||
|
#: Kind -> set of roles eligible to complete it. ``team`` and the two
|
||||||
|
#: codes span roles; the three role contracts are single-role by nature.
|
||||||
|
DOCUMENT_KIND_ROLES = {
|
||||||
|
'player': ('player',),
|
||||||
|
'coach': ('coach',),
|
||||||
|
'manager': ('manager',),
|
||||||
|
'team': ('player', 'coach', 'manager'),
|
||||||
|
'code_of_conduct': ('player', 'coach', 'manager'),
|
||||||
|
'ethics_code': ('player', 'coach', 'manager'),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Possible values for DocumentAssignment.status.
|
||||||
|
ASSIGNMENT_STATUSES = ('pending', 'signed', 'acknowledged')
|
||||||
|
|
||||||
|
|
||||||
|
class Document(db.Model):
|
||||||
|
"""A canonical document, stored once, completed by eligible people."""
|
||||||
|
|
||||||
|
__tablename__ = 'documents'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(255), nullable=False)
|
||||||
|
kind = db.Column(db.String(30), nullable=False, default='player')
|
||||||
|
#: Admins choose when the document is downloadable by team members. The
|
||||||
|
#: old ``document_uploads_enabled`` global toggle is replaced by this
|
||||||
|
#: per-document switch for the new flow.
|
||||||
|
is_published = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
current_version_id = db.Column(db.Integer, db.ForeignKey('document_versions.id'), nullable=True)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
versions = db.relationship(
|
||||||
|
'DocumentVersion',
|
||||||
|
backref='document',
|
||||||
|
lazy='dynamic',
|
||||||
|
order_by='DocumentVersion.version',
|
||||||
|
foreign_keys='DocumentVersion.document_id',
|
||||||
|
)
|
||||||
|
current_version = db.relationship(
|
||||||
|
'DocumentVersion',
|
||||||
|
foreign_keys=[current_version_id],
|
||||||
|
uselist=False,
|
||||||
|
)
|
||||||
|
assignments = db.relationship(
|
||||||
|
'DocumentAssignment',
|
||||||
|
back_populates='document',
|
||||||
|
lazy='dynamic',
|
||||||
|
order_by='DocumentAssignment.created_at',
|
||||||
|
foreign_keys='DocumentAssignment.document_id',
|
||||||
|
)
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_signature(self):
|
||||||
|
"""Whether completion means an uploaded signed PDF."""
|
||||||
|
return self.kind in SIGNATURE_KINDS
|
||||||
|
|
||||||
|
def completion_counts(self):
|
||||||
|
"""(completed, pending) over every assignment of this document.
|
||||||
|
|
||||||
|
``completed`` covers both signed and acknowledged assignments.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
db.session.query(DocumentAssignment.status, db.func.count())
|
||||||
|
.filter(DocumentAssignment.document_id == self.id)
|
||||||
|
.group_by(DocumentAssignment.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
totals = dict(rows)
|
||||||
|
completed = totals.get('signed', 0) + totals.get('acknowledged', 0)
|
||||||
|
return completed, totals.get('pending', 0)
|
||||||
|
|
||||||
|
def next_version_number(self):
|
||||||
|
"""The version number a freshly published revision should carry."""
|
||||||
|
latest = self.versions.order_by(DocumentVersion.version.desc()).first()
|
||||||
|
return (latest.version + 1) if latest else 1
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentVersion(db.Model):
|
||||||
|
"""One immutable published revision of a document."""
|
||||||
|
|
||||||
|
__tablename__ = 'document_versions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
|
||||||
|
version = db.Column(db.Integer, nullable=False)
|
||||||
|
original_filename = db.Column(db.String(255), nullable=False)
|
||||||
|
stored_filename = db.Column(db.String(255), nullable=False)
|
||||||
|
#: ``gdrive://<file-id>`` for Drive storage, or a path relative to the
|
||||||
|
#: document root for the local backend.
|
||||||
|
file_path = db.Column(db.String(500), nullable=False)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
published_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
published_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
publisher = db.relationship('User', foreign_keys=[published_by])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('document_id', 'version', name='unique_document_version'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAssignment(db.Model):
|
||||||
|
"""One person's obligation to sign or acknowledge a document."""
|
||||||
|
|
||||||
|
__tablename__ = 'document_assignments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
completed_version_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('document_versions.id'), nullable=True
|
||||||
|
)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default='pending')
|
||||||
|
signed_filename = db.Column(db.String(255), nullable=True)
|
||||||
|
signed_file_path = db.Column(db.String(500), nullable=True)
|
||||||
|
completed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
#: The team whose Drive folder files the signed copy. A member on
|
||||||
|
#: several teams still files under one canonical team only.
|
||||||
|
filed_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
document = db.relationship('Document', back_populates='assignments', foreign_keys=[document_id])
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id], backref='document_assignments')
|
||||||
|
completed_version = db.relationship('DocumentVersion', foreign_keys=[completed_version_id])
|
||||||
|
filed_team = db.relationship('OrgTeam', foreign_keys=[filed_team_id])
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint('document_id', 'user_id', name='unique_document_user'),)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completed(self):
|
||||||
|
return self.status in ('signed', 'acknowledged')
|
||||||
+189
-1
@@ -6,6 +6,7 @@ to administrators.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -17,14 +18,23 @@ from flask import (
|
|||||||
send_file,
|
send_file,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
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.extensions import db
|
||||||
|
from app.forms import flash_validation_errors
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
DOCUMENT_KIND_ROLES,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
Admin,
|
Admin,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
BackupRecord,
|
BackupRecord,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
DocumentVersion,
|
||||||
OrgTeam,
|
OrgTeam,
|
||||||
TeamMatch,
|
TeamMatch,
|
||||||
TeamMatchParticipant,
|
TeamMatchParticipant,
|
||||||
@@ -33,12 +43,16 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.responders import respond_after_change
|
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 (
|
from app.supporting_scripts.backup import (
|
||||||
BackupError,
|
BackupError,
|
||||||
backup_database,
|
backup_database,
|
||||||
create_backup_dir,
|
create_backup_dir,
|
||||||
parse_database_url,
|
parse_database_url,
|
||||||
)
|
)
|
||||||
|
from app.validators import DocumentCreateSchema
|
||||||
|
|
||||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
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.
|
# Create a safety backup of the current state before restoring.
|
||||||
try:
|
try:
|
||||||
safety = _create_backup_record(
|
_create_backup_record(
|
||||||
backup_type='pre_restore',
|
backup_type='pre_restore',
|
||||||
notes='Automatic safety backup before restoring ' + record.filename,
|
notes='Automatic safety backup before restoring ' + record.filename,
|
||||||
)
|
)
|
||||||
@@ -423,3 +437,177 @@ def wipe_teams():
|
|||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
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.permissions import visible_org_teams
|
||||||
from app.responders import respond_after_change
|
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.time_utils import utc_now_naive
|
||||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||||
|
|
||||||
@@ -215,6 +216,9 @@ def create_team():
|
|||||||
team.managers.append(manager)
|
team.managers.append(manager)
|
||||||
|
|
||||||
db.session.commit()
|
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')
|
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
@@ -269,6 +273,8 @@ def edit_team(team_id):
|
|||||||
team.managers.append(manager)
|
team.managers.append(manager)
|
||||||
|
|
||||||
db.session.commit()
|
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')
|
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
@@ -368,6 +374,7 @@ def add_coach(team_id):
|
|||||||
if not team.coach_id:
|
if not team.coach_id:
|
||||||
team.coach_id = coach.id
|
team.coach_id = coach.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
backfill_assignments_for_user(coach)
|
||||||
flash(
|
flash(
|
||||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
@@ -411,6 +418,7 @@ def add_manager(team_id):
|
|||||||
if not team.manager_id:
|
if not team.manager_id:
|
||||||
team.manager_id = manager.id
|
team.manager_id = manager.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
backfill_assignments_for_user(manager)
|
||||||
flash(
|
flash(
|
||||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
@@ -507,6 +515,7 @@ def add_player(team_id):
|
|||||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||||
db.session.add(tp)
|
db.session.add(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
backfill_assignments_for_user(player)
|
||||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.routes.users import (
|
|||||||
accounts, # noqa: F401,E402
|
accounts, # noqa: F401,E402
|
||||||
availability, # noqa: F401,E402
|
availability, # noqa: F401,E402
|
||||||
contracts, # noqa: F401,E402
|
contracts, # noqa: F401,E402
|
||||||
|
documents, # noqa: F401,E402
|
||||||
notes, # noqa: F401,E402
|
notes, # noqa: F401,E402
|
||||||
one_on_one, # noqa: F401,E402
|
one_on_one, # noqa: F401,E402
|
||||||
profile, # 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)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Document lifecycle logic: eligibility, assignment backfill, primary team.
|
||||||
|
|
||||||
|
The rules this encodes are the club's:
|
||||||
|
|
||||||
|
- Only *active* members who belong to at least one team receive an
|
||||||
|
obligation. A player not placed on a team signs nothing.
|
||||||
|
- Obligations are role-scoped. A player on two teams signs the player
|
||||||
|
contract once; ``UNIQUE(document_id, user_id)`` is the guarantee, and the
|
||||||
|
team folder below is only where the signed copy is filed.
|
||||||
|
- Publishing a version (or enabling a document) backfills pending
|
||||||
|
assignments for everyone currently eligible. Joining a team backfills
|
||||||
|
that one person across every published document matching their role.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import (
|
||||||
|
DOCUMENT_KIND_ROLES,
|
||||||
|
Coach,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
Manager,
|
||||||
|
OrgTeam,
|
||||||
|
Player,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_org_team_ids(user):
|
||||||
|
"""Ids of the organisation teams the user is a member of.
|
||||||
|
|
||||||
|
Role-appropriate: placements for a player, the coach/managers
|
||||||
|
relationships (many-to-many *and* legacy single column) for staff. An
|
||||||
|
admin or a scout belongs to none, regardless of how many teams exist.
|
||||||
|
"""
|
||||||
|
from app.permissions import coach_org_teams, manager_org_teams
|
||||||
|
|
||||||
|
if isinstance(user, Player):
|
||||||
|
return [tp.org_team_id for tp in user.team_placements]
|
||||||
|
if isinstance(user, Coach):
|
||||||
|
return [team.id for team in coach_org_teams(user)]
|
||||||
|
if isinstance(user, Manager):
|
||||||
|
return [team.id for team in manager_org_teams(user)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_any_team(user):
|
||||||
|
"""Whether the user belongs to at least one organisation team."""
|
||||||
|
return bool(user_org_team_ids(user))
|
||||||
|
|
||||||
|
|
||||||
|
def primary_team_for_user(user):
|
||||||
|
"""The canonical team used for filing a signed copy, or None.
|
||||||
|
|
||||||
|
A member can sit on several teams but every signed file is filed under
|
||||||
|
one team only. The choice is deterministic: the team with the lowest
|
||||||
|
id among the user's memberships.
|
||||||
|
"""
|
||||||
|
team_ids = user_org_team_ids(user)
|
||||||
|
if not team_ids:
|
||||||
|
return None
|
||||||
|
return db.session.get(OrgTeam, min(team_ids))
|
||||||
|
|
||||||
|
|
||||||
|
def user_eligible_for_kind(user, kind):
|
||||||
|
"""Whether *user* must complete documents of *kind*."""
|
||||||
|
if kind not in DOCUMENT_KIND_ROLES:
|
||||||
|
return False
|
||||||
|
if not user.is_active_account:
|
||||||
|
return False
|
||||||
|
if user.role not in DOCUMENT_KIND_ROLES[kind]:
|
||||||
|
return False
|
||||||
|
return user_has_any_team(user)
|
||||||
|
|
||||||
|
|
||||||
|
def eligible_user_ids(kind):
|
||||||
|
"""Ids of every user currently eligible for *kind*, ordered by id."""
|
||||||
|
roles = DOCUMENT_KIND_ROLES[kind]
|
||||||
|
users = (
|
||||||
|
User.query.filter(User.role.in_(roles), User.is_active_account.is_(True))
|
||||||
|
.order_by(User.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [u.id for u in users if user_has_any_team(u)]
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_assignments_for_document(document):
|
||||||
|
"""Create a pending assignment for every currently eligible user.
|
||||||
|
|
||||||
|
Idempotent: anyone who already has an assignment for this document is
|
||||||
|
left alone. Returns how many new assignments were created.
|
||||||
|
"""
|
||||||
|
existing = {a.user_id for a in document.assignments.all()}
|
||||||
|
created = 0
|
||||||
|
for user_id in eligible_user_ids(document.kind):
|
||||||
|
if user_id in existing:
|
||||||
|
continue
|
||||||
|
db.session.add(
|
||||||
|
DocumentAssignment(
|
||||||
|
document_id=document.id,
|
||||||
|
user_id=user_id,
|
||||||
|
status='pending',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_assignments_for_user(user):
|
||||||
|
"""Create pending assignments for *user* across published documents.
|
||||||
|
|
||||||
|
Called when a member joins (or is reactivated into) a team. Only
|
||||||
|
published documents are backfilled: an essentially invisible draft
|
||||||
|
creates no obligations. Returns how many new assignments were created.
|
||||||
|
"""
|
||||||
|
if not user.is_active_account or not user_has_any_team(user):
|
||||||
|
return 0
|
||||||
|
documents = Document.query.filter(Document.is_published.is_(True)).order_by(Document.id).all()
|
||||||
|
created = 0
|
||||||
|
for doc in documents:
|
||||||
|
if user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
|
||||||
|
continue
|
||||||
|
exists = DocumentAssignment.query.filter_by(document_id=doc.id, user_id=user.id).first()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
db.session.add(
|
||||||
|
DocumentAssignment(
|
||||||
|
document_id=doc.id,
|
||||||
|
user_id=user.id,
|
||||||
|
status='pending',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
return created
|
||||||
@@ -41,6 +41,12 @@ from app.google_drive import (
|
|||||||
from app.google_drive import (
|
from app.google_drive import (
|
||||||
download_file as download_google_drive_file,
|
download_file as download_google_drive_file,
|
||||||
)
|
)
|
||||||
|
from app.google_drive import (
|
||||||
|
get_or_create_folder as get_or_create_drive_folder,
|
||||||
|
)
|
||||||
|
from app.google_drive import (
|
||||||
|
root_folder_id as google_drive_root_folder_id,
|
||||||
|
)
|
||||||
from app.google_drive import (
|
from app.google_drive import (
|
||||||
upload_file as upload_google_drive_file,
|
upload_file as upload_google_drive_file,
|
||||||
)
|
)
|
||||||
@@ -159,6 +165,73 @@ def store_uploaded_document(file_storage, relative_path):
|
|||||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def _save_locally(file_storage, relative_path):
|
||||||
|
"""Persist *relative_path* (from ``documents_root()``) and return it."""
|
||||||
|
absolute_path = document_path(relative_path)
|
||||||
|
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||||
|
file_storage.save(absolute_path)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_folder_for(parts):
|
||||||
|
"""Resolve (and create as needed) a nested chain of Drive folders.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parts: Folder names under the root folder, root first.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Drive id of the deepest folder.
|
||||||
|
"""
|
||||||
|
parent = google_drive_root_folder_id()
|
||||||
|
for part in parts:
|
||||||
|
parent = get_or_create_drive_folder(part, parent)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def store_master_document(file_storage, filename, mimetype=None):
|
||||||
|
"""Store the unsigned template of a document (the canonical copy).
|
||||||
|
|
||||||
|
The point is to store the file exactly once: every assignment for that
|
||||||
|
document points at this one reference.
|
||||||
|
"""
|
||||||
|
if document_storage_backend() == 'local':
|
||||||
|
return _save_locally(file_storage, os.path.join(CONTRACTS_DIR, filename))
|
||||||
|
|
||||||
|
file_id = upload_google_drive_file(
|
||||||
|
stream=file_storage.stream,
|
||||||
|
filename=filename,
|
||||||
|
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
|
||||||
|
)
|
||||||
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def store_signed_document(file_storage, *, team_name, role, username, filename, mimetype=None):
|
||||||
|
"""File a signed copy under ``signés/<team>/<role>/<username>/<filename>``.
|
||||||
|
|
||||||
|
The root folder is already the signed-contracts folder, so no extra
|
||||||
|
``signed`` segment is added (the layout the club requested).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_storage: The uploaded FileStorage.
|
||||||
|
team_name: Name of the signing member's (primary) team.
|
||||||
|
role: The signing member's own role (player/coach/manager).
|
||||||
|
username: The signing member's username.
|
||||||
|
filename: Name of the signed file, e.g. ``player-v1.pdf``.
|
||||||
|
"""
|
||||||
|
if document_storage_backend() == 'local':
|
||||||
|
relative = os.path.join(CONTRACTS_DIR, team_name, role, username, filename)
|
||||||
|
return _save_locally(file_storage, relative)
|
||||||
|
|
||||||
|
parent = _drive_folder_for([team_name, role, username])
|
||||||
|
file_id = upload_google_drive_file(
|
||||||
|
stream=file_storage.stream,
|
||||||
|
filename=filename,
|
||||||
|
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
|
||||||
|
parents=[parent],
|
||||||
|
)
|
||||||
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
def open_document(stored_path):
|
def open_document(stored_path):
|
||||||
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||||
if is_google_drive_path(stored_path):
|
if is_google_drive_path(stored_path):
|
||||||
|
|||||||
@@ -113,6 +113,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('users.list_my_documents') }}" class="{% if request.endpoint and 'document' in request.endpoint %}active{% endif %}">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<span>{{ _('Documents') }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
||||||
<i class="fas fa-file-contract"></i>
|
<i class="fas fa-file-contract"></i>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ document.title }} - Admin{% endblock %}
|
||||||
|
{% block page_title %}{{ document.title }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / <a href="{{ url_for('admin.documents') }}">{{ _('Documents') }}</a> / {{ document.title }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set kind_labels = {
|
||||||
|
'player': _('Player contract'),
|
||||||
|
'coach': _('Coach contract'),
|
||||||
|
'manager': _('Manager contract'),
|
||||||
|
'team': _('Team agreement'),
|
||||||
|
'code_of_conduct': _('Code of conduct'),
|
||||||
|
'ethics_code': _('Ethics code'),
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-file-upload"></i> {{ _('Publish a new version') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{{ _('Type') }}: <strong>{{ kind_labels.get(document.kind, document.kind) }}</strong>
|
||||||
|
{% if document.requires_signature %} · {{ _('signed by the member') }}
|
||||||
|
{% else %} · {{ _('acknowledged by the member') }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin.publish_document_version', doc_id=document.id) }}" enctype="multipart/form-data" class="form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="version_file">{{ _('New version file (PDF)') }}</label>
|
||||||
|
<input type="file" id="version_file" name="version_file" accept=".pdf" required>
|
||||||
|
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">{{ _('Version notes (optional)') }}</label>
|
||||||
|
<textarea id="notes" name="notes" rows="2" placeholder="{{ _('What changed in this version?') }}"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('admin.documents') }}" class="btn btn-secondary">{{ _('Back') }}</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upload"></i> {{ _('Publish version') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-history"></i> {{ _('Versions') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Version') }}</th>
|
||||||
|
<th>{{ _('File') }}</th>
|
||||||
|
<th>{{ _('Notes') }}</th>
|
||||||
|
<th>{{ _('Published') }}</th>
|
||||||
|
<th>{{ _('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for version in versions %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ _('v%(number)s', number=version.version) }}
|
||||||
|
{% if document.current_version_id == version.id %}
|
||||||
|
<span class="badge badge-success">{{ _('Current') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ version.original_filename }}</td>
|
||||||
|
<td>{{ version.notes or '—' }}</td>
|
||||||
|
<td>{{ version.published_at.strftime('%Y-%m-%d %H:%M') if version.published_at else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin.download_document_version', version_id=version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-muted">{{ _('No versions published yet.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-list-check"></i> {{ _('Completion') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Member') }}</th>
|
||||||
|
<th>{{ _('Role') }}</th>
|
||||||
|
<th>{{ _('Status') }}</th>
|
||||||
|
<th>{{ _('Completed at') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for assignment, user in assignments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.role | capitalize }}</td>
|
||||||
|
<td>
|
||||||
|
{% if assignment.status == 'signed' %}
|
||||||
|
<span class="badge badge-success">{{ _('Signed') }}</span>
|
||||||
|
{% elif assignment.status == 'acknowledged' %}
|
||||||
|
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">{{ _('Pending') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ assignment.completed_at.strftime('%Y-%m-%d %H:%M') if assignment.completed_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-muted">
|
||||||
|
{{ _('No one is assigned yet. Publish a version first.') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ _('Documents') }} - Admin{% endblock %}
|
||||||
|
{% block page_title %}{{ _('Document management') }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / {{ _('Documents') }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set kind_labels = {
|
||||||
|
'player': _('Player contract'),
|
||||||
|
'coach': _('Coach contract'),
|
||||||
|
'manager': _('Manager contract'),
|
||||||
|
'team': _('Team agreement'),
|
||||||
|
'code_of_conduct': _('Code of conduct'),
|
||||||
|
'ethics_code': _('Ethics code'),
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-plus-circle"></i> {{ _('Create a document') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('admin.create_document') }}" class="form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">{{ _('Title') }}</label>
|
||||||
|
<input type="text" id="title" name="title" class="form-control" required
|
||||||
|
placeholder="{{ _('e.g. Player contract 2026-2027') }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="kind">{{ _('Type') }}</label>
|
||||||
|
<select id="kind" name="kind" class="form-select" required>
|
||||||
|
<option value="">{{ _('-- Select a type --') }}</option>
|
||||||
|
{% for kind in kinds %}
|
||||||
|
<option value="{{ kind }}">{{ kind_labels.get(kind, kind) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{{ _('The type decides who must complete the document and how.') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus"></i> {{ _('Create document') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-file-contract"></i> {{ _('Existing documents') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Title') }}</th>
|
||||||
|
<th>{{ _('Type') }}</th>
|
||||||
|
<th>{{ _('Version') }}</th>
|
||||||
|
<th>{{ _('Availability') }}</th>
|
||||||
|
<th>{{ _('Progress') }}</th>
|
||||||
|
<th>{{ _('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.document_detail', doc_id=document.id) }}">{{ document.title }}</a></td>
|
||||||
|
<td>{{ kind_labels.get(document.kind, document.kind) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if document.current_version %}
|
||||||
|
{{ _('v%(number)s', number=document.current_version.version) }}
|
||||||
|
{% else %}{{ _('None') }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if document.is_published %}
|
||||||
|
<span class="badge badge-success">{{ _('Downloadable') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">{{ _('Hidden') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set completed, pending = document.completion_counts() %}
|
||||||
|
<span class="badge badge-info">{{ _('%(completed)s done', completed=completed) }}</span>
|
||||||
|
<span class="badge badge-warning">{{ _('%(pending)s pending', pending=pending) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('admin.document_detail', doc_id=document.id) }}" class="btn btn-sm btn-primary" title="{{ _('Manage') }}">
|
||||||
|
<i class="fas fa-cog"></i> {{ _('Manage') }}
|
||||||
|
</a>
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_published', doc_id=document.id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
{% if document.is_published %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary" title="{{ _('Hide') }}">
|
||||||
|
<i class="fas fa-eye-slash"></i> {{ _('Hide') }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Make downloadable') }}">
|
||||||
|
<i class="fas fa-eye"></i> {{ _('Publish') }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-muted">{{ _('No documents yet. Create one above.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ _('My Documents') }} - UdeS team manager{% endblock %}
|
||||||
|
{% block page_title %}{{ _('My Documents') }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / {{ _('My Documents') }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set kind_labels = {
|
||||||
|
'player': _('Player contract'),
|
||||||
|
'coach': _('Coach contract'),
|
||||||
|
'manager': _('Manager contract'),
|
||||||
|
'team': _('Team agreement'),
|
||||||
|
'code_of_conduct': _('Code of conduct'),
|
||||||
|
'ethics_code': _('Ethics code'),
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-file-contract"></i> {{ _('Documents to sign or acknowledge') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if documents %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Document') }}</th>
|
||||||
|
<th>{{ _('Type') }}</th>
|
||||||
|
<th>{{ _('Version') }}</th>
|
||||||
|
<th>{{ _('Status') }}</th>
|
||||||
|
<th>{{ _('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.document.title }}</td>
|
||||||
|
<td>{{ kind_labels.get(entry.document.kind, entry.document.kind) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.document.current_version %}
|
||||||
|
{{ _('Version %(number)s', number=entry.document.current_version.version) }}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set assignment = entry.assignment %}
|
||||||
|
{% if assignment is not none and assignment.status == 'signed' %}
|
||||||
|
<span class="badge badge-success">{{ _('Signed') }}</span>
|
||||||
|
{% elif assignment is not none and assignment.status == 'acknowledged' %}
|
||||||
|
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">{{ _('Pending') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
{% if entry.document.current_version %}
|
||||||
|
<a href="{{ url_for('users.download_document', version_id=entry.document.current_version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
|
||||||
|
<i class="fas fa-download"></i> {{ _('Download') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if assignment is not none and assignment.status == 'pending' %}
|
||||||
|
{% if entry.document.requires_signature %}
|
||||||
|
<button class="btn btn-sm btn-warning" data-action="show-sign-document" data-assignment-id="{{ assignment.id }}" title="{{ _('Return signed') }}">
|
||||||
|
<i class="fas fa-upload"></i> {{ _('Sign') }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('users.acknowledge_document', assignment_id=assignment.id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-success" data-confirm="{{ _('Confirm that you have read and understood this document') }}">
|
||||||
|
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% elif not teams_progress %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-file-contract"></i>
|
||||||
|
<h4>{{ _('No documents for now') }}</h4>
|
||||||
|
<p>{{ _('Nothing has been published for you yet. Check back later.') }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if teams_progress %}
|
||||||
|
{% for block in teams_progress %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-users"></i> {{ _('Team progress') }} — {{ block.team.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Member') }}</th>
|
||||||
|
<th>{{ _('Role') }}</th>
|
||||||
|
{% for doc in block.documents %}<th>{{ doc.title }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in block.members %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ member.user.username }}</td>
|
||||||
|
<td>{{ member.user.role | capitalize }}</td>
|
||||||
|
{% for doc in block.documents %}
|
||||||
|
{% set status = member.statuses.get(doc.id) %}
|
||||||
|
<td>
|
||||||
|
{% if status == 'signed' %}<span class="badge badge-success">{{ _('Signed') }}</span>
|
||||||
|
{% elif status == 'acknowledged' %}<span class="badge badge-success">{{ _('Acknowledged') }}</span>
|
||||||
|
{% elif status == 'pending' %}<span class="badge badge-warning">{{ _('Pending') }}</span>
|
||||||
|
{% else %}<span class="text-muted">{{ _('Not required') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Upload signed document modal -->
|
||||||
|
<div id="signDocumentModal" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-action="hide-sign-document"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ _('Upload Signed Document') }}</h3>
|
||||||
|
<button class="modal-close" data-action="hide-sign-document">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="signDocumentForm" method="POST" enctype="multipart/form-data" class="form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="signed_file">{{ _('Select Signed Document File') }}</label>
|
||||||
|
<input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||||
|
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" data-action="hide-sign-document">{{ _('Cancel') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ _('Upload Signed Document') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
function showSignDocumentForm(assignmentId) {
|
||||||
|
document.getElementById('signDocumentForm').action = '/users/documents/assignments/' + assignmentId + '/sign';
|
||||||
|
document.getElementById('signDocumentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSignDocumentForm() {
|
||||||
|
document.getElementById('signDocumentModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
registerActions({
|
||||||
|
'show-sign-document': function (element) {
|
||||||
|
showSignDocumentForm(element.getAttribute('data-assignment-id'));
|
||||||
|
},
|
||||||
|
'hide-sign-document': hideSignDocumentForm,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -23,7 +23,7 @@ from marshmallow import (
|
|||||||
validates_schema,
|
validates_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
from app.models import DOCUMENT_KINDS, ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Custom Validators
|
# Custom Validators
|
||||||
@@ -1061,3 +1061,23 @@ class EvaluationSchema(StripMixin):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
load_default=None,
|
load_default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCreateSchema(StripMixin):
|
||||||
|
"""Validate the admin form that creates a new document.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
title: Human-readable name of the document (e.g. ``Contrat joueur``).
|
||||||
|
kind: One of DOCUMENT_KINDS. The kind decides who completes the
|
||||||
|
document and whether completion is a signature or an
|
||||||
|
acknowledgment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
title = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.Length(min=1, max=255, error=_l('Title is required.')),
|
||||||
|
)
|
||||||
|
kind = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.OneOf(DOCUMENT_KINDS, error=_l('That document type is not recognised.')),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user