Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5249331661 | ||
|
|
abf2f06642 | ||
|
|
bd9ed4bdc7 | ||
|
|
a9b33b30f5 | ||
|
|
32c112ceea | ||
|
|
30069d782f | ||
|
|
d994d88c12 |
+68
-3
@@ -28,6 +28,59 @@ def _setting(name):
|
||||
return value
|
||||
|
||||
|
||||
def root_folder_id():
|
||||
"""The configured Drive folder that holds every application document.
|
||||
|
||||
This is the folder the owner set up (the ``signés`` folder); new
|
||||
documents land directly in it and signed copies are filed in nested
|
||||
folders below it.
|
||||
"""
|
||||
return _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
|
||||
|
||||
|
||||
def get_or_create_folder(name, parent_id):
|
||||
"""Return the Drive folder named *name* under *parent_id*, creating it.
|
||||
|
||||
Drive has no ``mkdir -p``; creating a folder is a write, so a signed
|
||||
copy filed under ``<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():
|
||||
"""Build an authorized Drive client from the owner's refresh token."""
|
||||
try:
|
||||
@@ -86,8 +139,17 @@ def list_folder_files():
|
||||
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
||||
|
||||
|
||||
def upload_file(*, stream, filename, mimetype):
|
||||
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
||||
def upload_file(*, stream, filename, mimetype, parents=None):
|
||||
"""Upload a PDF to the configured owner folder and return its Drive ID.
|
||||
|
||||
Args:
|
||||
stream: The binary stream to upload.
|
||||
filename: Name used for the uploaded file.
|
||||
mimetype: MIME type of the file.
|
||||
parents: Drive ids of the parent folders. Defaults to the root
|
||||
folder when omitted, which is how the legacy contract flow
|
||||
still works.
|
||||
"""
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseUpload
|
||||
|
||||
@@ -97,7 +159,10 @@ def upload_file(*, stream, filename, mimetype):
|
||||
_drive_service()
|
||||
.files()
|
||||
.create(
|
||||
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
||||
body={
|
||||
'name': filename,
|
||||
'parents': parents or [root_folder_id()],
|
||||
},
|
||||
media_body=media,
|
||||
fields='id',
|
||||
)
|
||||
|
||||
+18
-4
@@ -324,8 +324,9 @@ def _create_database():
|
||||
def _stop(*_args):
|
||||
"""Stop the PostgreSQL server with a fast shutdown.
|
||||
|
||||
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
||||
normal exit, and terminal close.
|
||||
Registered as an ``atexit`` handler so it fires on normal exit. Signal
|
||||
handling goes through ``_handle_stop_signal``, which also stops the
|
||||
server but then re-raises so the process actually exits.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
@@ -349,6 +350,19 @@ def _stop(*_args):
|
||||
_start_system_service(srv)
|
||||
|
||||
|
||||
def _handle_stop_signal(signum, frame):
|
||||
"""Stop PostgreSQL, then let the interpreter shut down normally.
|
||||
|
||||
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
|
||||
system service, then returns without exiting, leaving the dev server
|
||||
running against a stopped database and the terminal frozen. Re-raising
|
||||
``KeyboardInterrupt`` hands control back to the framework, which exits;
|
||||
the ``atexit`` registration then runs ``_stop`` again (idempotent).
|
||||
"""
|
||||
_stop()
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -420,9 +434,9 @@ def start() -> str:
|
||||
_started = True
|
||||
atexit.register(_stop)
|
||||
with contextlib.suppress(ValueError, OSError):
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGINT, _handle_stop_signal)
|
||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
||||
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
|
||||
|
||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||
return DATABASE_URL
|
||||
|
||||
@@ -97,3 +97,17 @@ from app.models.one_on_one_request import OneOnOneRequest
|
||||
from app.models.admin_settings import AppSettings
|
||||
from app.models.backup_record import BackupRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
# =========================================================================
|
||||
# Layer 12: documents (canonical document + versions + assignments)
|
||||
# =========================================================================
|
||||
from app.models.document import (
|
||||
ACKNOWLEDGMENT_KINDS,
|
||||
ASSIGNMENT_STATUSES,
|
||||
DOCUMENT_KIND_ROLES,
|
||||
DOCUMENT_KINDS,
|
||||
SIGNATURE_KINDS,
|
||||
Document,
|
||||
DocumentAssignment,
|
||||
DocumentVersion,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Documents, versions, and per-user assignments.
|
||||
|
||||
The old contract flow stored one uploaded file per player even when the
|
||||
file was identical for everyone, and Drive received no team/role structure
|
||||
at all. This module replaces that with three two-level concepts:
|
||||
|
||||
- ``Document`` the canonical document (e.g. ``Contrat joueur``).
|
||||
One row per kind; the file is stored once, never
|
||||
once per person.
|
||||
- ``DocumentVersion`` an immutable published revision. Uploading a new
|
||||
PDF is publishing a new version, which is the twice
|
||||
a year update cycle.
|
||||
- ``DocumentAssignment`` a per-person obligation. ``UNIQUE(document_id,
|
||||
user_id)`` is what guarantees a player signs a
|
||||
document exactly once, whatever the number of teams
|
||||
they sit on.
|
||||
|
||||
Signing obligations are role-scoped, not team-scoped: the team folder in
|
||||
Drive is a *filing* concern for the signed copy; the obligation itself is
|
||||
created once per eligible person.
|
||||
"""
|
||||
|
||||
from app.extensions import db
|
||||
from app.time_utils import utc_now_naive
|
||||
|
||||
#: Every document kind this application can hold.
|
||||
DOCUMENT_KINDS = (
|
||||
'player',
|
||||
'coach',
|
||||
'manager',
|
||||
'team',
|
||||
'code_of_conduct',
|
||||
'ethics_code',
|
||||
)
|
||||
|
||||
#: Kinds whose completion is an uploaded signed PDF.
|
||||
SIGNATURE_KINDS = ('player', 'coach', 'manager', 'team')
|
||||
|
||||
#: Kinds whose completion is read-and-acknowledge only (no file).
|
||||
ACKNOWLEDGMENT_KINDS = ('code_of_conduct', 'ethics_code')
|
||||
|
||||
#: Kind -> set of roles eligible to complete it. ``team`` and the two
|
||||
#: codes span roles; the three role contracts are single-role by nature.
|
||||
DOCUMENT_KIND_ROLES = {
|
||||
'player': ('player',),
|
||||
'coach': ('coach',),
|
||||
'manager': ('manager',),
|
||||
'team': ('player', 'coach', 'manager'),
|
||||
'code_of_conduct': ('player', 'coach', 'manager'),
|
||||
'ethics_code': ('player', 'coach', 'manager'),
|
||||
}
|
||||
|
||||
#: Possible values for DocumentAssignment.status.
|
||||
ASSIGNMENT_STATUSES = ('pending', 'signed', 'acknowledged')
|
||||
|
||||
|
||||
class Document(db.Model):
|
||||
"""A canonical document, stored once, completed by eligible people."""
|
||||
|
||||
__tablename__ = 'documents'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(255), nullable=False)
|
||||
kind = db.Column(db.String(30), nullable=False, default='player')
|
||||
#: Admins choose when the document is downloadable by team members. The
|
||||
#: old ``document_uploads_enabled`` global toggle is replaced by this
|
||||
#: per-document switch for the new flow.
|
||||
is_published = db.Column(db.Boolean, nullable=False, default=False)
|
||||
current_version_id = db.Column(db.Integer, db.ForeignKey('document_versions.id'), nullable=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
versions = db.relationship(
|
||||
'DocumentVersion',
|
||||
backref='document',
|
||||
lazy='dynamic',
|
||||
order_by='DocumentVersion.version',
|
||||
foreign_keys='DocumentVersion.document_id',
|
||||
)
|
||||
current_version = db.relationship(
|
||||
'DocumentVersion',
|
||||
foreign_keys=[current_version_id],
|
||||
uselist=False,
|
||||
)
|
||||
assignments = db.relationship(
|
||||
'DocumentAssignment',
|
||||
back_populates='document',
|
||||
lazy='dynamic',
|
||||
order_by='DocumentAssignment.created_at',
|
||||
foreign_keys='DocumentAssignment.document_id',
|
||||
)
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
|
||||
@property
|
||||
def requires_signature(self):
|
||||
"""Whether completion means an uploaded signed PDF."""
|
||||
return self.kind in SIGNATURE_KINDS
|
||||
|
||||
def completion_counts(self):
|
||||
"""(completed, pending) over every assignment of this document.
|
||||
|
||||
``completed`` covers both signed and acknowledged assignments.
|
||||
"""
|
||||
rows = (
|
||||
db.session.query(DocumentAssignment.status, db.func.count())
|
||||
.filter(DocumentAssignment.document_id == self.id)
|
||||
.group_by(DocumentAssignment.status)
|
||||
.all()
|
||||
)
|
||||
totals = dict(rows)
|
||||
completed = totals.get('signed', 0) + totals.get('acknowledged', 0)
|
||||
return completed, totals.get('pending', 0)
|
||||
|
||||
def next_version_number(self):
|
||||
"""The version number a freshly published revision should carry."""
|
||||
latest = self.versions.order_by(DocumentVersion.version.desc()).first()
|
||||
return (latest.version + 1) if latest else 1
|
||||
|
||||
|
||||
class DocumentVersion(db.Model):
|
||||
"""One immutable published revision of a document."""
|
||||
|
||||
__tablename__ = 'document_versions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
|
||||
version = db.Column(db.Integer, nullable=False)
|
||||
original_filename = db.Column(db.String(255), nullable=False)
|
||||
stored_filename = db.Column(db.String(255), nullable=False)
|
||||
#: ``gdrive://<file-id>`` for Drive storage, or a path relative to the
|
||||
#: document root for the local backend.
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
published_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
published_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
publisher = db.relationship('User', foreign_keys=[published_by])
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('document_id', 'version', name='unique_document_version'),
|
||||
)
|
||||
|
||||
|
||||
class DocumentAssignment(db.Model):
|
||||
"""One person's obligation to sign or acknowledge a document."""
|
||||
|
||||
__tablename__ = 'document_assignments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
completed_version_id = db.Column(
|
||||
db.Integer, db.ForeignKey('document_versions.id'), nullable=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='pending')
|
||||
signed_filename = db.Column(db.String(255), nullable=True)
|
||||
signed_file_path = db.Column(db.String(500), nullable=True)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
#: The team whose Drive folder files the signed copy. A member on
|
||||
#: several teams still files under one canonical team only.
|
||||
filed_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
document = db.relationship('Document', back_populates='assignments', foreign_keys=[document_id])
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref='document_assignments')
|
||||
completed_version = db.relationship('DocumentVersion', foreign_keys=[completed_version_id])
|
||||
filed_team = db.relationship('OrgTeam', foreign_keys=[filed_team_id])
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('document_id', 'user_id', name='unique_document_user'),)
|
||||
|
||||
@property
|
||||
def completed(self):
|
||||
return self.status in ('signed', 'acknowledged')
|
||||
+189
-1
@@ -6,6 +6,7 @@ to administrators.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
@@ -17,14 +18,23 @@ from flask import (
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors
|
||||
from app.models import (
|
||||
DOCUMENT_KIND_ROLES,
|
||||
DOCUMENT_KINDS,
|
||||
Admin,
|
||||
AppSettings,
|
||||
AuditLog,
|
||||
BackupRecord,
|
||||
Document,
|
||||
DocumentAssignment,
|
||||
DocumentVersion,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
@@ -33,12 +43,16 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users._shared import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
|
||||
from app.services.documents import backfill_assignments_for_document
|
||||
from app.storage import GoogleDriveStorageError, open_document, store_master_document
|
||||
from app.supporting_scripts.backup import (
|
||||
BackupError,
|
||||
backup_database,
|
||||
create_backup_dir,
|
||||
parse_database_url,
|
||||
)
|
||||
from app.validators import DocumentCreateSchema
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@@ -211,7 +225,7 @@ def restore_backup(backup_id):
|
||||
|
||||
# Create a safety backup of the current state before restoring.
|
||||
try:
|
||||
safety = _create_backup_record(
|
||||
_create_backup_record(
|
||||
backup_type='pre_restore',
|
||||
notes='Automatic safety backup before restoring ' + record.filename,
|
||||
)
|
||||
@@ -423,3 +437,177 @@ def wipe_teams():
|
||||
'success',
|
||||
)
|
||||
return respond_after_change(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Documents (canonical document store)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin_bp.route('/documents')
|
||||
@login_required
|
||||
def documents():
|
||||
"""List every document with its published flag and completion counts."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
documents = Document.query.order_by(Document.kind, Document.title).all()
|
||||
return render_template(
|
||||
'pages/admin_documents.html',
|
||||
documents=documents,
|
||||
kinds=DOCUMENT_KINDS,
|
||||
kind_roles=DOCUMENT_KIND_ROLES,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/documents/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_document():
|
||||
"""Create a new canonical document (no file yet; publish one next)."""
|
||||
if not require_admin():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
try:
|
||||
data = DocumentCreateSchema().load(request.form)
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return respond_after_change(url_for('admin.documents'))
|
||||
|
||||
document = Document(
|
||||
title=data['title'],
|
||||
kind=data['kind'],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.commit()
|
||||
|
||||
_log('document_created', f'{data["kind"]}: {data["title"]}')
|
||||
flash(_('Document "%(title)s" created.', title=data['title']), 'success')
|
||||
return respond_after_change(url_for('admin.documents'))
|
||||
|
||||
|
||||
@admin_bp.route('/documents/<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,
|
||||
)
|
||||
|
||||
+269
-61
@@ -3,11 +3,13 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import false, func, select
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.extensions import db
|
||||
@@ -16,14 +18,18 @@ from app.models import (
|
||||
EVALUATION_CRITERIA,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
Player,
|
||||
Scout,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE
|
||||
from app.responders import respond_after_change
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import EvaluationSchema
|
||||
|
||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||
@@ -37,95 +43,286 @@ def _users_by_id(user_ids):
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
def validate_score(score_value):
|
||||
"""Turn a raw form value into a criterion score (1..10), or None.
|
||||
|
||||
A blank value means "not assessed". Anything else outside 1..10 is a
|
||||
validation error, matching the single-player form's ``EvaluationSchema``
|
||||
-- a coach must not be able to quietly turn 11 or 0 into "no score"
|
||||
(QUA-003).
|
||||
"""
|
||||
if score_value is None or str(score_value).strip() == '':
|
||||
return None
|
||||
try:
|
||||
score = int(score_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError('A score must be a whole number from 1 to 10.') from exc
|
||||
if not 1 <= score <= 10:
|
||||
raise ValidationError('Scores run from 1 to 10.')
|
||||
return score
|
||||
|
||||
|
||||
def _apply_evaluation(evaluation, scores, comments, position):
|
||||
"""Write validated scores, comments and position onto an Evaluation."""
|
||||
evaluation.apply_scores(scores)
|
||||
evaluation.comments = comments
|
||||
evaluation.position_recommendation = position
|
||||
|
||||
|
||||
def _visible_evaluations_query(user):
|
||||
"""Evaluations the acting user is allowed to see, as a query.
|
||||
|
||||
Admins and scouts see everything. Managers and coaches see the
|
||||
evaluations authored by managers and coaches inside the tryouts they
|
||||
manage; players never reach the evaluation pages.
|
||||
"""
|
||||
if isinstance(user, (Admin, Scout)):
|
||||
return Evaluation.query
|
||||
tryout_ids = [t.id for t in user.get_visible_tryouts()]
|
||||
if not tryout_ids:
|
||||
return Evaluation.query.filter(false())
|
||||
evaluator_alias = aliased(User, name='visible_evaluator')
|
||||
return (
|
||||
Evaluation.query
|
||||
.join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.filter(evaluator_alias.role.in_(['manager', 'coach']))
|
||||
.filter(Evaluation.tryout_id.in_(tryout_ids))
|
||||
)
|
||||
|
||||
|
||||
def _visible_evaluation_ids(user):
|
||||
"""A ``select`` of the ids of evaluations the acting user may see."""
|
||||
ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery()
|
||||
return select(ids.c.id)
|
||||
|
||||
|
||||
def _average(values):
|
||||
"""Mean of the non-None values, rounded to one decimal (or None)."""
|
||||
values = [value for value in values if value is not None]
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 1)
|
||||
|
||||
|
||||
def _aggregate(evaluations):
|
||||
"""Per-criterion averages plus a combined overall average."""
|
||||
criterion_averages = [
|
||||
(label, _average([getattr(e, field) for e in evaluations]))
|
||||
for field, label in EVALUATION_CRITERIA
|
||||
]
|
||||
return {
|
||||
'criterion_averages': criterion_averages,
|
||||
'combined_average': _average([e.overall_score for e in evaluations]),
|
||||
'count': len(evaluations),
|
||||
}
|
||||
|
||||
|
||||
def _evaluation_view(evaluation, evaluator, tryout):
|
||||
"""Shape an Evaluation for the templates (scores in criterion order)."""
|
||||
return {
|
||||
'evaluation': evaluation,
|
||||
'evaluator': evaluator,
|
||||
'tryout': tryout,
|
||||
'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA],
|
||||
'overall': evaluation.overall_score,
|
||||
'position': evaluation.position_recommendation,
|
||||
'comment': evaluation.comments,
|
||||
'date': evaluation.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _players_by_average(user):
|
||||
"""Players with visible evaluations, best combined average first."""
|
||||
visible_ids = _visible_evaluation_ids(user)
|
||||
rows = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
)
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
players_by_id = _users_by_id(row.player_id for row in rows)
|
||||
ordered = [
|
||||
(players_by_id[row.player_id], row.avg_score)
|
||||
for row in rows
|
||||
if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player)
|
||||
]
|
||||
ordered.sort(
|
||||
key=lambda pair: (
|
||||
pair[1] is None,
|
||||
-(pair[1] if pair[1] is not None else 0),
|
||||
pair[0].username.lower(),
|
||||
)
|
||||
)
|
||||
return ordered
|
||||
|
||||
|
||||
@evaluations_bp.route('')
|
||||
@login_required
|
||||
def list_evaluations():
|
||||
"""List all evaluations accessible to the current user."""
|
||||
"""List player evaluations grouped by player, with a per-player view."""
|
||||
user = current_user
|
||||
|
||||
if isinstance(user, Player):
|
||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
sort_column = request.args.get('sort', 'created_at')
|
||||
sort_column = request.args.get('sort', 'overall_score')
|
||||
sort_order = request.args.get('order', 'desc')
|
||||
if sort_order not in ('asc', 'desc'):
|
||||
sort_order = 'desc'
|
||||
|
||||
player_alias = aliased(User, name='eval_player')
|
||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
||||
visible_ids = _visible_evaluation_ids(user)
|
||||
|
||||
sort_map = {
|
||||
'tryout': Tryout.title,
|
||||
'player': player_alias.username,
|
||||
'evaluator': evaluator_alias.username,
|
||||
'mecanics_score': Evaluation.mecanics_score,
|
||||
'cohesion_score': Evaluation.cohesion_score,
|
||||
'communication_score': Evaluation.communication_score,
|
||||
'gamesense_score': Evaluation.gamesense_score,
|
||||
'versatility_score': Evaluation.versatility_score,
|
||||
'discipline_score': Evaluation.discipline_score,
|
||||
'analysis_score': Evaluation.analysis_score,
|
||||
'sport_ethics_score': Evaluation.sport_ethics_score,
|
||||
'mental_score': Evaluation.mental_score,
|
||||
'overall_score': Evaluation.overall_score,
|
||||
'position_recommendation': Evaluation.position_recommendation,
|
||||
'created_at': Evaluation.created_at,
|
||||
}
|
||||
|
||||
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
|
||||
if sort_order == 'asc':
|
||||
sort_expr = sort_expr.asc()
|
||||
else:
|
||||
sort_expr = sort_expr.desc()
|
||||
|
||||
if isinstance(user, Admin):
|
||||
evaluations_page = paginate(
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.order_by(sort_expr, Evaluation.id)
|
||||
)
|
||||
avg_scores = (
|
||||
aggregate = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
func.max(Evaluation.created_at).label('last_eval'),
|
||||
)
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = players_by_id.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p,
|
||||
|
||||
players_by_id = _users_by_id(row.player_id for row in aggregate)
|
||||
grouped = []
|
||||
for row in aggregate:
|
||||
player = players_by_id.get(row.player_id)
|
||||
if not player or not isinstance(player, Player):
|
||||
continue
|
||||
grouped.append(
|
||||
{
|
||||
'player': player,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
|
||||
'count': row.eval_count,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
||||
'last_eval': row.last_eval,
|
||||
'evaluations': [],
|
||||
}
|
||||
else:
|
||||
# Everyone still here evaluates: players were redirected above, and
|
||||
# can_evaluate() is true for the four remaining roles. The former
|
||||
# `else` branch listed evaluations *received* — a player's view,
|
||||
# unreachable from this point (ARCH-007).
|
||||
evaluations_page = paginate(
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.filter(Evaluation.evaluator_id == user.id)
|
||||
.order_by(sort_expr, Evaluation.id)
|
||||
)
|
||||
player_scores = {}
|
||||
|
||||
def sort_value(entry):
|
||||
if sort_column == 'player':
|
||||
return entry['player'].username.lower()
|
||||
if sort_column == 'count':
|
||||
return entry['count']
|
||||
if sort_column == 'created_at':
|
||||
return entry['last_eval'] or utc_now_naive()
|
||||
return entry['avg'] if entry['avg'] is not None else float('-inf')
|
||||
|
||||
grouped.sort(key=sort_value, reverse=(sort_order == 'desc'))
|
||||
|
||||
page = request.args.get('page', 1, type=int) or 1
|
||||
page = max(1, page)
|
||||
per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE
|
||||
per_page = max(1, min(per_page, MAX_PER_PAGE))
|
||||
|
||||
total = len(grouped)
|
||||
pages = math.ceil(total / per_page) if total else 1
|
||||
page = min(page, pages)
|
||||
start = (page - 1) * per_page
|
||||
page_items = grouped[start:start + per_page]
|
||||
|
||||
players_on_page = {entry['player'].id: entry for entry in page_items}
|
||||
if players_on_page:
|
||||
evaluations = (
|
||||
Evaluation.query
|
||||
.filter(Evaluation.player_id.in_(players_on_page))
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
|
||||
.all()
|
||||
)
|
||||
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
|
||||
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
|
||||
tryouts_by_id = (
|
||||
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
|
||||
if tryout_ids
|
||||
else {}
|
||||
)
|
||||
for evaluation in evaluations:
|
||||
players_on_page[evaluation.player_id]['evaluations'].append(
|
||||
_evaluation_view(
|
||||
evaluation,
|
||||
evaluators_by_id.get(evaluation.evaluator_id),
|
||||
tryouts_by_id.get(evaluation.tryout_id),
|
||||
)
|
||||
)
|
||||
|
||||
pagination = {
|
||||
'page': page,
|
||||
'pages': pages,
|
||||
'total': total,
|
||||
'has_prev': page > 1,
|
||||
'has_next': page < pages,
|
||||
'prev_num': page - 1,
|
||||
'next_num': page + 1,
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'pages/evaluations.html',
|
||||
evaluations=evaluations_page.items,
|
||||
pagination=evaluations_page,
|
||||
player_scores=player_scores,
|
||||
players=page_items,
|
||||
pagination=pagination,
|
||||
sort_column=sort_column,
|
||||
sort_order=sort_order,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
first_player_id=grouped[0]['player'].id if grouped else None,
|
||||
)
|
||||
|
||||
|
||||
@evaluations_bp.route('/player/<int:player_id>')
|
||||
@login_required
|
||||
def player_evaluations(player_id):
|
||||
"""One player's evaluations, with prev/next navigation between players."""
|
||||
user = current_user
|
||||
if not user.can_evaluate():
|
||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only view evaluations for players.'), 'danger')
|
||||
return redirect(url_for('evaluations.list_evaluations'))
|
||||
|
||||
ordered = _players_by_average(user)
|
||||
index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None)
|
||||
prev_player = ordered[index - 1][0] if index is not None and index > 0 else None
|
||||
next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None
|
||||
|
||||
visible_ids = _visible_evaluation_ids(user)
|
||||
evaluations = (
|
||||
Evaluation.query
|
||||
.filter(Evaluation.player_id == player.id)
|
||||
.filter(Evaluation.id.in_(visible_ids))
|
||||
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
|
||||
.all()
|
||||
)
|
||||
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
|
||||
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
|
||||
tryouts_by_id = (
|
||||
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
|
||||
if tryout_ids
|
||||
else {}
|
||||
)
|
||||
evaluation_views = [
|
||||
_evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id))
|
||||
for e in evaluations
|
||||
]
|
||||
|
||||
aggregates = _aggregate(evaluations)
|
||||
|
||||
return render_template(
|
||||
'pages/player_evaluations.html',
|
||||
player=player,
|
||||
evaluations=evaluation_views,
|
||||
prev_player=prev_player,
|
||||
next_player=next_player,
|
||||
criterion_averages=aggregates['criterion_averages'],
|
||||
combined_average=aggregates['combined_average'],
|
||||
evaluation_count=aggregates['count'],
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
)
|
||||
|
||||
|
||||
@@ -317,6 +514,7 @@ def batch_evaluate(tryout_id):
|
||||
|
||||
if request.method == 'POST':
|
||||
saved = 0
|
||||
try:
|
||||
for entry in players:
|
||||
pid = entry['player'].id
|
||||
scores = {
|
||||
@@ -338,6 +536,16 @@ def batch_evaluate(tryout_id):
|
||||
_apply_evaluation(evaluation, scores, comments, position)
|
||||
db.session.add(evaluation)
|
||||
saved += 1
|
||||
except ValidationError:
|
||||
db.session.rollback()
|
||||
flash(_('Scores must be whole numbers from 1 to 10.'), 'danger')
|
||||
return render_template(
|
||||
'pages/batch_evaluate.html',
|
||||
tryout=tryout,
|
||||
players=players,
|
||||
evaluation_criteria=EVALUATION_CRITERIA,
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||
|
||||
@@ -224,7 +224,6 @@ def api_events():
|
||||
|
||||
for match in matches:
|
||||
tryout = tryouts_by_id[match.tryout_id]
|
||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||
# 'description' used to be participants_str + '<br>' + description.
|
||||
# Building presentation markup inside a JSON field is what carried
|
||||
# the stored XSS: the browser dropped it straight into innerHTML,
|
||||
@@ -255,7 +254,6 @@ def api_events():
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
@@ -301,7 +299,6 @@ def api_events():
|
||||
'id': f'team_match_{team_match.id}',
|
||||
'title': team_match.title,
|
||||
'type': 'team_match',
|
||||
'color': '#3b82f6',
|
||||
'extendedProps': {
|
||||
'location': team_match.location or 'TBD',
|
||||
'status': team_match.status,
|
||||
@@ -334,7 +331,6 @@ def api_events():
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.models import (
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.services.documents import backfill_assignments_for_user
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
@@ -215,6 +216,9 @@ def create_team():
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
for staff_user in (coach, manager):
|
||||
if staff_user:
|
||||
backfill_assignments_for_user(staff_user)
|
||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
@@ -269,6 +273,8 @@ def edit_team(team_id):
|
||||
team.managers.append(manager)
|
||||
|
||||
db.session.commit()
|
||||
for staff_user in team.get_coaches() + team.get_managers():
|
||||
backfill_assignments_for_user(staff_user)
|
||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
@@ -368,6 +374,7 @@ def add_coach(team_id):
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
backfill_assignments_for_user(coach)
|
||||
flash(
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||
'success',
|
||||
@@ -411,6 +418,7 @@ def add_manager(team_id):
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
backfill_assignments_for_user(manager)
|
||||
flash(
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||
'success',
|
||||
@@ -507,6 +515,7 @@ def add_player(team_id):
|
||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||
db.session.add(tp)
|
||||
db.session.commit()
|
||||
backfill_assignments_for_user(player)
|
||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||
return respond_after_change(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.routes.users import (
|
||||
accounts, # noqa: F401,E402
|
||||
availability, # noqa: F401,E402
|
||||
contracts, # noqa: F401,E402
|
||||
documents, # noqa: F401,E402
|
||||
notes, # noqa: F401,E402
|
||||
one_on_one, # noqa: F401,E402
|
||||
profile, # noqa: F401,E402
|
||||
|
||||
@@ -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
|
||||
+189
-15
@@ -1043,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
|
||||
|
||||
.fc {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--fc-border-color: var(--gray-200);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: var(--gray-50);
|
||||
--fc-today-bg-color: var(--info-light);
|
||||
}
|
||||
|
||||
.fc-toolbar {
|
||||
@@ -1055,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.fc-button {
|
||||
background: var(--primary) !important;
|
||||
border: none !important;
|
||||
.fc .fc-button {
|
||||
background: var(--gray-50) !important;
|
||||
border: 1px solid var(--gray-200) !important;
|
||||
color: var(--gray-700) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 6px 12px !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.fc-button:hover {
|
||||
background: var(--primary-dark) !important;
|
||||
.fc .fc-button:hover {
|
||||
background: var(--gray-100) !important;
|
||||
color: var(--gray-900) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary-dark) !important;
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc .fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-day {
|
||||
.fc-daygrid-day,
|
||||
.fc-timegrid-slot {
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
@@ -1080,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
font-size: 0.8rem;
|
||||
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
|
||||
its inline event colors) */
|
||||
#calendar .fc-event {
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 3px 6px !important;
|
||||
font-size: 0.8rem !important;
|
||||
background-color: var(--fc-event-bg, var(--primary)) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-weight: 600;
|
||||
#calendar .fc-event-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#calendar .fc-event-body i {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#calendar .fc-event-text {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#calendar .fc-event-time {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#calendar .fc-event-title {
|
||||
font-weight: 600 !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Event type colors (semantic, light theme) */
|
||||
#calendar .fc-event--team { --fc-event-bg: #10b981; }
|
||||
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
|
||||
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
|
||||
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
|
||||
|
||||
#calendar .fc-now-indicator {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Dark theme: the calendar chrome reacts to the light/dark switch */
|
||||
[data-theme="dark"] .fc {
|
||||
--fc-border-color: var(--border-color);
|
||||
--fc-neutral-bg-color: var(--bg-secondary);
|
||||
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-toolbar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button:hover {
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-daygrid-day:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-daygrid-day-number,
|
||||
[data-theme="dark"] .fc-col-header-cell-cushion,
|
||||
[data-theme="dark"] .fc-timegrid-axis-cushion,
|
||||
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Pastel event chips with dark text keep contrast on a dark background. */
|
||||
[data-theme="dark"] #calendar .fc-event {
|
||||
color: var(--gray-900) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
|
||||
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
|
||||
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
|
||||
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -2122,3 +2226,73 @@ a:hover { color: var(--primary-dark); }
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Evaluation comments - separate grid so long notes stay readable */
|
||||
.eval-comments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.eval-comments-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eval-comment {
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.eval-comment-text {
|
||||
margin: 0 0 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.eval-comment-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.eval-comment-author strong {
|
||||
color: var(--gray-700);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment-meta {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comment-author strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.eval-comments-empty {
|
||||
margin: 8px 0 0;
|
||||
color: var(--gray-500);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .eval-comments-empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ from app.google_drive import (
|
||||
from app.google_drive import (
|
||||
download_file as download_google_drive_file,
|
||||
)
|
||||
from app.google_drive import (
|
||||
get_or_create_folder as get_or_create_drive_folder,
|
||||
)
|
||||
from app.google_drive import (
|
||||
root_folder_id as google_drive_root_folder_id,
|
||||
)
|
||||
from app.google_drive import (
|
||||
upload_file as upload_google_drive_file,
|
||||
)
|
||||
@@ -159,6 +165,73 @@ def store_uploaded_document(file_storage, relative_path):
|
||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||
|
||||
|
||||
def _save_locally(file_storage, relative_path):
|
||||
"""Persist *relative_path* (from ``documents_root()``) and return it."""
|
||||
absolute_path = document_path(relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
file_storage.save(absolute_path)
|
||||
return relative_path
|
||||
|
||||
|
||||
def _drive_folder_for(parts):
|
||||
"""Resolve (and create as needed) a nested chain of Drive folders.
|
||||
|
||||
Args:
|
||||
parts: Folder names under the root folder, root first.
|
||||
|
||||
Returns:
|
||||
str: The Drive id of the deepest folder.
|
||||
"""
|
||||
parent = google_drive_root_folder_id()
|
||||
for part in parts:
|
||||
parent = get_or_create_drive_folder(part, parent)
|
||||
return parent
|
||||
|
||||
|
||||
def store_master_document(file_storage, filename, mimetype=None):
|
||||
"""Store the unsigned template of a document (the canonical copy).
|
||||
|
||||
The point is to store the file exactly once: every assignment for that
|
||||
document points at this one reference.
|
||||
"""
|
||||
if document_storage_backend() == 'local':
|
||||
return _save_locally(file_storage, os.path.join(CONTRACTS_DIR, filename))
|
||||
|
||||
file_id = upload_google_drive_file(
|
||||
stream=file_storage.stream,
|
||||
filename=filename,
|
||||
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
|
||||
)
|
||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||
|
||||
|
||||
def store_signed_document(file_storage, *, team_name, role, username, filename, mimetype=None):
|
||||
"""File a signed copy under ``signés/<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):
|
||||
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||
if is_google_drive_path(stored_path):
|
||||
|
||||
@@ -113,6 +113,12 @@
|
||||
</a>
|
||||
</li>
|
||||
{% 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>
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -137,12 +137,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
||||
right: ''
|
||||
},
|
||||
events: '/matches/api/events',
|
||||
eventClick: function(info) {
|
||||
showEventModal(info.event);
|
||||
},
|
||||
eventClassNames: function(arg) {
|
||||
return [eventClassName(arg.event)];
|
||||
},
|
||||
eventContent: function(arg) {
|
||||
return renderEventContent(arg);
|
||||
},
|
||||
dateClick: function(info) {
|
||||
if (canScheduleMatches) {
|
||||
var dateStr = info.dateStr;
|
||||
@@ -158,7 +164,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
},
|
||||
slotMinTime: '12:00:00',
|
||||
slotMaxTime: '24:00:00'
|
||||
slotMaxTime: '24:00:00',
|
||||
slotEventOverlap: true,
|
||||
dayMaxEvents: false,
|
||||
nowIndicator: true,
|
||||
height: 'auto'
|
||||
});
|
||||
calendar.render();
|
||||
|
||||
@@ -177,6 +187,41 @@ function changeView(viewName) {
|
||||
}
|
||||
}
|
||||
|
||||
function eventClassName(event) {
|
||||
var props = event.extendedProps || {};
|
||||
if (props.type === 'team_match') { return 'fc-event--team-match'; }
|
||||
if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; }
|
||||
return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim';
|
||||
}
|
||||
|
||||
function eventIcon(type, matchType) {
|
||||
if (type === 'team_match') { return 'fa-trophy'; }
|
||||
if (type === 'one_on_one') { return 'fa-user-friends'; }
|
||||
if (matchType === 'team_vs_team') { return 'fa-shield-alt'; }
|
||||
if (matchType === 'player_vs_player') { return 'fa-users'; }
|
||||
return 'fa-gamepad';
|
||||
}
|
||||
|
||||
function renderEventContent(arg) {
|
||||
// Built with DOM nodes + textContent: the title comes from a JSON endpoint
|
||||
// and has never been HTML-escaped, so it must not reach innerHTML.
|
||||
var props = arg.event.extendedProps || {};
|
||||
var wrap = makeEl('div', 'fc-event-body');
|
||||
|
||||
var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type));
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
wrap.appendChild(icon);
|
||||
|
||||
var text = makeEl('span', 'fc-event-text');
|
||||
if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) {
|
||||
text.appendChild(makeEl('span', 'fc-event-time', arg.timeText));
|
||||
}
|
||||
text.appendChild(makeEl('span', 'fc-event-title', arg.event.title));
|
||||
wrap.appendChild(text);
|
||||
|
||||
return { domNodes: [wrap] };
|
||||
}
|
||||
|
||||
// --- Create Event Modal ---
|
||||
function showCreateEventModal(dateStr) {
|
||||
document.getElementById('createEventDate').textContent = dateStr;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set sort_column = sort_column | default('created_at') %}
|
||||
{% set sort_column = sort_column | default('overall_score') %}
|
||||
{% set sort_order = sort_order | default('desc') %}
|
||||
|
||||
{% macro sort_link(column, label) %}
|
||||
@@ -22,76 +22,111 @@
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% if current_user.role == 'admin' and player_scores %}
|
||||
<div class="stats-grid mb-4">
|
||||
{% for pid, data in player_scores.items() %}
|
||||
<div class="stat-card stat-card-sm">
|
||||
<div class="stat-icon bg-info">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ data.player.username[:12] }}</h3>
|
||||
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="view-toggle mb-3">
|
||||
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||
</a>
|
||||
{% if first_player_id %}
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=first_player_id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-user"></i> {{ _('By player') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i>
|
||||
{% if current_user.role == 'player' %}
|
||||
My Evaluations
|
||||
{% else %}
|
||||
All Evaluations
|
||||
{% endif %}
|
||||
</h3>
|
||||
<h3><i class="fas fa-clipboard-list"></i> {{ _('Player Evaluations') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ sort_link('tryout', 'Tryout') }}
|
||||
<th></th>
|
||||
{{ sort_link('player', 'Player') }}
|
||||
{{ sort_link('evaluator', 'Evaluator') }}
|
||||
{{ sort_link('mecanics_score', 'Mecanics') }}
|
||||
{{ sort_link('cohesion_score', 'Cohesion') }}
|
||||
{{ sort_link('communication_score', 'Communication') }}
|
||||
{{ sort_link('gamesense_score', 'Gamesense') }}
|
||||
{{ sort_link('versatility_score', 'Versatility') }}
|
||||
{{ sort_link('discipline_score', 'Discipline') }}
|
||||
{{ sort_link('analysis_score', 'Analysis') }}
|
||||
{{ sort_link('sport_ethics_score', 'Sport Ethics') }}
|
||||
{{ sort_link('mental_score', 'Mental') }}
|
||||
{{ sort_link('overall_score', 'Overall') }}
|
||||
{{ sort_link('position_recommendation', 'Position') }}
|
||||
{{ sort_link('created_at', 'Date') }}
|
||||
{{ sort_link('overall_score', 'Average') }}
|
||||
{{ sort_link('count', 'Evaluations') }}
|
||||
<th>{{ _('Open') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eval in evaluations %}
|
||||
{% for entry in players %}
|
||||
<tr class="eval-row" data-action="toggle-player-evals" data-target="evals-{{ entry.player.id }}">
|
||||
<td><i class="fas fa-chevron-right eval-chevron"></i></td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="player-link">
|
||||
{{ entry.player.username }}
|
||||
</a>
|
||||
</td>
|
||||
<td><span class="score">{{ entry.avg | round(1) if entry.avg is not none else '-' }}</span></td>
|
||||
<td>{{ entry.count }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="eval-detail hidden" id="evals-{{ entry.player.id }}">
|
||||
<td colspan="5">
|
||||
<div class="eval-detail-inner">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td>
|
||||
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td>
|
||||
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td>
|
||||
<td>{{ eval.mecanics_score or '-' }}</td>
|
||||
<td>{{ eval.cohesion_score or '-' }}</td>
|
||||
<td>{{ eval.communication_score or '-' }}</td>
|
||||
<td>{{ eval.gamesense_score or '-' }}</td>
|
||||
<td>{{ eval.versatility_score or '-' }}</td>
|
||||
<td>{{ eval.discipline_score or '-' }}</td>
|
||||
<td>{{ eval.analysis_score or '-' }}</td>
|
||||
<td>{{ eval.sport_ethics_score or '-' }}</td>
|
||||
<td>{{ eval.mental_score or '-' }}</td>
|
||||
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
|
||||
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td>
|
||||
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td>
|
||||
<th>{{ _('Evaluator') }}</th>
|
||||
<th>{{ _('Tryout') }}</th>
|
||||
{% for field, label in evaluation_criteria %}
|
||||
<th>{{ label }}</th>
|
||||
{% endfor %}
|
||||
<th>{{ _('Overall') }}</th>
|
||||
<th>{{ _('Position') }}</th>
|
||||
<th>{{ _('Date') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ev in entry.evaluations %}
|
||||
<tr>
|
||||
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
||||
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
||||
{% for score in ev.scores %}
|
||||
<td>{{ score if score is not none else '-' }}</td>
|
||||
{% endfor %}
|
||||
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
|
||||
<td>{{ ev.position or '-' }}</td>
|
||||
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="15" class="text-center">
|
||||
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if entry.evaluations %}
|
||||
{% set comment_evals = entry.evaluations | selectattr('comment') | list %}
|
||||
<div class="eval-comments">
|
||||
<h4 class="eval-comments-title"><i class="fas fa-comments"></i> {{ _('Comments') }}</h4>
|
||||
{% if comment_evals %}
|
||||
{% for ev in comment_evals %}
|
||||
<article class="eval-comment">
|
||||
<p class="eval-comment-text">{{ ev.comment }}</p>
|
||||
<footer class="eval-comment-meta">
|
||||
<span class="eval-comment-author">{{ _('Evaluator') }} : <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
|
||||
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} · {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
|
||||
</footer>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="eval-comments-empty">{{ _('No comments') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-clipboard"></i>
|
||||
<h3>{{ _('No evaluations yet') }}</h3>
|
||||
@@ -106,3 +141,36 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.view-toggle { display: flex; gap: 8px; }
|
||||
.eval-row { cursor: pointer; }
|
||||
.eval-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||
.eval-detail-inner { padding: 8px 4px; }
|
||||
.eval-detail-inner .table { margin-bottom: 0; }
|
||||
.player-nav { display: flex; gap: 8px; align-items: center; }
|
||||
.is-disabled { opacity: 0.5; pointer-events: none; }
|
||||
</style>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function togglePlayerEvals(element, event) {
|
||||
if (event.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
var detail = document.getElementById(element.getAttribute('data-target'));
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
var chevron = element.querySelector('.eval-chevron');
|
||||
var isHidden = detail.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
detail.classList.remove('hidden');
|
||||
if (chevron) chevron.classList.add('fa-rotate-90');
|
||||
} else {
|
||||
detail.classList.add('hidden');
|
||||
if (chevron) chevron.classList.remove('fa-rotate-90');
|
||||
}
|
||||
}
|
||||
registerActions({ 'toggle-player-evals': togglePlayerEvals });
|
||||
</script>
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,134 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ player.username }} - {{ _('Evaluations') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Player Evaluations') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('evaluations.list_evaluations') }}">{{ _('Evaluations') }}</a> / {{ player.username }}</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="view-toggle mb-3">
|
||||
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||
</a>
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=player.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-user"></i> {{ _('By player') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-user"></i> {{ player.username }}</h3>
|
||||
<div class="player-nav">
|
||||
{% if prev_player %}
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=prev_player.id) }}" class="btn btn-sm btn-secondary" title="{{ prev_player.username }}">
|
||||
<i class="fas fa-chevron-left"></i> {{ prev_player.username }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-left"></i></span>
|
||||
{% endif %}
|
||||
{% if next_player %}
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=next_player.id) }}" class="btn btn-sm btn-secondary" title="{{ next_player.username }}">
|
||||
{{ next_player.username }} <i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-right"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="stats-grid mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info"><i class="fas fa-star"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ combined_average | round(1) if combined_average is not none else '-' }}</h3>
|
||||
<p>{{ _('Combined average') }} · {{ evaluation_count }} {{ _('evaluations') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% for label, avg in criterion_averages %}
|
||||
<div class="stat-card stat-card-sm">
|
||||
<div class="stat-icon bg-info"><i class="fas fa-chart-bar"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ avg | round(1) if avg is not none else '-' }}</h3>
|
||||
<p>{{ label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Evaluator') }}</th>
|
||||
<th>{{ _('Tryout') }}</th>
|
||||
{% for field, label in evaluation_criteria %}
|
||||
<th>{{ label }}</th>
|
||||
{% endfor %}
|
||||
<th>{{ _('Overall') }}</th>
|
||||
<th>{{ _('Position') }}</th>
|
||||
<th>{{ _('Date') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ev in evaluations %}
|
||||
<tr>
|
||||
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
||||
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
||||
{% for score in ev.scores %}
|
||||
<td>{{ score if score is not none else '-' }}</td>
|
||||
{% endfor %}
|
||||
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
|
||||
<td>{{ ev.position or '-' }}</td>
|
||||
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-clipboard"></i>
|
||||
<h3>{{ _('No evaluations for this player yet') }}</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if evaluations %}
|
||||
{% set comment_evals = evaluations | selectattr('comment') | list %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if comment_evals %}
|
||||
<div class="eval-comments">
|
||||
{% for ev in comment_evals %}
|
||||
<article class="eval-comment">
|
||||
<p class="eval-comment-text">{{ ev.comment }}</p>
|
||||
<footer class="eval-comment-meta">
|
||||
<span class="eval-comment-author">{{ _('Evaluator') }} : <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
|
||||
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} · {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
|
||||
</footer>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-comments"></i>
|
||||
<h3>{{ _('No comments') }}</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.view-toggle { display: flex; gap: 8px; }
|
||||
.player-nav { display: flex; gap: 8px; align-items: center; }
|
||||
.is-disabled { opacity: 0.5; pointer-events: none; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -23,7 +23,7 @@ from marshmallow import (
|
||||
validates_schema,
|
||||
)
|
||||
|
||||
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
||||
from app.models import DOCUMENT_KINDS, ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
||||
|
||||
# =============================================================================
|
||||
# Custom Validators
|
||||
@@ -1061,3 +1061,23 @@ class EvaluationSchema(StripMixin):
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
)
|
||||
|
||||
|
||||
class DocumentCreateSchema(StripMixin):
|
||||
"""Validate the admin form that creates a new document.
|
||||
|
||||
Fields:
|
||||
title: Human-readable name of the document (e.g. ``Contrat joueur``).
|
||||
kind: One of DOCUMENT_KINDS. The kind decides who completes the
|
||||
document and whether completion is a signature or an
|
||||
acknowledgment.
|
||||
"""
|
||||
|
||||
title = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=255, error=_l('Title is required.')),
|
||||
)
|
||||
kind = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(DOCUMENT_KINDS, error=_l('That document type is not recognised.')),
|
||||
)
|
||||
|
||||
@@ -179,3 +179,61 @@ class TestThroughTheForm:
|
||||
evaluation = Evaluation.query.one()
|
||||
assert evaluation.mecanics_score is None
|
||||
assert evaluation.overall_score == 4
|
||||
|
||||
|
||||
class TestBatchEvaluate:
|
||||
"""The batch page drives the same two helpers the single-player form does,
|
||||
through ``{field}_{player_id}`` names. The helpers were lost once, in a
|
||||
refactor, and the page 500'd on every POST until someone noticed."""
|
||||
|
||||
def _submit(self, client, setup, **fields):
|
||||
data = {'player_ids': [str(setup['player_id'])]}
|
||||
data.update(fields)
|
||||
return client.post(
|
||||
f'/evaluations/{setup["tryout_id"]}/batch',
|
||||
data=data,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def test_a_valid_batch_evaluation_is_stored(self, app, client, evaluation_setup):
|
||||
pid = evaluation_setup['player_id']
|
||||
self._submit(
|
||||
client,
|
||||
evaluation_setup,
|
||||
**{
|
||||
f'mecanics_score_{pid}': '8',
|
||||
f'mental_score_{pid}': '6',
|
||||
f'comments_{pid}': 'Solid.',
|
||||
},
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
evaluation = Evaluation.query.one()
|
||||
assert evaluation.mecanics_score == 8
|
||||
assert evaluation.overall_score == 7
|
||||
assert evaluation.comments == 'Solid.'
|
||||
assert evaluation.position_recommendation is None
|
||||
|
||||
def test_an_out_of_range_batch_score_stores_nothing(self, app, client, evaluation_setup):
|
||||
pid = evaluation_setup['player_id']
|
||||
self._submit(
|
||||
client,
|
||||
evaluation_setup,
|
||||
**{f'mecanics_score_{pid}': '11', f'mental_score_{pid}': '6'},
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert Evaluation.query.count() == 0
|
||||
|
||||
def test_a_second_batch_submission_replaces_the_first(self, app, client, evaluation_setup):
|
||||
pid = evaluation_setup['player_id']
|
||||
self._submit(
|
||||
client, evaluation_setup, **{f'mecanics_score_{pid}': '8', f'mental_score_{pid}': '4'}
|
||||
)
|
||||
self._submit(
|
||||
client, evaluation_setup, **{f'mecanics_score_{pid}': '4', f'mental_score_{pid}': '4'}
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
evaluation = Evaluation.query.one()
|
||||
assert evaluation.overall_score == 4
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Player-specific evaluation views (the "By player" toggle and its page).
|
||||
|
||||
These pages regroup the flat evaluation table by player: one row per player in
|
||||
the table view, and a dedicated page per player with left/right navigation and
|
||||
per-criterion averages. Visibility follows the viewer's role — managers and
|
||||
coaches see staff-authored evaluations inside their own tryouts.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Evaluation, Tryout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_setup(app, as_role, make_user):
|
||||
"""A coach who manages one tryout, plus staff, a player, and a foreign tryout."""
|
||||
coach_id = as_role('coach', username='viewer_coach')
|
||||
manager_id = make_user('manager', username='other_manager')
|
||||
admin_id = make_user('admin', username='the_admin')
|
||||
player_id = make_user('player', username='the_player')
|
||||
outsider_id = make_user('coach', username='outsider_coach')
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='Spring',
|
||||
game='Valorant',
|
||||
date=date(2030, 4, 1),
|
||||
created_by=coach_id,
|
||||
coach_id=coach_id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
other_tryout = Tryout(
|
||||
title='Other',
|
||||
game='Valorant',
|
||||
date=date(2030, 5, 1),
|
||||
created_by=outsider_id,
|
||||
)
|
||||
db.session.add(other_tryout)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'coach_id': coach_id,
|
||||
'manager_id': manager_id,
|
||||
'admin_id': admin_id,
|
||||
'player_id': player_id,
|
||||
'tryout_id': tryout.id,
|
||||
'other_tryout_id': other_tryout.id,
|
||||
}
|
||||
|
||||
|
||||
class TestVisibility:
|
||||
def test_coach_sees_staff_evaluations_only_in_their_tryout(self, app, client, staff_setup):
|
||||
with app.app_context():
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=staff_setup['tryout_id'],
|
||||
player_id=staff_setup['player_id'],
|
||||
evaluator_id=staff_setup['manager_id'],
|
||||
overall_score=7,
|
||||
comments='manager in own tryout',
|
||||
)
|
||||
)
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=staff_setup['tryout_id'],
|
||||
player_id=staff_setup['player_id'],
|
||||
evaluator_id=staff_setup['admin_id'],
|
||||
overall_score=5,
|
||||
comments='admin authored',
|
||||
)
|
||||
)
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=staff_setup['other_tryout_id'],
|
||||
player_id=staff_setup['player_id'],
|
||||
evaluator_id=staff_setup['manager_id'],
|
||||
overall_score=9,
|
||||
comments='manager other tryout',
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
body = client.get(f"/evaluations/player/{staff_setup['player_id']}").get_data(as_text=True)
|
||||
|
||||
assert 'manager in own tryout' in body
|
||||
assert 'admin authored' not in body
|
||||
assert 'manager other tryout' not in body
|
||||
|
||||
def test_admin_sees_every_evaluation(self, app, client, as_role, make_user):
|
||||
admin_id = as_role('admin', username='boss')
|
||||
coach_id = make_user('coach', username='some_coach')
|
||||
player_id = make_user('player', username='star')
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='T',
|
||||
game='Valorant',
|
||||
date=date(2030, 1, 1),
|
||||
created_by=admin_id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=tryout.id,
|
||||
player_id=player_id,
|
||||
evaluator_id=coach_id,
|
||||
overall_score=6,
|
||||
comments='coach comment',
|
||||
)
|
||||
)
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=tryout.id,
|
||||
player_id=player_id,
|
||||
evaluator_id=admin_id,
|
||||
overall_score=4,
|
||||
comments='admin comment',
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
|
||||
|
||||
assert 'coach comment' in body
|
||||
assert 'admin comment' in body
|
||||
|
||||
def test_a_player_cannot_open_the_player_page(self, client, as_role):
|
||||
player_id = as_role('player')
|
||||
response = client.get(f'/evaluations/player/{player_id}', follow_redirects=False)
|
||||
assert response.status_code in (301, 302)
|
||||
|
||||
|
||||
class TestAggregates:
|
||||
def test_combined_and_per_criterion_averages(self, app, client, as_role, make_user):
|
||||
coach_id = as_role('coach', username='agg_coach')
|
||||
coach2_id = make_user('coach', username='agg_coach2')
|
||||
player_id = make_user('player', username='agg_player')
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='Agg',
|
||||
game='Valorant',
|
||||
date=date(2030, 1, 1),
|
||||
created_by=coach_id,
|
||||
coach_id=coach_id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
first = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach_id)
|
||||
first.apply_scores({'mecanics_score': 8, 'mental_score': 6})
|
||||
second = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach2_id)
|
||||
second.apply_scores({'mecanics_score': 4, 'mental_score': 8})
|
||||
db.session.add_all([first, second])
|
||||
db.session.commit()
|
||||
|
||||
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
|
||||
|
||||
# overall: (7 + 6) / 2 = 6.5; mecanics: 6.0; mental: 7.0.
|
||||
assert '6.5' in body
|
||||
assert '6.0' in body
|
||||
assert '7.0' in body
|
||||
|
||||
|
||||
class TestNavigation:
|
||||
def test_players_are_ordered_best_first_with_arrows(self, app, client, as_role, make_user):
|
||||
coach_id = as_role('coach', username='order_coach')
|
||||
high = make_user('player', username='zzz_high')
|
||||
mid = make_user('player', username='aaa_mid')
|
||||
low = make_user('player', username='mmm_low')
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='Order',
|
||||
game='Valorant',
|
||||
date=date(2030, 1, 1),
|
||||
created_by=coach_id,
|
||||
coach_id=coach_id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
for player_id, score in [(high, 8), (mid, 5), (low, 3)]:
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=tryout.id,
|
||||
player_id=player_id,
|
||||
evaluator_id=coach_id,
|
||||
overall_score=score,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
best = client.get(f'/evaluations/player/{high}').get_data(as_text=True)
|
||||
middle = client.get(f'/evaluations/player/{mid}').get_data(as_text=True)
|
||||
worst = client.get(f'/evaluations/player/{low}').get_data(as_text=True)
|
||||
|
||||
# Best: no previous (left arrow disabled), next is the middle player.
|
||||
assert 'aria-disabled="true"' in best
|
||||
assert f'/evaluations/player/{mid}' in best
|
||||
assert f'/evaluations/player/{low}' not in best
|
||||
|
||||
# Middle: both arrows active, pointing at the neighbours.
|
||||
assert f'/evaluations/player/{high}' in middle
|
||||
assert f'/evaluations/player/{low}' in middle
|
||||
assert 'aria-disabled="true"' not in middle
|
||||
|
||||
# Worst: no next (right arrow disabled), previous is the middle player.
|
||||
assert 'aria-disabled="true"' in worst
|
||||
assert f'/evaluations/player/{mid}' in worst
|
||||
assert f'/evaluations/player/{high}' not in worst
|
||||
|
||||
|
||||
class TestTableView:
|
||||
def test_table_groups_by_player_and_keeps_the_comment(self, app, client, as_role, make_user):
|
||||
coach_id = as_role('coach', username='table_coach')
|
||||
player_id = make_user('player', username='table_player')
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='T',
|
||||
game='Valorant',
|
||||
date=date(2030, 1, 1),
|
||||
created_by=coach_id,
|
||||
coach_id=coach_id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
db.session.add(
|
||||
Evaluation(
|
||||
tryout_id=tryout.id,
|
||||
player_id=player_id,
|
||||
evaluator_id=coach_id,
|
||||
overall_score=7,
|
||||
comments='a distinctive comment',
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
body = client.get('/evaluations').get_data(as_text=True)
|
||||
|
||||
assert 'table_player' in body
|
||||
assert f'/evaluations/player/{player_id}' in body
|
||||
assert 'a distinctive comment' in body
|
||||
Reference in New Issue
Block a user