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