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
31 changed files with 2997 additions and 1443 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')
+2 -9
View File
@@ -61,14 +61,7 @@ class OrgTeam(db.Model):
return [tp.player for tp in self.team_players] return [tp.player for tp in self.team_players]
def get_players_with_status(self): def get_players_with_status(self):
rows = [ return [
{ {'player': tp.player, 'status': tp.status, 'position': tp.position}
'player': tp.player,
'status': tp.status,
'position': tp.position,
'is_team_representative': tp.is_team_representative,
}
for tp in self.team_players for tp in self.team_players
] ]
# The responsible heads the roster; everyone else keeps roster order.
return sorted(rows, key=lambda row: not row['is_team_representative'])
-4
View File
@@ -12,10 +12,6 @@ class TeamPlayer(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter') status = db.Column(db.String(20), nullable=False, default='starter')
#: A player an admin named as their team's responsible ("joueur
#: responsable") — see app/permissions.py. Grants match-management
#: rights for that one team and nothing else.
is_team_representative = db.Column(db.Boolean, nullable=False, default=False)
position = db.Column(db.String(50), nullable=True) position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=utc_now_naive) added_at = db.Column(db.DateTime, default=utc_now_naive)
-3
View File
@@ -29,9 +29,6 @@ class Admin(User):
def can_manage_this_org_team(self, org_team): def can_manage_this_org_team(self, org_team):
return True return True
def can_designate_team_representative(self):
return True
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
-15
View File
@@ -103,20 +103,5 @@ class User(UserMixin, db.Model):
def can_manage_this_org_team(self, org_team): def can_manage_this_org_team(self, org_team):
return False return False
def can_manage_team_matches(self, org_team):
"""Whether the user may manage this team's matches and practices.
Broader than :meth:`can_manage_this_org_team`: a player named as the
team's responsible joins whoever administers it. The rule itself
lives in ``app.permissions``.
"""
from app.permissions import can_manage_team_matches
return can_manage_team_matches(self, org_team)
def can_designate_team_representative(self):
"""Whether the user may name a team's responsible. President only."""
return False
def get_visible_tryouts(self): def get_visible_tryouts(self):
return [] return []
-72
View File
@@ -158,78 +158,6 @@ def can_manage_org_team(user, org_team):
return bool(org_team) and user.can_manage_this_org_team(org_team) return bool(org_team) and user.can_manage_this_org_team(org_team)
# ---------------------------------------------------------------------------
# Team representatives ("joueurs responsables")
# ---------------------------------------------------------------------------
def is_team_representative(user, org_team):
"""Whether the user is the named responsible of this organisation team.
Args:
user: The acting user.
org_team: The team concerned.
Returns:
bool
"""
if not user or not org_team:
return False
from app.models import TeamPlayer
return (
TeamPlayer.query.filter_by(
player_id=user.id,
org_team_id=org_team.id,
is_team_representative=True,
).first()
is not None
)
def representative_org_teams(user):
"""Organisation teams the user is the responsible of, ordered by name.
Args:
user: The acting user.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import OrgTeam, TeamPlayer
if not user:
return []
return (
OrgTeam.query.join(TeamPlayer, TeamPlayer.org_team_id == OrgTeam.id)
.filter(
TeamPlayer.player_id == user.id,
TeamPlayer.is_team_representative.is_(True),
)
.order_by(OrgTeam.name)
.all()
)
def can_manage_team_matches(user, org_team):
"""Whether the user may manage this team's matches and practices.
The staff rule (see :func:`can_manage_org_team`) *plus* the player named
as the team's responsible. Deliberately narrower than
``can_manage_org_team``: a responsible schedules and edits matches, but
does not administer the roster.
Args:
user: The acting user.
org_team: The team concerned.
Returns:
bool
"""
return can_manage_org_team(user, org_team) or is_team_representative(user, org_team)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Reach over players # Reach over players
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+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,
)
+7 -8
View File
@@ -23,7 +23,7 @@ from app.models import (
TeamPlayer, TeamPlayer,
) )
from app.pagination import paginate from app.pagination import paginate
from app.permissions import coach_org_teams, representative_org_teams, visible_org_teams from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.responders import respond_after_change from app.responders import respond_after_change
from app.routes.matches import default_end_time from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
@@ -36,11 +36,10 @@ team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches'
def can_manage_team_match(team): def can_manage_team_match(team):
"""Whether the current user can manage matches for this team. """Whether the current user can manage matches for this team.
Everyone who administers the team, plus the player named as its Same rule as administering the team itself, so it is the same call.
responsible. Narrower than administering the team itself: a responsible This function used to restate it, and the restatement drifted.
schedules and edits matches, but does not touch the roster.
""" """
return current_user.can_manage_team_matches(team) return can_manage_org_team(current_user, team)
def season_locked(): def season_locked():
@@ -301,13 +300,13 @@ def delete_match(match_id):
@login_required @login_required
def api_manageable_teams(): def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for.""" """API endpoint returning teams the current user can schedule matches for."""
if not current_user.can_schedule_matches():
return jsonify([])
if isinstance(current_user, (Admin, Manager)): if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all() teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
teams = coach_org_teams(current_user) teams = coach_org_teams(current_user)
elif isinstance(current_user, Player):
# A responsible schedules matches for their own team(s) only.
teams = representative_org_teams(current_user)
else: else:
return jsonify([]) return jsonify([])
+12 -35
View File
@@ -26,8 +26,9 @@ from app.models import (
Tryout, Tryout,
User, User,
) )
from app.permissions import representative_org_teams, 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
@@ -40,12 +41,10 @@ def list_teams():
"""List all organization teams visible to the current user.""" """List all organization teams visible to the current user."""
can_manage = current_user.can_manage_teams() can_manage = current_user.can_manage_teams()
# A player reaches this page only as a team's responsible; anyone else if isinstance(current_user, Player):
# is sent to their own view.
if isinstance(current_user, Player) and not representative_org_teams(current_user):
flash(_('Use My Team(s) to view your teams.'), 'info') flash(_('Use My Team(s) to view your teams.'), 'info')
return redirect(url_for('teams.my_teams')) return redirect(url_for('teams.my_teams'))
if not isinstance(current_user, (Admin, Coach, Manager, Player)): if not isinstance(current_user, (Admin, Coach, Manager)):
flash(_('You do not have permission to view teams.'), 'danger') flash(_('You do not have permission to view teams.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@@ -217,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'))
@@ -271,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'))
@@ -370,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',
@@ -413,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',
@@ -509,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'))
@@ -565,36 +572,6 @@ def toggle_player_status(team_id, player_id):
) )
@teams_bp.route('/<int:team_id>/toggle_representative/<int:player_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_player_representative(team_id, player_id):
"""Name or un-name a player as their team's responsible (president only).
The responsible gains match-management rights for this one team. The
flag itself stays a president's decision: this is deliberately the one
roster action a responsible cannot perform on their own team.
"""
db.get_or_404(OrgTeam, team_id)
if not current_user.can_designate_team_representative():
return jsonify({'error': 'Permission denied'}), 403
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
return jsonify({'error': 'Player not found on this team'}), 404
tp.is_team_representative = not tp.is_team_representative
db.session.commit()
return jsonify(
{
'success': True,
'player_id': player_id,
'is_team_representative': tp.is_team_representative,
'player_name': tp.player.username,
}
)
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST']) @teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required @login_required
def add_team_note(team_id): def add_team_note(team_id):
+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 %}
-13
View File
@@ -10,16 +10,6 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-users"></i> {{ team.name }}</h3> <h3><i class="fas fa-users"></i> {{ team.name }}</h3>
<div class="card-actions">
{% if current_user.can_manage_team_matches(team) %}
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
<i class="fas fa-plus"></i> {{ _('Match') }}
</a>
<a href="{{ url_for('team_matches.create_match', team_id=team.id, type='practice') }}" class="btn btn-sm btn-info" title="{{ _('Schedule Practice') }}">
<i class="fas fa-dumbbell"></i> {{ _('Practice') }}
</a>
{% endif %}
</div>
</div> </div>
<!-- Staff bar --> <!-- Staff bar -->
<div class="team-staff-bar"> <div class="team-staff-bar">
@@ -71,9 +61,6 @@
<div class="user-mini"> <div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div> <div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a> <a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
{% if entry.is_team_representative %}
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
{% endif %}
</div> </div>
</td> </td>
<td> <td>
+1 -1
View File
@@ -93,7 +93,7 @@
<span class="badge badge-{{ m.status }}">{{ m.status }}</span> <span class="badge badge-{{ m.status }}">{{ m.status }}</span>
</td> </td>
<td class="eval-actions"> <td class="eval-actions">
{% set can_manage_this = current_user.can_manage_team_matches(m.org_team) %} {% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
{% if can_manage_this %} {% if can_manage_this %}
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}"> <a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
+1 -44
View File
@@ -127,7 +127,7 @@
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="{{ _('View Team Matches') }}"> <a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="{{ _('View Team Matches') }}">
<i class="fas fa-futbol"></i> {{ _('Matches') }} <i class="fas fa-futbol"></i> {{ _('Matches') }}
</a> </a>
{% if current_user.can_manage_team_matches(team) %} {% if current_user.can_schedule_matches() and (current_user.role in ['admin', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %}
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}"> <a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
<i class="fas fa-plus"></i> {{ _('Match') }} <i class="fas fa-plus"></i> {{ _('Match') }}
</a> </a>
@@ -159,9 +159,6 @@
<div class="user-mini"> <div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div> <div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a> <a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
{% if entry.is_team_representative %}
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
{% endif %}
</div> </div>
</td> </td>
<td> <td>
@@ -183,16 +180,6 @@
<td>{{ entry.player.phone or '-' }}</td> <td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %} {% if can_manage_team %}
<td> <td>
{% if current_user.can_designate_team_representative() %}
<button type="button"
class="btn btn-sm representative-toggle-btn {% if entry.is_team_representative %}btn-warning{% else %}btn-outline{% endif %}"
data-team-id="{{ team.id }}"
data-player-id="{{ entry.player.id }}"
data-action="toggle-representative"
title="{{ _('Name as team representative') }}">
<i class="fas fa-star"></i>
</button>
{% endif %}
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}"> <form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger"> <button type="submit" class="btn btn-sm btn-danger">
@@ -354,35 +341,6 @@ function toggleStatus(btn) {
}); });
} }
function toggleRepresentative(btn) {
var teamId = btn.getAttribute('data-team-id');
var playerId = btn.getAttribute('data-player-id');
fetch('/teams/' + teamId + '/toggle_representative/' + playerId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
if (data.is_team_representative) {
btn.classList.remove('btn-outline');
btn.classList.add('btn-warning');
} else {
btn.classList.remove('btn-warning');
btn.classList.add('btn-outline');
}
window.TT.refreshPage(window.location.pathname);
}
})
.catch(function(error) {
console.error('Error:', error);
});
}
var currentEditTeamId = null; var currentEditTeamId = null;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -458,7 +416,6 @@ registerActions({
'hide-create-form': hideCreateForm, 'hide-create-form': hideCreateForm,
'hide-edit-form': hideEditForm, 'hide-edit-form': hideEditForm,
'toggle-status': toggleStatus, 'toggle-status': toggleStatus,
'toggle-representative': toggleRepresentative,
}); });
</script> </script>
<style> <style>
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.')),
)
-15
View File
@@ -128,18 +128,3 @@ Générer la migration initiale depuis les modèles « pour avancer en
attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une
production estampillée sur un état qu'elle n'a pas, et la dérive préservée production estampillée sur un état qu'elle n'a pas, et la dérive préservée
sous une couche de plus. sous une couche de plus.
## Ajouts de colonnes faits à la main
En attendant Alembic, une colonne ajoutée à un modèle doit être rattrapée sur
les bases existantes par un script, en plus du modèle (pour les installations
neuves, via `create_all()`). `migrations/add_tryout_coaches.py` a ouvert la
voie ; `migrations/add_team_representative.py` ajoute
`team_players.is_team_representative` de la même façon : lecture du catalogue,
`ALTER TABLE ... ADD COLUMN` idempotent, rien si la colonne est déjà là.
Le script trouve sa base tout seul : `DATABASE_URL` s'il est défini (production,
CI), sinon les valeurs `LOCAL_PG*` de `app/.env` — le serveur de développement
est réutilisé s'il répond, sinon (re)démarré puis arrêté à la fin du script :
python migrations/add_team_representative.py
-28
View File
@@ -64,31 +64,6 @@ voulue, elle n'est écrite nulle part ailleurs qu'ici.
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée | | Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
| Recruteur, Joueur | toujours faux | | Recruteur, Joueur | toujours faux |
### `can_manage_team_matches(team)`
Plus étroite que `can_manage_this_org_team`, et c'est voulu : elle répond à
« peut-on gérer les matchs et les entraînements de cette équipe ? ».
| Rôle | Règle |
|---|---|
| Président, Gérant | toujours vrai |
| Coach | rattaché à l'équipe (m2m **ou** colonne héritée) |
| Joueur responsable | vrai pour **sa seule** équipe |
| Recruteur, Joueur | faux |
### Le joueur responsable
Un **joueur responsable** est un joueur qu'un **président** désigne, depuis la
page Équipes, sur la ligne de son équipe (étoile à côté du bouton de retrait).
Il peut planifier, modifier et supprimer les **matchs et entraînements** de
cette équipe, et rien d'autre : ni l'effectif, ni les notes, ni le statut
partant/remplaçant, qui restent réservés au personnel.
Le drapeau vit sur `team_players.is_team_representative` (voir
`docs/database-schema.md`). La désignation est la décision du président —
`can_designate_team_representative()`, vrai seulement pour `Admin` — pour
qu'un responsable ne puisse pas se reproduire lui-même.
## Le piège : deux façons d'être rattaché ## Le piège : deux façons d'être rattaché
C'est le point le plus coûteux de tout le modèle d'autorisation, et il est C'est le point le plus coûteux de tout le modèle d'autorisation, et il est
@@ -142,9 +117,6 @@ contexte de requête.
| `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle | | `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle |
| `visible_org_teams(user)` | Ce qu'elle a le droit de voir | | `visible_org_teams(user)` | Ce qu'elle a le droit de voir |
| `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe | | `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe |
| `can_manage_team_matches(user, team)` | Peut-elle gérer les matchs et entraînements de cette équipe |
| `is_team_representative(user, team)` | Est-elle le responsable désigné de cette équipe |
| `representative_org_teams(user)` | Les équipes dont elle est le responsable |
| `coach_player_ids(coach)` | Les joueurs qu'il encadre | | `coach_player_ids(coach)` | Les joueurs qu'il encadre |
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur | | `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui | | `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
-176
View File
@@ -1,176 +0,0 @@
"""Migration: add the `is_team_representative` flag to `team_players`.
A team's responsible ("joueur responsable") is a player an admin names to
manage that team's matches and practices. The flag lives on the roster row,
beside `status` and `position`.
`db.create_all()` creates missing tables and never alters an existing one, so
a database that already has `team_players` needs this script. Fresh installs
get the column from the model.
Where it connects
-----------------
The script finds its own database, so there is no URL to remember:
1. If `DATABASE_URL` is already set, it is used and nothing else is touched.
That is the production and CI path.
2. Otherwise the local values from `app/.env` are used. If the development
server in `instance/pgdata/` answers a connection it is reused as-is; if it
does not, it is (re)started and stopped again when this script exits. This
is the `LOCAL_PG=true` block of `app/.env.example`.
3. If neither is available the script explains what to set and stops.
So a laptop with the `LOCAL_PG=true` block runs the migration with a single
command:
python migrations/add_team_representative.py
Usage: python migrations/add_team_representative.py
"""
import os
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
# Loaded before anything else reads the environment: app.local_postgres reads
# LOCAL_PG_PORT / LOCAL_PG_USER / LOCAL_PG_DATABASE at import time, and without
# this they would fall back to that module's defaults instead of app/.env.
from dotenv import load_dotenv
_ENV_FILE = os.path.join(PROJECT_ROOT, 'app', '.env')
load_dotenv(_ENV_FILE if os.path.exists(_ENV_FILE) else None)
from sqlalchemy import text
from app.extensions import db
def _local_database():
"""The development URL and port, read from the LOCAL_PG_* variables."""
from app.local_postgres import DATABASE_URL, PORT
return DATABASE_URL, PORT
def _postgres_answers(url):
"""Whether `url` is a working PostgreSQL connection right now.
A bare TCP probe is not enough here: a server killed part-way through its
startup can leave the port listening while every connection is refused,
and trusting that evidence fails the migration for a reason that looks
nothing like its cause.
"""
from sqlalchemy import create_engine
from app.app import normalise_database_url
try:
engine = create_engine(
normalise_database_url(url), connect_args={'connect_timeout': 2}
)
except Exception:
return False
try:
with engine.connect():
return True
except Exception:
return False
finally:
engine.dispose()
def _stop_local_server():
"""Fast-stop the local development server, if a postmaster is alive.
Called only before (re)starting it: `pg_ctl status` calls a half-dead
postmaster "running", so `start()` would leave the broken one in place.
"""
from app.local_postgres import DATADIR, _find_binary
pg_ctl = _find_binary('pg_ctl')
if not pg_ctl:
return
subprocess.run(
[str(pg_ctl), 'stop', '-m', 'fast', '-D', str(DATADIR)],
capture_output=True,
text=True,
)
def resolve_database_url():
"""Pick the database to migrate.
Returns:
tuple[str, str]: the connection URL and a note saying where it came
from, for the log line.
Raises:
SystemExit: when neither a URL nor a usable local server is available.
"""
explicit = os.environ.get('DATABASE_URL')
if explicit:
return explicit, 'DATABASE_URL'
url, port = _local_database()
if _postgres_answers(url):
return url, f'local PostgreSQL already running on port {port}'
if os.getenv('LOCAL_PG', '').strip().lower() == 'true':
from app.local_postgres import start
_stop_local_server()
return start(), f'local PostgreSQL started on port {port}'
raise SystemExit(
'No database to migrate.\n'
' Set DATABASE_URL to a connection string, or set LOCAL_PG=true in\n'
' app/.env to use the local development server (see app/.env.example,\n'
' DEVELOPMENT block).'
)
def main():
# A migration must never start the Discord bot: two instances fight over
# the same gateway session, and neither belongs to a script whose only job
# is to run an ALTER.
os.environ['ENABLE_DISCORD_BOT'] = 'false'
url, source = resolve_database_url()
os.environ['DATABASE_URL'] = url
# Imported here, after the environment is settled, so create_app() reads
# the URL this script resolved rather than a missing one.
from app.app import create_app
print(f'[INFO] Database: {source}.')
app = create_app()
with app.app_context():
already_there = db.session.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'team_players' "
"AND column_name = 'is_team_representative'"
)
).scalar()
if already_there:
print("Column 'team_players.is_team_representative' already exists. Skipping.")
return
db.session.execute(
text(
"ALTER TABLE team_players "
"ADD COLUMN is_team_representative BOOLEAN NOT NULL DEFAULT FALSE"
)
)
db.session.commit()
print("Added 'team_players.is_team_representative'.")
if __name__ == '__main__':
main()
-348
View File
@@ -1,348 +0,0 @@
"""Team representative ("joueur responsable") — the rule and the routes.
A player an admin names as their team's responsible may create, edit and
delete that team's matches and practices, and nothing else. Designation
itself is the president's decision, never the responsible's.
"""
from datetime import date, time
import pytest
from app.extensions import db
from app.models import AppSettings, OrgTeam, TeamMatch, TeamPlayer, User
from app.permissions import (
can_manage_team_matches,
is_team_representative,
representative_org_teams,
)
def _redirected(response):
return response.status_code in (301, 302)
def _build_world(app, make_user):
"""Two teams, a responsible on the first, a plain player on it too."""
with app.app_context():
admin_id = make_user('admin')
rep_id = make_user('player')
plain_id = make_user('player')
team = OrgTeam(name='Varsity', created_by=admin_id)
other = OrgTeam(name='JV', created_by=admin_id)
db.session.add_all([team, other])
db.session.flush()
# Added plain-first on purpose: the roster must still show the
# responsible at the top, not last, and a test whose world added the
# responsible first would pass without the ordering ever running.
db.session.add_all(
[
TeamPlayer(player_id=plain_id, org_team_id=team.id),
TeamPlayer(player_id=rep_id, org_team_id=team.id, is_team_representative=True),
]
)
db.session.commit()
# create/delete matches are gated on the season for non-presidents,
# and the responsible is one of them.
AppSettings.set_bool('season_active', True)
return {
'admin': admin_id,
'rep': rep_id,
'plain': plain_id,
'team': team.id,
'other': other.id,
'rep_username': db.session.get(User, rep_id).username,
'plain_username': db.session.get(User, plain_id).username,
}
def _make_match(app, world, title, team_key='team'):
with app.app_context():
match = TeamMatch(
org_team_id=world[team_key],
title=title,
date=date(2026, 10, 1),
start_time=time(10, 0),
end_time=time(11, 0),
created_by=world['admin'],
)
db.session.add(match)
db.session.commit()
return match.id
def _login_as(client, login, username):
"""Switch the test client to another existing account."""
client.post('/auth/logout', follow_redirects=False)
response = login(username)
assert _redirected(response), f'could not log in as {username}'
class TestTheRule:
"""The permission itself, without a request in the way."""
def test_a_rep_manages_their_team(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
team = db.session.get(OrgTeam, world['team'])
assert is_team_representative(rep, team) is True
assert can_manage_team_matches(rep, team) is True
assert rep.can_manage_team_matches(team) is True
def test_a_rep_manages_no_other_team(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
other = db.session.get(OrgTeam, world['other'])
assert is_team_representative(rep, other) is False
assert can_manage_team_matches(rep, other) is False
def test_a_rep_is_not_a_roster_manager(self, app, make_user):
"""Match rights only: the roster stays staff-only."""
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
team = db.session.get(OrgTeam, world['team'])
assert rep.can_manage_this_org_team(team) is False
assert rep.can_manage_teams() is False
assert rep.can_schedule_matches() is False
def test_a_plain_player_manages_nothing(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
plain = db.session.get(User, world['plain'])
team = db.session.get(OrgTeam, world['team'])
assert can_manage_team_matches(plain, team) is False
def test_representative_org_teams_is_scoped(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
rep = db.session.get(User, world['rep'])
assert [team.id for team in representative_org_teams(rep)] == [world['team']]
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
def test_only_the_president_may_designate(self, app, make_user, role):
with app.app_context():
user = db.session.get(User, make_user(role))
assert user.can_designate_team_representative() == (role == 'admin')
class TestMatchManagement:
"""The routes the feature exists for: matches and practices."""
def test_a_rep_schedules_a_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(
f'/team-matches/{world["team"]}/create',
data={'title': 'Rep Match', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
assert TeamMatch.query.filter_by(title='Rep Match').count() == 1
def test_a_rep_schedules_a_practice(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(
f'/team-matches/{world["team"]}/create?type=practice',
data={'title': 'Evening Scrim', 'date': '2026-10-02', 'start_time': '18:00'},
follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
match = TeamMatch.query.filter_by(title='Evening Scrim').first()
assert match is not None
assert match.opponent is None
def test_a_rep_edits_a_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
match_id = _make_match(app, world, title='Before')
_login_as(client, login, world['rep_username'])
response = client.post(
f'/team-matches/{match_id}/edit',
data={'title': 'After', 'date': '2026-10-03', 'start_time': '12:00'},
follow_redirects=False,
)
assert _redirected(response)
with app.app_context():
assert db.session.get(TeamMatch, match_id).title == 'After'
def test_a_rep_deletes_a_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
match_id = _make_match(app, world, title='Doomed')
_login_as(client, login, world['rep_username'])
response = client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
assert _redirected(response)
with app.app_context():
assert db.session.get(TeamMatch, match_id) is None
def test_a_rep_does_not_schedule_for_another_team(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
client.post(
f'/team-matches/{world["other"]}/create',
data={'title': 'Sneaky', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
with app.app_context():
assert TeamMatch.query.filter_by(title='Sneaky').count() == 0
def test_a_rep_does_not_delete_another_teams_match(self, app, client, login, make_user):
world = _build_world(app, make_user)
match_id = _make_match(app, world, title='Elsewhere', team_key='other')
_login_as(client, login, world['rep_username'])
client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
with app.app_context():
assert db.session.get(TeamMatch, match_id) is not None
def test_a_plain_player_does_not_schedule(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['plain_username'])
client.post(
f'/team-matches/{world["team"]}/create',
data={'title': 'Nope', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
with app.app_context():
assert TeamMatch.query.filter_by(title='Nope').count() == 0
def test_a_rep_does_not_touch_the_roster(self, app, client, login, make_user):
"""Decision 3: matches only, never the roster."""
world = _build_world(app, make_user)
newcomer_id = make_user('player')
_login_as(client, login, world['rep_username'])
client.post(
f'/teams/{world["team"]}/add_player',
data={'player_id': str(newcomer_id), 'status': 'starter'},
follow_redirects=False,
)
with app.app_context():
added = TeamPlayer.query.filter_by(
player_id=newcomer_id, org_team_id=world['team']
).first()
assert added is None
def test_a_rep_does_not_toggle_a_roster_status(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(f'/teams/{world["team"]}/toggle_status/{world["plain"]}', json={})
assert response.status_code == 403
class TestDesignation:
"""Only the president names a responsible."""
@pytest.mark.parametrize('role', ['manager', 'coach', 'player', 'scout'])
def test_a_non_president_cannot_designate(self, app, client, as_role, make_user, role):
world = _build_world(app, make_user)
target_id = make_user('player')
as_role(role)
response = client.post(f'/teams/{world["team"]}/toggle_representative/{target_id}', json={})
assert response.status_code == 403
def test_the_president_designates_a_rep(self, app, client, as_role, make_user):
world = _build_world(app, make_user)
as_role('admin')
response = client.post(
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
)
assert response.status_code == 200
assert response.get_json()['is_team_representative'] is True
with app.app_context():
row = TeamPlayer.query.filter_by(
player_id=world['plain'], org_team_id=world['team']
).one()
assert row.is_team_representative is True
def test_a_rep_cannot_promote_another_player(self, app, client, login, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
response = client.post(
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
)
assert response.status_code == 403
with app.app_context():
row = TeamPlayer.query.filter_by(
player_id=world['plain'], org_team_id=world['team']
).one()
assert row.is_team_representative is False
class TestReachingThePage:
"""Reuse of the teams page, scoped (decision 4)."""
def test_a_plain_player_is_sent_to_their_own_view(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['plain_username'])
response = client.get('/teams', follow_redirects=False)
assert _redirected(response)
assert '/teams/my-teams' in response.headers['Location']
def test_a_rep_settles_on_the_teams_page(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
assert client.get('/teams').status_code == 200
def test_the_page_offers_matches_but_not_the_roster(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
html = client.get('/teams').get_data(as_text=True)
assert f'/team-matches/{world["team"]}/create' in html
assert f'/teams/{world["team"]}/add_player' not in html
def test_the_my_teams_page_offers_the_schedule_buttons(self, client, login, app, make_user):
"""A player's nav only has "My Team(s)", so the rep schedules there."""
world = _build_world(app, make_user)
_login_as(client, login, world['rep_username'])
html = client.get('/teams/my-teams').get_data(as_text=True)
assert f'/team-matches/{world["team"]}/create' in html
assert 'create?type=practice' in html
def test_the_my_teams_page_is_silent_for_a_plain_player(self, client, login, app, make_user):
world = _build_world(app, make_user)
_login_as(client, login, world['plain_username'])
html = client.get('/teams/my-teams').get_data(as_text=True)
assert f'/team-matches/{world["team"]}/create' not in html
class TestRosterOrder:
"""The responsible heads the roster, on both pages that render it."""
def test_the_responsible_is_listed_first(self, app, make_user):
world = _build_world(app, make_user)
with app.app_context():
team = db.session.get(OrgTeam, world['team'])
roster = team.get_players_with_status()
assert [row['player'].id for row in roster] == [world['rep'], world['plain']]
assert roster[0]['is_team_representative'] is True
assert roster[1]['is_team_representative'] is False