From abf2f0664287d08617943429d9708f0af39d1e84 Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Fri, 11 Sep 2026 09:51:48 -0400 Subject: [PATCH] premiere iteration de la gestion des documents --- app/google_drive.py | 71 +++++- app/models/__init__.py | 14 ++ app/models/document.py | 173 +++++++++++++++ app/routes/admin.py | 190 +++++++++++++++- app/routes/teams.py | 9 + app/routes/users/__init__.py | 1 + app/routes/users/documents.py | 210 ++++++++++++++++++ app/services/documents.py | 137 ++++++++++++ app/storage.py | 73 ++++++ app/templates/layouts/base.html | 6 + .../pages/admin_document_detail.html | 134 +++++++++++ app/templates/pages/admin_documents.html | 118 ++++++++++ app/templates/pages/my_documents.html | 173 +++++++++++++++ app/validators.py | 22 +- 14 files changed, 1326 insertions(+), 5 deletions(-) create mode 100644 app/models/document.py create mode 100644 app/routes/users/documents.py create mode 100644 app/services/documents.py create mode 100644 app/templates/pages/admin_document_detail.html create mode 100644 app/templates/pages/admin_documents.html create mode 100644 app/templates/pages/my_documents.html diff --git a/app/google_drive.py b/app/google_drive.py index e8a4626..829ecbc 100644 --- a/app/google_drive.py +++ b/app/google_drive.py @@ -28,6 +28,59 @@ def _setting(name): 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 ``//`` 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(): """Build an authorized Drive client from the owner's refresh token.""" try: @@ -86,8 +139,17 @@ def list_folder_files(): raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc -def upload_file(*, stream, filename, mimetype): - """Upload a PDF to the configured owner folder and return its Drive ID.""" +def upload_file(*, stream, filename, mimetype, parents=None): + """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: from googleapiclient.http import MediaIoBaseUpload @@ -97,7 +159,10 @@ def upload_file(*, stream, filename, mimetype): _drive_service() .files() .create( - body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]}, + body={ + 'name': filename, + 'parents': parents or [root_folder_id()], + }, media_body=media, fields='id', ) diff --git a/app/models/__init__.py b/app/models/__init__.py index e73a455..55c423e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -97,3 +97,17 @@ from app.models.one_on_one_request import OneOnOneRequest from app.models.admin_settings import AppSettings from app.models.backup_record import BackupRecord 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, +) diff --git a/app/models/document.py b/app/models/document.py new file mode 100644 index 0000000..120a96a --- /dev/null +++ b/app/models/document.py @@ -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', + backref='assigned_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://`` 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', 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') diff --git a/app/routes/admin.py b/app/routes/admin.py index 88d3ebc..5dfdbee 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -6,6 +6,7 @@ to administrators. """ import os +import uuid from datetime import datetime from flask import ( @@ -17,14 +18,23 @@ from flask import ( send_file, url_for, ) +from flask_babel import gettext as _ from flask_login import current_user, login_required +from marshmallow import ValidationError +from werkzeug.utils import secure_filename from app.extensions import db +from app.forms import flash_validation_errors from app.models import ( + DOCUMENT_KIND_ROLES, + DOCUMENT_KINDS, Admin, AppSettings, AuditLog, BackupRecord, + Document, + DocumentAssignment, + DocumentVersion, OrgTeam, TeamMatch, TeamMatchParticipant, @@ -33,12 +43,16 @@ from app.models import ( User, ) from app.responders import respond_after_change +from app.routes.users._shared import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error +from app.services.documents import backfill_assignments_for_document +from app.storage import GoogleDriveStorageError, open_document, store_master_document from app.supporting_scripts.backup import ( BackupError, backup_database, create_backup_dir, parse_database_url, ) +from app.validators import DocumentCreateSchema admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -211,7 +225,7 @@ def restore_backup(backup_id): # Create a safety backup of the current state before restoring. try: - safety = _create_backup_record( + _create_backup_record( backup_type='pre_restore', notes='Automatic safety backup before restoring ' + record.filename, ) @@ -423,3 +437,177 @@ def wipe_teams(): 'success', ) return respond_after_change(url_for('admin.dashboard')) + + +# --------------------------------------------------------------------------- +# Documents (canonical document store) +# --------------------------------------------------------------------------- + + +@admin_bp.route('/documents') +@login_required +def documents(): + """List every document with its published flag and completion counts.""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + documents = Document.query.order_by(Document.kind, Document.title).all() + return render_template( + 'pages/admin_documents.html', + documents=documents, + kinds=DOCUMENT_KINDS, + kind_roles=DOCUMENT_KIND_ROLES, + ) + + +@admin_bp.route('/documents/create', methods=['POST']) +@login_required +def create_document(): + """Create a new canonical document (no file yet; publish one next).""" + if not require_admin(): + return redirect(url_for('main.dashboard')) + + try: + data = DocumentCreateSchema().load(request.form) + except ValidationError as err: + flash_validation_errors(err) + return respond_after_change(url_for('admin.documents')) + + document = Document( + title=data['title'], + kind=data['kind'], + created_by=current_user.id, + ) + db.session.add(document) + db.session.commit() + + _log('document_created', f'{data["kind"]}: {data["title"]}') + flash(_('Document "%(title)s" created.', title=data['title']), 'success') + return respond_after_change(url_for('admin.documents')) + + +@admin_bp.route('/documents//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//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//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/') +@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, + ) diff --git a/app/routes/teams.py b/app/routes/teams.py index 179445c..e439b49 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -28,6 +28,7 @@ from app.models import ( ) from app.permissions import visible_org_teams from app.responders import respond_after_change +from app.services.documents import backfill_assignments_for_user from app.time_utils import utc_now_naive from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema @@ -215,6 +216,9 @@ def create_team(): team.managers.append(manager) db.session.commit() + for staff_user in (coach, manager): + if staff_user: + backfill_assignments_for_user(staff_user) flash(_('Team "%(name)s" created successfully!', name=name), 'success') return respond_after_change(url_for('teams.list_teams')) @@ -269,6 +273,8 @@ def edit_team(team_id): team.managers.append(manager) db.session.commit() + for staff_user in team.get_coaches() + team.get_managers(): + backfill_assignments_for_user(staff_user) flash(_('Team "%(name)s" updated successfully!', name=name), 'success') return respond_after_change(url_for('teams.list_teams')) @@ -368,6 +374,7 @@ def add_coach(team_id): if not team.coach_id: team.coach_id = coach.id db.session.commit() + backfill_assignments_for_user(coach) flash( _('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), 'success', @@ -411,6 +418,7 @@ def add_manager(team_id): if not team.manager_id: team.manager_id = manager.id db.session.commit() + backfill_assignments_for_user(manager) flash( _('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), 'success', @@ -507,6 +515,7 @@ def add_player(team_id): tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status) db.session.add(tp) db.session.commit() + backfill_assignments_for_user(player) flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success') return respond_after_change(url_for('teams.list_teams')) diff --git a/app/routes/users/__init__.py b/app/routes/users/__init__.py index 7fb7bbd..2f240a3 100644 --- a/app/routes/users/__init__.py +++ b/app/routes/users/__init__.py @@ -15,6 +15,7 @@ from app.routes.users import ( accounts, # noqa: F401,E402 availability, # noqa: F401,E402 contracts, # noqa: F401,E402 + documents, # noqa: F401,E402 notes, # noqa: F401,E402 one_on_one, # noqa: F401,E402 profile, # noqa: F401,E402 diff --git a/app/routes/users/documents.py b/app/routes/users/documents.py new file mode 100644 index 0000000..00f1edf --- /dev/null +++ b/app/routes/users/documents.py @@ -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//sign', methods=['POST']) +@login_required +def sign_document(assignment_id): + """Upload the signed copy of a pending signature document.""" + assignment = db.get_or_404(DocumentAssignment, assignment_id) + if assignment.user_id != current_user.id: + flash(_('You do not have permission to sign this document.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + if assignment.completed: + flash(_('This document has already been completed.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + if not assignment.document.requires_signature: + flash(_('This document is acknowledged, not signed.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + if not assignment.document.current_version: + flash(_('This document has no version published yet.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + file = request.files.get('signed_file') + error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) + if error: + flash(error, 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + team = primary_team_for_user(current_user) + if team is None: + flash(_('You must be part of a team before signing documents.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + version = assignment.document.current_version + # The kind prefix keeps separate documents from colliding: a player signs + # both the player contract and the team agreement, each at version 1. + signed_filename = f'{assignment.document.kind}-v{version.version}.pdf' + try: + stored_path = store_signed_document( + file, + team_name=team.name, + role=current_user.role, + username=current_user.username, + filename=signed_filename, + ) + except GoogleDriveStorageError: + flash( + _('Document storage is temporarily unavailable. Please try again later.'), + 'danger', + ) + return respond_after_change(url_for('users.list_my_documents')) + + assignment.status = 'signed' + assignment.signed_filename = signed_filename + assignment.signed_file_path = stored_path + assignment.completed_version_id = version.id + assignment.completed_at = utc_now_naive() + assignment.filed_team_id = team.id + db.session.commit() + + flash(_('Document signed successfully!'), 'success') + return respond_after_change(url_for('users.list_my_documents')) + + +@users_bp.route('/documents/assignments//acknowledge', methods=['POST']) +@login_required +def acknowledge_document(assignment_id): + """Mark a read-and-acknowledge document (code of conduct, ethics).""" + assignment = db.get_or_404(DocumentAssignment, assignment_id) + if assignment.user_id != current_user.id: + flash(_('You do not have permission to acknowledge this document.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + if assignment.completed: + flash(_('This document has already been completed.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + if assignment.document.requires_signature: + flash(_('This document must be signed, not acknowledged.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + if not assignment.document.current_version: + flash(_('This document has no version published yet.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + assignment.status = 'acknowledged' + assignment.completed_version_id = assignment.document.current_version.id + assignment.completed_at = utc_now_naive() + db.session.commit() + + flash(_('Document acknowledged.'), 'success') + return respond_after_change(url_for('users.list_my_documents')) + + +@users_bp.route('/documents/version//download') +@login_required +def download_document(version_id): + """Download the canonical file, gated on the admin's published flag.""" + version = db.get_or_404(DocumentVersion, version_id) + allowed = isinstance(current_user, Admin) or ( + version.document.is_published + and user_eligible_for_kind(current_user, version.document.kind) + ) + if not allowed: + flash(_('This document is not available to you.'), 'danger') + return respond_after_change(url_for('users.list_my_documents')) + + try: + file_object = open_document(version.file_path) + except GoogleDriveStorageError: + flash( + _('Document storage is temporarily unavailable. Please try again later.'), + 'danger', + ) + return respond_after_change(url_for('users.list_my_documents')) + return send_file(file_object, as_attachment=True, download_name=version.original_filename) diff --git a/app/services/documents.py b/app/services/documents.py new file mode 100644 index 0000000..3fbd6fd --- /dev/null +++ b/app/services/documents.py @@ -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_team_ids, manager_org_team_ids + + if isinstance(user, Player): + return [tp.org_team_id for tp in user.team_placements] + if isinstance(user, Coach): + return coach_org_team_ids(user) + if isinstance(user, Manager): + return manager_org_team_ids(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 diff --git a/app/storage.py b/app/storage.py index c2b1cea..6bf3ce1 100644 --- a/app/storage.py +++ b/app/storage.py @@ -41,6 +41,12 @@ from app.google_drive import ( from app.google_drive import ( 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 ( 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}' +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////``. + + 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): """Return a filesystem path or in-memory stream suitable for ``send_file``.""" if is_google_drive_path(stored_path): diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index c71dc7d..9b1ccca 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -113,6 +113,12 @@ {% endif %} +
  • + + + {{ _('Documents') }} + +
  • diff --git a/app/templates/pages/admin_document_detail.html b/app/templates/pages/admin_document_detail.html new file mode 100644 index 0000000..995d811 --- /dev/null +++ b/app/templates/pages/admin_document_detail.html @@ -0,0 +1,134 @@ +{% extends "layouts/base.html" %} +{% block title %}{{ document.title }} - Admin{% endblock %} +{% block page_title %}{{ document.title }}{% endblock %} +{% block breadcrumb %}Home / Admin / {{ _('Documents') }} / {{ document.title }}{% 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'), +} %} + +
    +
    +

    {{ _('Publish a new version') }}

    +
    +
    +

    + {{ _('Type') }}: {{ kind_labels.get(document.kind, document.kind) }} + {% if document.requires_signature %} · {{ _('signed by the member') }} + {% else %} · {{ _('acknowledged by the member') }}{% endif %} +

    +
    + +
    + + + {{ _('Accepted format: PDF') }} +
    +
    + + +
    +
    + {{ _('Back') }} + +
    +
    +
    +
    + +
    +
    +

    {{ _('Versions') }}

    +
    +
    +
    + + + + + + + + + + + + {% for version in versions %} + + + + + + + + {% else %} + + + + {% endfor %} + +
    {{ _('Version') }}{{ _('File') }}{{ _('Notes') }}{{ _('Published') }}{{ _('Actions') }}
    + {{ _('v%(number)s', number=version.version) }} + {% if document.current_version_id == version.id %} + {{ _('Current') }}{% endif %} + {{ version.original_filename }}{{ version.notes or '—' }}{{ version.published_at.strftime('%Y-%m-%d %H:%M') if version.published_at else '—' }} + + + +
    {{ _('No versions published yet.') }}
    +
    +
    +
    + +
    +
    +

    {{ _('Completion') }}

    +
    +
    +
    + + + + + + + + + + + {% for assignment, user in assignments %} + + + + + + + {% else %} + + + + {% endfor %} + +
    {{ _('Member') }}{{ _('Role') }}{{ _('Status') }}{{ _('Completed at') }}
    {{ user.username }}{{ user.role | capitalize }} + {% if assignment.status == 'signed' %} + {{ _('Signed') }} + {% elif assignment.status == 'acknowledged' %} + {{ _('Acknowledged') }} + {% else %} + {{ _('Pending') }} + {% endif %} + {{ assignment.completed_at.strftime('%Y-%m-%d %H:%M') if assignment.completed_at else '—' }}
    + {{ _('No one is assigned yet. Publish a version first.') }} +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/pages/admin_documents.html b/app/templates/pages/admin_documents.html new file mode 100644 index 0000000..9d128bc --- /dev/null +++ b/app/templates/pages/admin_documents.html @@ -0,0 +1,118 @@ +{% extends "layouts/base.html" %} +{% block title %}{{ _('Documents') }} - Admin{% endblock %} +{% block page_title %}{{ _('Document management') }}{% endblock %} +{% block breadcrumb %}Home / Admin / {{ _('Documents') }}{% 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'), +} %} + +
    +
    +

    {{ _('Create a document') }}

    +
    +
    +
    + +
    + + +
    +
    + + + + {{ _('The type decides who must complete the document and how.') }} + +
    +
    + +
    +
    +
    +
    + +
    +
    +

    {{ _('Existing documents') }}

    +
    +
    +
    + + + + + + + + + + + + + {% for document in documents %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
    {{ _('Title') }}{{ _('Type') }}{{ _('Version') }}{{ _('Availability') }}{{ _('Progress') }}{{ _('Actions') }}
    {{ document.title }}{{ kind_labels.get(document.kind, document.kind) }} + {% if document.current_version %} + {{ _('v%(number)s', number=document.current_version.version) }} + {% else %}{{ _('None') }}{% endif %} + + {% if document.is_published %} + {{ _('Downloadable') }} + {% else %} + {{ _('Hidden') }} + {% endif %} + + {% set completed, pending = document.completion_counts() %} + {{ _('%(completed)s done', completed=completed) }} + {{ _('%(pending)s pending', pending=pending) }} + +
    + + {{ _('Manage') }} + +
    + + {% if document.is_published %} + + {% else %} + + {% endif %} +
    +
    +
    {{ _('No documents yet. Create one above.') }}
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/pages/my_documents.html b/app/templates/pages/my_documents.html new file mode 100644 index 0000000..bd8c5ab --- /dev/null +++ b/app/templates/pages/my_documents.html @@ -0,0 +1,173 @@ +{% extends "layouts/base.html" %} +{% block title %}{{ _('My Documents') }} - UdeS team manager{% endblock %} +{% block page_title %}{{ _('My Documents') }}{% endblock %} +{% block breadcrumb %}Home / Profile / {{ _('My Documents') }}{% 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'), +} %} + +
    +
    +

    {{ _('Documents to sign or acknowledge') }}

    +
    +
    + {% if documents %} +
    + + + + + + + + + + + + {% for entry in documents %} + + + + + + + + {% endfor %} + +
    {{ _('Document') }}{{ _('Type') }}{{ _('Version') }}{{ _('Status') }}{{ _('Actions') }}
    {{ entry.document.title }}{{ kind_labels.get(entry.document.kind, entry.document.kind) }} + {% if entry.document.current_version %} + {{ _('Version %(number)s', number=entry.document.current_version.version) }} + {% else %}—{% endif %} + + {% set assignment = entry.assignment %} + {% if assignment is not none and assignment.status == 'signed' %} + {{ _('Signed') }} + {% elif assignment is not none and assignment.status == 'acknowledged' %} + {{ _('Acknowledged') }} + {% else %} + {{ _('Pending') }} + {% endif %} + +
    + {% if entry.document.current_version %} + + {{ _('Download') }} + + {% endif %} + {% if assignment is not none and assignment.status == 'pending' %} + {% if entry.document.requires_signature %} + + {% else %} +
    + + +
    + {% endif %} + {% endif %} +
    +
    +
    + {% elif not teams_progress %} +
    + +

    {{ _('No documents for now') }}

    +

    {{ _('Nothing has been published for you yet. Check back later.') }}

    +
    + {% endif %} +
    +
    + +{% if teams_progress %} +{% for block in teams_progress %} +
    +
    +

    {{ _('Team progress') }} — {{ block.team.name }}

    +
    +
    +
    + + + + + + {% for doc in block.documents %}{% endfor %} + + + + {% for member in block.members %} + + + + {% for doc in block.documents %} + {% set status = member.statuses.get(doc.id) %} + + {% endfor %} + + {% endfor %} + +
    {{ _('Member') }}{{ _('Role') }}{{ doc.title }}
    {{ member.user.username }}{{ member.user.role | capitalize }} + {% if status == 'signed' %}{{ _('Signed') }} + {% elif status == 'acknowledged' %}{{ _('Acknowledged') }} + {% elif status == 'pending' %}{{ _('Pending') }} + {% else %}{{ _('Not required') }}{% endif %} +
    +
    +
    +
    +{% endfor %} +{% endif %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/app/validators.py b/app/validators.py index 70eba91..3d4fcba 100644 --- a/app/validators.py +++ b/app/validators.py @@ -23,7 +23,7 @@ from marshmallow import ( 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 @@ -1061,3 +1061,23 @@ class EvaluationSchema(StripMixin): allow_none=True, 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.')), + )