Author SHA1 Message Date
cedrick2711 5249331661 test docu, non fonctionnel pour l'instant
CI - Security, Lint & Tests / validate (push) Failing after 1m7s
2026-09-15 12:28:12 -04:00
cedrick2711 abf2f06642 premiere iteration de la gestion des documents
CI - Security, Lint & Tests / validate (push) Failing after 1m20s
2026-09-11 09:51:48 -04:00
18 changed files with 2983 additions and 648 deletions
+68 -3
View File
@@ -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',
) )
+14
View File
@@ -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,
)
+173
View File
@@ -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
View File
@@ -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,
)
+9
View File
@@ -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'))
+1
View File
@@ -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
+210
View File
@@ -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)
+137
View File
@@ -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
+73
View File
@@ -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):
+6
View File
@@ -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 %}
+118
View File
@@ -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 %}
+173
View File
@@ -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">&times;</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
View File
@@ -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.')),
)