Author SHA1 Message Date
cedrick2711 5249331661 test docu, non fonctionnel pour l'instant
CI - Security, Lint & Tests / validate (push) Failing after 1m7s
2026-09-15 12:28:12 -04:00
cedrick2711 abf2f06642 premiere iteration de la gestion des documents
CI - Security, Lint & Tests / validate (push) Failing after 1m20s
2026-09-11 09:51:48 -04:00
cedrick2711 bd9ed4bdc7 changer le look du calendar légèrement et régler problème avec déconnection des test
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-10 10:43:47 -04:00
cedrick2711 a9b33b30f5 forcer l'affichage des nombre un jusqu'a un chiffre apres la virgule
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 13:54:51 -04:00
cedrick2711 32c112ceea Finit les changement de visualisation des evaluations
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 13:40:35 -04:00
cedrick2711 30069d782f changement visualistation des evalutations
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-09 12:15:25 -04:00
cedrick2711 d994d88c12 Fix probleme d'evaluation
CI - Security, Lint & Tests / validate (push) Failing after 1m2s
2026-09-09 09:25:17 -04:00
cedrick2711 b9791d1547 chhangement du comportement du discord bot sur modif des matchs
CI - Security, Lint & Tests / validate (push) Failing after 59s
2026-09-08 12:46:44 -04:00
cedrick2711 4a2e2f053f Changement du refresh handler, soft reset d'ajouter sur beaucoup de formulaire de modification
CI - Security, Lint & Tests / validate (push) Failing after 1m3s
2026-09-08 11:27:53 -04:00
cedrick2711 e2e2b1b07e premier fix pour le discord bot qui envoie des reminders sur des versions de matchs qui n'existent plus
CI - Security, Lint & Tests / validate (push) Failing after 1m5s
2026-09-06 13:49:07 -04:00
cedrick2711 e4a271cfd8 changer gestion des team intra tryout
CI - Security, Lint & Tests / validate (push) Failing after 1m4s
2026-09-05 14:38:31 -04:00
cedrick2711 742995d3cd fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs
CI - Security, Lint & Tests / validate (push) Failing after 1m0s
2026-09-05 12:50:48 -04:00
89 changed files with 5617 additions and 3628 deletions
-40
View File
@@ -1,40 +0,0 @@
"""Add Drive signed upload test."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
drive_test = (
b"\n def test_signed_upload_with_google_drive(\n"
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
b" ):\n"
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
b" monkeypatch.setattr(\n"
b" 'app.storage.upload_google_drive_file',\n"
b" lambda **_kwargs: 'drive-signed-456',\n"
b" )\n"
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status == 'signed'\n"
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
)
# Add before 'class TestTheHelper:'
marker = b'\nclass TestTheHelper:'
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + drive_test + t[pos:]
p.write_bytes(t)
print('Added Drive signed upload test')
else:
print('Could not find marker')
-48
View File
@@ -1,48 +0,0 @@
"""Add new tests to test_contract_uploads.py."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
marker = b'\n\nclass TestTheHelper:'
test = (
b"\n def test_a_header_only_pdf_is_refused(\n"
b" self, app, client, as_role, make_user, contract_for\n"
b" ):\n"
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status != 'signed'\n"
b" assert contract.signed_file_path is None\n\n"
)
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + test + t[pos:]
# 2. Header-only helper test at end of TestTheHelper
trailing = t.rstrip(b'\n')
idx = t.find(trailing) + len(trailing)
helper = (
b"\n def test_a_header_only_file_is_refused(self, app):\n"
b' """A stream starting with %PDF- but holding non-PDF data is\n'
b" rejected (the header check is not enough).\"\"\"\n"
b" from werkzeug.datastructures import FileStorage\n\n"
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
b" with app.test_request_context('/'):\n"
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
)
t = t[:idx] + helper + t[idx:]
p.write_bytes(t)
print('Added new tests')
+52 -4
View File
@@ -26,8 +26,10 @@ from sqlalchemy import text
from werkzeug.exceptions import HTTPException
from app import i18n
from app.api import json_endpoint
from app.extensions import babel, csrf, db, limiter, login_manager
from app.pagination import page_url
from app.responders import current_revision, is_ajax_submit
load_dotenv()
@@ -347,6 +349,42 @@ def create_app(config=None):
response.headers['X-Request-Id'] = g.get('request_id', '-')
return response
@app.before_request
def reject_stale_ajax_write():
"""Reject an AJAX edit whose view is stale, instead of overwriting.
The AJAX form handler in main.js sends the revision it last saw in the
X-Revision header. If another client has committed since then the
server's revision has moved on, and clobbering their change would be
silent last-write-wins. Answer 409 so the client asks the user to
reload the page first.
"""
if request.method != 'POST':
return None
if not is_ajax_submit():
return None
sent = request.headers.get('X-Revision')
if sent is None:
return None
try:
sent = int(sent)
except (TypeError, ValueError):
return None
if sent != current_revision():
return (
jsonify(
{
'error': 'conflict',
'message': _(
'This page was changed by someone else. Reload to '
'see the latest changes before saving.'
),
}
),
409,
)
return None
@app.url_defaults
def version_static_urls(endpoint, values):
"""Stamp every static URL with the file's modification time.
@@ -403,6 +441,7 @@ def create_app(config=None):
configure_logging(app)
from app.routes.admin import admin_bp
from app.routes.auth import auth_bp
from app.routes.evaluations import evaluations_bp
from app.routes.main import main_bp
@@ -411,8 +450,6 @@ def create_app(config=None):
from app.routes.teams import teams_bp
from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp
from app.routes.admin import admin_bp
from app.routes.objectives import objectives_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -423,7 +460,6 @@ def create_app(config=None):
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(objectives_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
@@ -533,6 +569,18 @@ def create_app(config=None):
return jsonify(health_data), 200
@app.route('/api/revision')
@json_endpoint
def data_revision():
"""The current dataset revision, for the AJAX form handler in main.js.
One primary-key lookup in app_settings. The client compares this with
the revision it last saw to decide whether a focus refresh is needed,
and sends it back with every AJAX mutation so a stale edit is rejected
with a 409 instead of overwriting someone else's change.
"""
return jsonify({'revision': current_revision()})
# =========================================================================
# Custom Error Handlers
# =========================================================================
@@ -727,4 +775,4 @@ def create_app(config=None):
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
# module defines the factory; run.py starts it for development, wsgi.py for
# production (ARCH-007).
# production (ARCH-007).
+82 -14
View File
@@ -492,6 +492,19 @@ class TeamTryoutsBot(commands.Bot):
await self._send_schedule_notification(**item['data'])
elif kind == 'one_on_one_response':
await self._send_one_on_one_response_dm(**item['data'])
elif kind == 'purge_pending':
participant_ids = item.get('data', {}).get('participant_ids', [])
if participant_ids:
changed = False
for msg_id, record in list(self.pending_requests.items()):
if (
record.get('type') == 'schedule_addition'
and record.get('id') in participant_ids
):
del self.pending_requests[msg_id]
changed = True
if changed:
self._save_pending()
else:
logger.error('Unknown queue item type %r; the notification was dropped', kind)
@@ -673,6 +686,7 @@ class TeamTryoutsBot(commands.Bot):
event_date: str,
event_time: str,
reference_id: int,
change_kind: str = 'scheduled',
) -> int:
"""Send a schedule addition notification to a player.
@@ -683,6 +697,7 @@ class TeamTryoutsBot(commands.Bot):
event_date: Date string.
event_time: Time string.
reference_id: ID of the MatchParticipant or TryoutRegistration record.
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
"""
# Look up the DB user to get their Discord user ID
from app.extensions import db
@@ -699,16 +714,29 @@ class TeamTryoutsBot(commands.Bot):
event_name = "Match" if event_type == 'match' else "Tryout"
message = (
f"📅 **{event_name} Scheduled**\n\n"
f"You have been added to the following {event_type}:\n"
f"**{event_title}**\n"
f"**Date:** {event_date}\n"
f"**Time:** {event_time}\n\n"
"Please confirm your attendance:\n"
f"{CHECK_EMOJI} - Confirm attendance\n"
f"{CROSS_EMOJI} - Decline"
)
if change_kind == 'modified':
message = (
f"📅 **{event_name} Modified**\n\n"
f"The {event_type} date and/or time has been modified for "
f"**{event_title}**.\n"
f"The new date and/or time is:\n"
f"**Date:** {event_date}\n"
f"**Time:** {event_time}\n\n"
"Please confirm your attendance:\n"
f"{CHECK_EMOJI} - Confirm attendance\n"
f"{CROSS_EMOJI} - Decline"
)
else:
message = (
f"📅 **{event_name} Scheduled**\n\n"
f"You have been added to the following {event_type}:\n"
f"**{event_title}**\n"
f"**Date:** {event_date}\n"
f"**Time:** {event_time}\n\n"
"Please confirm your attendance:\n"
f"{CHECK_EMOJI} - Confirm attendance\n"
f"{CROSS_EMOJI} - Decline"
)
msg = await self._send_dm(
db_user.discord_user_id,
@@ -992,6 +1020,12 @@ class TeamTryoutsBot(commands.Bot):
player.id,
reference_id,
)
else:
# The row no longer exists (e.g. match was edited, participant
# was deleted). Silently drop the stale pending entry so the
# reaction is not answerable again. No user-facing reply.
self._forget_pending(message_id)
return
except SQLAlchemyError:
db.session.rollback()
logger.exception(
@@ -1040,6 +1074,11 @@ class TeamTryoutsBot(commands.Bot):
db.session.delete(row)
else:
row.status = 'no_show'
else:
# The row no longer exists. Silently drop the stale pending
# entry so the reaction is not answerable again.
self._forget_pending(message_id)
return
except SQLAlchemyError:
db.session.rollback()
logger.exception(
@@ -1222,10 +1261,11 @@ class TeamTryoutsBot(commands.Bot):
async def send_match_reminder(self, player, match):
"""Send match reminder to player. True if it was delivered."""
date_str = match.date.strftime('%A, %B %d, %Y') if match.date else 'TBD'
message = (
"🔔 **Match Reminder**\n\n"
f"Your match **{match.title}** is scheduled for tomorrow:\n"
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
f"**Date:** {date_str}\n"
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
f"**Location:** {match.location or 'TBD'}\n\n"
@@ -1241,10 +1281,11 @@ class TeamTryoutsBot(commands.Bot):
async def send_tryout_reminder(self, player, tryout):
"""Send tryout reminder to player. True if it was delivered."""
date_str = tryout.date.strftime('%A, %B %d, %Y') if tryout.date else 'TBD'
message = (
"🔔 **Tryout Reminder**\n\n"
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
f"**Date:** {date_str}\n"
f"**Location:** {tryout.location or 'TBD'}\n\n"
"Please confirm your attendance in the app."
)
@@ -1314,10 +1355,11 @@ class TeamTryoutsBot(commands.Bot):
async def send_one_on_one_reminder(self, player, session):
"""Send One on One reminder to player. True if it was delivered."""
date_str = session.date.strftime('%A, %B %d, %Y') if session.date else 'TBD'
message = (
"🔔 **One on One Reminder**\n\n"
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
f"**Date:** {date_str}\n"
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
"Please prepare for your session!"
@@ -1417,8 +1459,13 @@ def send_schedule_notification(
event_date: str,
event_time: str,
reference_id: int,
change_kind: str = 'scheduled',
) -> bool:
"""Queue a schedule addition notification to be sent by the bot."""
"""Queue a schedule addition notification to be sent by the bot.
Args:
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
"""
return _enqueue(
'schedule_addition',
{
@@ -1428,11 +1475,32 @@ def send_schedule_notification(
'event_date': event_date,
'event_time': event_time,
'reference_id': reference_id,
'change_kind': change_kind,
},
description=f'the {event_type} notification for user {user_id}',
)
def purge_pending_for_participants(participant_ids):
"""Queue a purge of pending Discord entries for stale participant rows.
Called after a match edit deletes participants. The bot thread removes
matching ``schedule_addition`` pending entries so that a reaction on an
old message cannot produce a false confirmation.
Args:
participant_ids: list[int] of MatchParticipant primary keys that no
longer exist.
"""
if not participant_ids:
return None
return _enqueue(
'purge_pending',
{'participant_ids': participant_ids},
description=f'purge of {len(participant_ids)} stale participant records',
)
def send_one_on_one_response(
player_discord_id: str,
player_full_name: str,
+68 -3
View File
@@ -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',
)
+22 -17
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -415,23 +429,14 @@ def start() -> str:
# 3. Create the application database (idempotent).
_create_database()
# 4. Register cleanup (only in the *parent* process, not the reloader
# worker — the worker registers its own and tears the database down
# behind any sibling process that is still connected).
#
# The Werkzeug reloader sets WERKZEUG_RUN_MAIN=true in the worker
# process and leaves it unset (or false) in the watcher process that
# acts as the parent. We want the cleanup on the *actual process
# lifecycle*, i.e. when the watcher exits, not every time the
# reloader restarts the worker.
# 4. Register cleanup.
if not _started:
_started = True
if os.environ.get('WERKZEUG_RUN_MAIN', '').lower() != 'true':
atexit.register(_stop)
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _stop)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
atexit.register(_stop)
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _handle_stop_signal)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
return DATABASE_URL
+11 -17
View File
@@ -21,11 +21,6 @@ from app.models._constants import (
PLATFORM_CODES,
TRN_URLS,
EVALUATION_CRITERIA,
OBJECTIVE_TYPES,
OBJECTIVE_STATUSES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_CHECK_FREQUENCIES,
DEFAULT_OBJECTIVE_TEMPLATES,
)
# =========================================================================
@@ -104,16 +99,15 @@ from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
# =========================================================================
# Layer 12: player evolution objectives
# Layer 12: documents (canonical document + versions + assignments)
# =========================================================================
from app.models.objective import Objective # noqa: F401 — new table for create_all()
# =========================================================================
# Layer 13: player evolution entries (KPI measurements)
# =========================================================================
from app.models.objective_entry import ObjectiveEntry # noqa: F401
# =========================================================================
# Layer 14: objective comments (coach feedback, no existing-schema change)
# =========================================================================
from app.models.objective_comment import ObjectiveComment # noqa: F401
from app.models.document import (
ACKNOWLEDGMENT_KINDS,
ASSIGNMENT_STATUSES,
DOCUMENT_KIND_ROLES,
DOCUMENT_KINDS,
SIGNATURE_KINDS,
Document,
DocumentAssignment,
DocumentVersion,
)
-73
View File
@@ -69,79 +69,6 @@ PLATFORM_CODES = {
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
# Objective-related constants for the Player Evolution planning feature.
# Ordered lists of (value, human_label) pairs for form fields and display.
OBJECTIVE_TYPES = [
('individual', 'Individuel'),
('team', 'Équipe'),
]
OBJECTIVE_STATUSES = [
('active', 'Actif'),
('pending', 'En attente'),
('completed', 'Terminé'),
('overdue', 'En retard'),
]
OBJECTIVE_PRIORITIES = [
('low', 'Faible'),
('medium', 'Moyenne'),
('high', 'Élevée'),
]
OBJECTIVE_CHECK_FREQUENCIES = [
('weekly', 'Hebdomadaire'),
('biweekly', 'Bi-hebdomadaire'),
('monthly', 'Mensuelle'),
]
# Default templates seeded on first run.
# (priority, title, description, kpi_metric, kpi_target, kpi_unit, type, category)
DEFAULT_OBJECTIVE_TEMPLATES = [
# Quantitative
('medium', 'Améliorer le taux de headshot (HS)',
'Augmenter le pourcentage de têtes sur les kills totaux.',
'Taux de headshot', 45.0, '%', 'individual', 'Mécaniques'),
('medium', 'Augmenter le K/D ratio',
'Améliorer le ratio kills/morts sur les matchs compétitifs.',
'K/D Ratio', 1.2, '', 'individual', 'Performance'),
('medium', 'Atteindre un KDA cible',
'Maintenir un ratio kills/deaths/assists au-dessus du seuil.',
'KDA', 3.0, '', 'individual', 'Performance'),
('medium', 'Améliorer la précision',
'Augmenter le pourcentage de tirs touchés par partie.',
'Précision', 40.0, '%', 'individual', 'Mécaniques'),
('medium', 'Augmenter les CS par minute',
'Atteindre une moyenne de last-hits par minute en jeu.',
'CS/min', 8.0, '', 'individual', 'Farm'),
('medium', 'Réduire les morts par match',
'Diminuer le nombre de décès moyens par partie compétitive.',
'Morts/match', 5.0, '', 'individual', 'Survie'),
('medium', 'Augmenter les dégâts par round',
'Améliorer le damage output moyen par round joué.',
'Dégâts/round', 150.0, '', 'individual', 'Performance'),
('medium', 'Améliorer le taux de victoire',
'Augmenter le pourcentage de parties gagnées sur la saison.',
'Winrate', 55.0, '%', 'team', 'Compétitif'),
# Qualitative
('medium', 'Améliorer le positionnement',
'Travailler le placement sur la carte pour réduire les angles exposés.',
None, None, None, 'individual', 'Positionnement'),
('medium', 'Améliorer la communication',
'Être plus vocal et clair dans les callouts en équipe.',
None, None, None, 'individual', 'Communication'),
('medium', 'Développer la conscience de carte',
'Anticiper les rotations adverses et mieux lire la minimap.',
None, None, None, 'individual', 'Game Sense'),
('medium', 'Améliorer la prise de décision',
'Réduire les décisions risquées et améliorer le choix des engagements.',
None, None, None, 'individual', 'Decision'),
('medium', 'Renforcer la cohésion d\'équipe',
'Participer activement aux stratégies collectives et aux drills.',
None, None, None, 'team', 'Équipe'),
]
TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
+1 -1
View File
@@ -53,4 +53,4 @@ class AppSettings(db.Model):
@staticmethod
def set_bool(key, value):
"""Store a boolean setting as 'true' / 'false'."""
AppSettings.set(key, 'true' if value else 'false')
AppSettings.set(key, 'true' if value else 'false')
+173
View File
@@ -0,0 +1,173 @@
"""Documents, versions, and per-user assignments.
The old contract flow stored one uploaded file per player even when the
file was identical for everyone, and Drive received no team/role structure
at all. This module replaces that with three two-level concepts:
- ``Document`` the canonical document (e.g. ``Contrat joueur``).
One row per kind; the file is stored once, never
once per person.
- ``DocumentVersion`` an immutable published revision. Uploading a new
PDF is publishing a new version, which is the twice
a year update cycle.
- ``DocumentAssignment`` a per-person obligation. ``UNIQUE(document_id,
user_id)`` is what guarantees a player signs a
document exactly once, whatever the number of teams
they sit on.
Signing obligations are role-scoped, not team-scoped: the team folder in
Drive is a *filing* concern for the signed copy; the obligation itself is
created once per eligible person.
"""
from app.extensions import db
from app.time_utils import utc_now_naive
#: Every document kind this application can hold.
DOCUMENT_KINDS = (
'player',
'coach',
'manager',
'team',
'code_of_conduct',
'ethics_code',
)
#: Kinds whose completion is an uploaded signed PDF.
SIGNATURE_KINDS = ('player', 'coach', 'manager', 'team')
#: Kinds whose completion is read-and-acknowledge only (no file).
ACKNOWLEDGMENT_KINDS = ('code_of_conduct', 'ethics_code')
#: Kind -> set of roles eligible to complete it. ``team`` and the two
#: codes span roles; the three role contracts are single-role by nature.
DOCUMENT_KIND_ROLES = {
'player': ('player',),
'coach': ('coach',),
'manager': ('manager',),
'team': ('player', 'coach', 'manager'),
'code_of_conduct': ('player', 'coach', 'manager'),
'ethics_code': ('player', 'coach', 'manager'),
}
#: Possible values for DocumentAssignment.status.
ASSIGNMENT_STATUSES = ('pending', 'signed', 'acknowledged')
class Document(db.Model):
"""A canonical document, stored once, completed by eligible people."""
__tablename__ = 'documents'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
kind = db.Column(db.String(30), nullable=False, default='player')
#: Admins choose when the document is downloadable by team members. The
#: old ``document_uploads_enabled`` global toggle is replaced by this
#: per-document switch for the new flow.
is_published = db.Column(db.Boolean, nullable=False, default=False)
current_version_id = db.Column(db.Integer, db.ForeignKey('document_versions.id'), nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=utc_now_naive)
versions = db.relationship(
'DocumentVersion',
backref='document',
lazy='dynamic',
order_by='DocumentVersion.version',
foreign_keys='DocumentVersion.document_id',
)
current_version = db.relationship(
'DocumentVersion',
foreign_keys=[current_version_id],
uselist=False,
)
assignments = db.relationship(
'DocumentAssignment',
back_populates='document',
lazy='dynamic',
order_by='DocumentAssignment.created_at',
foreign_keys='DocumentAssignment.document_id',
)
creator = db.relationship('User', foreign_keys=[created_by])
@property
def requires_signature(self):
"""Whether completion means an uploaded signed PDF."""
return self.kind in SIGNATURE_KINDS
def completion_counts(self):
"""(completed, pending) over every assignment of this document.
``completed`` covers both signed and acknowledged assignments.
"""
rows = (
db.session.query(DocumentAssignment.status, db.func.count())
.filter(DocumentAssignment.document_id == self.id)
.group_by(DocumentAssignment.status)
.all()
)
totals = dict(rows)
completed = totals.get('signed', 0) + totals.get('acknowledged', 0)
return completed, totals.get('pending', 0)
def next_version_number(self):
"""The version number a freshly published revision should carry."""
latest = self.versions.order_by(DocumentVersion.version.desc()).first()
return (latest.version + 1) if latest else 1
class DocumentVersion(db.Model):
"""One immutable published revision of a document."""
__tablename__ = 'document_versions'
id = db.Column(db.Integer, primary_key=True)
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
version = db.Column(db.Integer, nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
stored_filename = db.Column(db.String(255), nullable=False)
#: ``gdrive://<file-id>`` for Drive storage, or a path relative to the
#: document root for the local backend.
file_path = db.Column(db.String(500), nullable=False)
notes = db.Column(db.Text, nullable=True)
published_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
published_at = db.Column(db.DateTime, default=utc_now_naive)
publisher = db.relationship('User', foreign_keys=[published_by])
__table_args__ = (
db.UniqueConstraint('document_id', 'version', name='unique_document_version'),
)
class DocumentAssignment(db.Model):
"""One person's obligation to sign or acknowledge a document."""
__tablename__ = 'document_assignments'
id = db.Column(db.Integer, primary_key=True)
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
completed_version_id = db.Column(
db.Integer, db.ForeignKey('document_versions.id'), nullable=True
)
status = db.Column(db.String(20), nullable=False, default='pending')
signed_filename = db.Column(db.String(255), nullable=True)
signed_file_path = db.Column(db.String(500), nullable=True)
completed_at = db.Column(db.DateTime, nullable=True)
#: The team whose Drive folder files the signed copy. A member on
#: several teams still files under one canonical team only.
filed_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
created_at = db.Column(db.DateTime, default=utc_now_naive)
document = db.relationship('Document', back_populates='assignments', foreign_keys=[document_id])
user = db.relationship('User', foreign_keys=[user_id], backref='document_assignments')
completed_version = db.relationship('DocumentVersion', foreign_keys=[completed_version_id])
filed_team = db.relationship('OrgTeam', foreign_keys=[filed_team_id])
__table_args__ = (db.UniqueConstraint('document_id', 'user_id', name='unique_document_user'),)
@property
def completed(self):
return self.status in ('signed', 'acknowledged')
+1 -1
View File
@@ -11,7 +11,7 @@ class BaseMatch(db.Model):
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
date = db.Column(db.Date, nullable=False)
date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True)
location = db.Column(db.String(200), nullable=True)
-84
View File
@@ -1,84 +0,0 @@
"""Player evolution objective — individual or team KPI-based goal."""
from app.extensions import db
from app.time_utils import utc_now_naive
class Objective(db.Model):
"""Player evolution objective — a KPI-driven plan for individual players
or teams, created and tracked by coaching staff.
Covers the full lifecycle: draft (pending), active, completed, overdue.
Supports arbitrary KPI metrics, optional tags, and verifiable progress.
"""
__tablename__ = 'objectives'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Category
type = db.Column(
db.String(20),
nullable=False,
default='individual',
)
status = db.Column(
db.String(20),
nullable=False,
default='active',
)
priority = db.Column(
db.String(20),
nullable=False,
default='medium',
)
category = db.Column(db.String(50), nullable=True)
is_template = db.Column(db.Boolean, default=False, nullable=False)
# Assignment (individual player / org team)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
# KPI fields
kpi_metric = db.Column(db.String(120), nullable=True)
kpi_target = db.Column(db.Numeric(10, 2), nullable=True)
kpi_unit = db.Column(db.String(20), nullable=True)
kpi_precision = db.Column(db.Integer, nullable=True)
# Timeline
start_date = db.Column(db.Date, nullable=True)
due_date = db.Column(db.Date, nullable=False)
check_frequency = db.Column(db.String(30), nullable=True)
# Progress tracking (0–100)
progress = db.Column(db.Integer, default=0, nullable=False)
# Misc
tags = db.Column(db.String(255), nullable=True)
# Who created it
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
player = db.relationship(
'User',
foreign_keys=[player_id],
backref=db.backref('objectives', lazy='dynamic'),
)
team = db.relationship(
'OrgTeam',
foreign_keys=[team_id],
backref=db.backref('objectives', lazy='dynamic'),
)
creator = db.relationship(
'User',
foreign_keys=[created_by],
)
-35
View File
@@ -1,35 +0,0 @@
"""Coach comments on a specific objective."""
from app.extensions import db
from app.time_utils import utc_now_naive
class ObjectiveComment(db.Model):
"""A coach's comment on a player-evolution objective.
This is a separate table from PersonalNote — no existing schema
is altered. The objective-form page also *reads* PersonalNote and
TeamNote for context, but never writes to those tables.
"""
__tablename__ = 'objective_comments'
id = db.Column(db.Integer, primary_key=True)
objective_id = db.Column(
db.Integer,
db.ForeignKey('objectives.id'),
nullable=False,
)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
objective = db.relationship(
'Objective',
backref=db.backref('comments', lazy='dynamic', order_by='ObjectiveComment.created_at'),
)
coach = db.relationship('User', foreign_keys=[coach_id])
-39
View File
@@ -1,39 +0,0 @@
"""Player evolution — KPI measurement entries for objectives."""
from app.extensions import db
from app.time_utils import utc_now_naive
class ObjectiveEntry(db.Model):
"""A single KPI measurement recorded for an objective.
Each entry records a value at a point in time so the evolution
graph can plot historical data and compute a trend line.
"""
__tablename__ = 'objective_entries'
id = db.Column(db.Integer, primary_key=True)
objective_id = db.Column(
db.Integer,
db.ForeignKey('objectives.id'),
nullable=False,
)
value = db.Column(db.Numeric(10, 2), nullable=True)
recorded_at = db.Column(db.DateTime, nullable=False, default=utc_now_naive)
note = db.Column(db.Text, nullable=True)
# Who recorded it
recorded_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
objective = db.relationship(
'Objective',
backref=db.backref('entries', lazy='dynamic', order_by='ObjectiveEntry.recorded_at'),
)
recorder = db.relationship('User', foreign_keys=[recorded_by])
+1 -1
View File
@@ -12,7 +12,7 @@ class OneOnOneRequest(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
date = db.Column(db.Date, nullable=False)
date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True)
+7 -3
View File
@@ -13,21 +13,25 @@ class Tryout(db.Model):
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
game = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False)
date = db.Column(db.Date, nullable=False, index=True)
end_date = db.Column(db.Date, nullable=True)
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='upcoming')
max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
manager_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
manager = db.relationship(
'User', foreign_keys=[manager_id], backref='managed_tryouts'
) # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
-124
View File
@@ -1,124 +0,0 @@
"""Objective analytics — linear regression for KPI prediction.
Given a set of ObjectiveEntry measurements, computes a simple linear
regression (y = mx + b, where x = days since first entry) and answers:
- **predicted_achievement_date**: the date when the trend line crosses
the KPI target value (or None if the trend is flat or negative).
- **confidence_score**: a heuristic 0.0–1.0 based on sample count and
the R² coefficient of determination.
- **predicted_value_at_date(date)**: the value the trend predicts at a
given future date.
"""
from __future__ import annotations
import datetime
def _to_days(timestamps: list[datetime.datetime]) -> list[float]:
"""Convert datetimes to float days since the first entry."""
if not timestamps:
return []
t0 = timestamps[0]
return [(t - t0).total_seconds() / 86400.0 for t in timestamps]
def _r_squared(xs, ys, slope, intercept):
"""Coefficient of determination (R²)."""
if len(xs) < 2:
return 0.0
mean_y = sum(ys) / len(ys)
ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys, strict=True))
ss_tot = sum((y - mean_y) ** 2 for y in ys)
if ss_tot == 0:
return 1.0
return 1.0 - ss_res / ss_tot
def compute_trend(
timestamps: list[datetime.datetime],
values: list[float],
target: float | None,
) -> dict:
"""Compute a linear regression trend and prediction.
Args:
timestamps: Entry dates.
values: Entry values (must be same length as timestamps).
target: KPI target value, or None for qualitative objectives.
Returns:
dict with keys:
slope, intercept, r_squared, confidence,
predicted_achievement_days, predicted_achievement_date,
predicted_value_at_due, target_value
"""
n = len(timestamps)
if n < 2 or not values:
return {
'slope': 0.0,
'intercept': float(values[0]) if values else 0.0,
'r_squared': 0.0,
'confidence': 0.0,
'predicted_achievement_days': None,
'predicted_achievement_date': None,
'predicted_value_at_due': None,
'target_value': target,
}
xs = _to_days(timestamps)
ys = [float(v) for v in values]
n_f = float(n)
sum_x = sum(xs)
sum_y = sum(ys)
sum_xy = sum(x * y for x, y in zip(xs, ys, strict=True))
sum_xx = sum(x * x for x in xs)
denom = n_f * sum_xx - sum_x * sum_x
if abs(denom) < 1e-12:
return {
'slope': 0.0,
'intercept': sum_y / n_f,
'r_squared': 0.0,
'confidence': 0.0,
'predicted_achievement_days': None,
'predicted_achievement_date': None,
'predicted_value_at_due': None,
'target_value': target,
}
slope = (n_f * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n_f
r2 = _r_squared(xs, ys, slope, intercept)
# Confidence heuristic: combination of R² and sample count
sample_confidence = min(1.0, n / 10.0) # 10+ entries = full confidence
confidence = sample_confidence * r2
# Days from first entry to target (if slope > 0 and target > intercept)
predicted_achievement_days = None
predicted_achievement_date = None
if target is not None and slope > 1e-12:
days_to_target = (target - intercept) / slope
if days_to_target >= 0:
predicted_achievement_days = days_to_target
predicted_achievement_date = timestamps[0] + datetime.timedelta(
days=days_to_target,
)
return {
'slope': slope,
'intercept': intercept,
'r_squared': round(r2, 4),
'confidence': round(confidence, 4),
'predicted_achievement_days': (
round(predicted_achievement_days, 1) if predicted_achievement_days is not None else None
),
'predicted_achievement_date': (
predicted_achievement_date.isoformat() if predicted_achievement_date else None
),
'predicted_value_at_due': None,
'target_value': target,
}
-70
View File
@@ -343,73 +343,3 @@ def coach_manages_tryout(coach, tryout):
if tryout.target_org_team_id:
return tryout.target_org_team_id in coach_org_team_ids(coach)
return False
# ---------------------------------------------------------------------------
# Player Evolution — Objectives
# ---------------------------------------------------------------------------
def can_view_objectives(user):
"""Whether the user may view the objectives list.
Everyone except regular players without any context can browse objectives.
Players see only their own.
"""
return True
def can_manage_objectives(user):
"""Whether the user may create, edit, or delete objectives.
Presidents, managers, and coaches may manage objectives. Players are
read-only.
"""
from app.models import Admin, Coach, Manager
return isinstance(user, (Admin, Manager, Coach))
def can_manage_objective(user, objective):
"""Whether the user may edit or delete a specific objective.
Presidents and managers may manage any objective. A coach may manage
objectives assigned to their players or their teams.
"""
from app.models import Admin, Coach, Manager
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
if objective.player_id and objective.player_id in coach_player_ids(user):
return True
if objective.team_id and objective.team_id in coach_org_team_ids(user):
return True
return False
def can_view_objective(user, objective):
"""Whether the user may view this objective's detail page.
Presidents, managers, and coaches may view any they manage.
A player may view only their own assigned objectives.
Everyone else may view objectives they created.
"""
from app.models import Admin, Coach, Manager, Player
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
return can_manage_objective(user, objective)
if isinstance(user, Player):
return objective.player_id == user.id
return objective.created_by == user.id
def can_add_objective_entry(user, objective):
"""Whether the user may record a KPI measurement on this objective.
The same as can_view_objective — the assigned player may record
their own measurements on their own objectives.
"""
return can_view_objective(user, objective)
+84
View File
@@ -0,0 +1,84 @@
"""Helpers for mutating routes that should not force a full page reload.
The site is server-rendered, and every mutating route used to end with
``return redirect(url_for(...))``, so each change reloaded the whole page:
scroll reset, open dialogs closed, and chaining several edits meant waiting
for a full render between each one.
These helpers let a route answer an AJAX submit (the ``data-ajax`` forms in
main.js) with a small JSON payload instead of a redirect, while the plain
HTML form path keeps behaving exactly as before. The client then fetches the
page again and swaps only the content region, so the page never reloads.
The counterpart that performs the swap lives in app/static/js/main.js.
"""
from flask import get_flashed_messages, jsonify, redirect, request, session
from app.models import AppSettings
#: Key holding a coarse, monotonically increasing stamp for the whole dataset.
#: It advances after every committed mutation so a stale client can be told
#: (with a 409) that someone else has changed what it is looking at.
REVISION_KEY = 'data_revision'
def is_ajax_submit():
"""Whether this POST came from the fetch-based form handler in main.js."""
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def current_revision():
"""The current revision stamp, as an int (0 when nothing has been bumped)."""
try:
return int(AppSettings.get(REVISION_KEY, '0') or '0')
except (TypeError, ValueError):
return 0
def bump_revision():
"""Advance the revision stamp after a committed change.
Stored through AppSettings so it survives restarts and is shared by every
worker, unlike a module-level counter.
"""
AppSettings.set(REVISION_KEY, str(current_revision() + 1))
def _flashed_success():
"""Whether a success flash is pending, without consuming it.
The HTML path still redirects and must leave the flash for the next page
to render; peeking at the session is the only way to know without clearing
it. Only a successful commit advances the revision -- a rejected edit must
not make other users reload for nothing.
"""
flashes = session.get('_flashes', [])
return any(category == 'success' for category, _message in flashes)
def respond_after_change(redirect_url):
"""Answer a committed (or rejected) mutation.
For a normal HTML form submit this is exactly the ``redirect(...)`` the
routes used to return. For an AJAX submit it returns JSON telling the
client where to fetch the refreshed fragment and which flash messages to
show, so the page can update in place instead of reloading.
"""
if _flashed_success():
bump_revision()
if is_ajax_submit():
messages = [
{'category': category, 'text': message}
for category, message in get_flashed_messages(with_categories=True)
]
return jsonify(
{
'ok': True,
'redirect': redirect_url,
'messages': messages,
'revision': current_revision(),
}
)
return redirect(redirect_url)
+255 -38
View File
@@ -6,23 +6,53 @@ to administrators.
"""
import os
import uuid
from datetime import datetime
from flask import (
Blueprint, render_template, redirect, url_for, flash, request,
Blueprint,
flash,
redirect,
render_template,
request,
send_file,
url_for,
)
from flask_login import login_required, current_user
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 (
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
DOCUMENT_KIND_ROLES,
DOCUMENT_KINDS,
Admin,
AppSettings,
AuditLog,
BackupRecord,
Document,
DocumentAssignment,
DocumentVersion,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
Tryout,
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 (
BACKUP_DIR, BackupError, backup_database, backup_documents,
create_backup_dir, parse_database_url, verify_backup,
BackupError,
backup_database,
create_backup_dir,
parse_database_url,
)
from app.validators import DocumentCreateSchema
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -56,6 +86,7 @@ def _log(action, details=None):
# Dashboard
# ---------------------------------------------------------------------------
@admin_bp.route('')
@login_required
def dashboard():
@@ -96,6 +127,7 @@ def dashboard():
# Backups
# ---------------------------------------------------------------------------
def _create_backup_record(backup_type='manual', notes=None):
"""Run a database backup and return a BackupRecord, or raise BackupError."""
create_backup_dir()
@@ -130,11 +162,11 @@ def create_backup():
except BackupError as e:
flash(f'Backup failed: {e}', 'danger')
_log('backup_failed', f'Error: {e}')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
flash(f'Backup created successfully: {record.filename}', 'success')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/backup/<int:backup_id>/download')
@@ -147,7 +179,7 @@ def download_backup(backup_id):
record = BackupRecord.query.get_or_404(backup_id)
if not os.path.exists(record.file_path):
flash('Backup file is missing from disk.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
_log('backup_downloaded', f'File: {record.filename}')
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
@@ -166,13 +198,13 @@ def delete_backup(backup_id):
os.remove(record.file_path)
except OSError as e:
flash(f'Could not remove backup file: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
_log('backup_deleted', f'File: {record.filename}')
db.session.delete(record)
db.session.commit()
flash(f'Backup {record.filename} deleted.', 'success')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
@@ -189,32 +221,41 @@ def restore_backup(backup_id):
record = BackupRecord.query.get_or_404(backup_id)
if not os.path.exists(record.file_path):
flash('Backup file is missing from disk.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# 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,
)
except BackupError as e:
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# Restore using pg_restore
import subprocess
from app.supporting_scripts.backup import (
PG_RESTORE, dump_environment, parse_database_url,
PG_RESTORE,
dump_environment,
parse_database_url,
)
conn = parse_database_url(os.getenv('DATABASE_URL'))
cmd = [
PG_RESTORE,
'--host', conn['host'],
'--port', conn['port'],
'--username', conn['user'],
'--dbname', conn['dbname'],
'--clean', '--if-exists', '--no-owner',
'--host',
conn['host'],
'--port',
conn['port'],
'--username',
conn['user'],
'--dbname',
conn['dbname'],
'--clean',
'--if-exists',
'--no-owner',
record.file_path,
]
try:
@@ -230,20 +271,21 @@ def restore_backup(backup_id):
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
flash(f'Restore failed: {e}', 'danger')
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
_log('backup_restored', f'File: {record.filename}')
flash(
f'Backup {record.filename} restored successfully. The database has been rolled back.',
'success',
)
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Tryouts open/close toggle
# ---------------------------------------------------------------------------
@admin_bp.route('/toggle-tryouts', methods=['POST'])
@login_required
def toggle_tryouts():
@@ -258,13 +300,14 @@ def toggle_tryouts():
state = 'opened' if new_value else 'closed'
_log('tryouts_toggled', f'Tryouts {state}')
flash(f'Tryouts are now {state}.', 'success')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Season lifecycle
# ---------------------------------------------------------------------------
@admin_bp.route('/season/start', methods=['POST'])
@login_required
def start_season():
@@ -274,19 +317,19 @@ def start_season():
if AppSettings.get_bool('season_active', default=False):
flash('A season is already active. End it before starting a new one.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
name = request.form.get('season_name', '').strip()
start_date = request.form.get('season_start', '').strip()
if not start_date:
flash('A season start date is required.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
try:
datetime.strptime(start_date, '%Y-%m-%d')
except ValueError:
flash('Invalid season start date format.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
AppSettings.set('season_name', name or 'Untitled Season')
AppSettings.set('season_start', start_date)
@@ -295,7 +338,7 @@ def start_season():
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/season/end', methods=['POST'])
@@ -307,18 +350,18 @@ def end_season():
if not AppSettings.get_bool('season_active', default=False):
flash('No season is currently active.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
end_date = request.form.get('season_end', '').strip()
if not end_date:
flash('A season end date is required.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
try:
datetime.strptime(end_date, '%Y-%m-%d')
except ValueError:
flash('Invalid season end date format.', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
name = AppSettings.get('season_name')
AppSettings.set('season_end', end_date)
@@ -326,7 +369,9 @@ def end_season():
_log('season_ended', f'Season: {name}, End: {end_date}')
flash(f'Season "{name}" has ended.', 'success')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
@login_required
def toggle_document_uploads():
@@ -341,16 +386,14 @@ def toggle_document_uploads():
state = 'enabled' if new_value else 'disabled'
_log('document_uploads_toggled', f'Document uploads {state}')
flash(f'Document uploads are now {state}.', 'success')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Wipe teams for new season
# ---------------------------------------------------------------------------
@admin_bp.route('/teams/wipe', methods=['POST'])
@login_required
def wipe_teams():
@@ -366,7 +409,7 @@ def wipe_teams():
confirm = request.form.get('confirm', '').strip()
if confirm != 'WIPE':
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# Safety backup before destructive operation
try:
@@ -376,7 +419,7 @@ def wipe_teams():
)
except BackupError as e:
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
roster_count = TeamPlayer.query.count()
match_count = TeamMatch.query.count()
@@ -393,4 +436,178 @@ def wipe_teams():
f'and {match_count} regular-season matches.',
'success',
)
return redirect(url_for('admin.dashboard'))
return respond_after_change(url_for('admin.dashboard'))
# ---------------------------------------------------------------------------
# Documents (canonical document store)
# ---------------------------------------------------------------------------
@admin_bp.route('/documents')
@login_required
def documents():
"""List every document with its published flag and completion counts."""
if not require_admin():
return redirect(url_for('main.dashboard'))
documents = Document.query.order_by(Document.kind, Document.title).all()
return render_template(
'pages/admin_documents.html',
documents=documents,
kinds=DOCUMENT_KINDS,
kind_roles=DOCUMENT_KIND_ROLES,
)
@admin_bp.route('/documents/create', methods=['POST'])
@login_required
def create_document():
"""Create a new canonical document (no file yet; publish one next)."""
if not require_admin():
return redirect(url_for('main.dashboard'))
try:
data = DocumentCreateSchema().load(request.form)
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('admin.documents'))
document = Document(
title=data['title'],
kind=data['kind'],
created_by=current_user.id,
)
db.session.add(document)
db.session.commit()
_log('document_created', f'{data["kind"]}: {data["title"]}')
flash(_('Document "%(title)s" created.', title=data['title']), 'success')
return respond_after_change(url_for('admin.documents'))
@admin_bp.route('/documents/<int:doc_id>/publish-version', methods=['POST'])
@login_required
def publish_document_version(doc_id):
"""Store a new PDF once and record it as the current version."""
if not require_admin():
return redirect(url_for('main.dashboard'))
document = db.get_or_404(Document, doc_id)
file = request.files.get('version_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error:
flash(error, 'danger')
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
original_filename = secure_filename(file.filename)
stored_filename = f'{uuid.uuid4()}.pdf'
try:
stored_path = store_master_document(file, stored_filename)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
version = DocumentVersion(
document_id=document.id,
version=document.next_version_number(),
original_filename=original_filename,
stored_filename=stored_filename,
file_path=stored_path,
notes=request.form.get('notes', '').strip() or None,
published_by=current_user.id,
)
db.session.add(version)
db.session.flush()
document.current_version_id = version.id
db.session.commit()
backfill_assignments_for_document(document)
_log('document_version_published', f'{document.title} v{version.version}')
flash(
_(
'Version %(number)s of "%(title)s" published.',
number=version.version,
title=document.title,
),
'success',
)
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
@admin_bp.route('/documents/<int:doc_id>/toggle-published', methods=['POST'])
@login_required
def toggle_document_published(doc_id):
"""Flip whether team members can see and download the document."""
if not require_admin():
return redirect(url_for('main.dashboard'))
document = db.get_or_404(Document, doc_id)
document.is_published = not document.is_published
db.session.commit()
if document.is_published:
# Enabling a document must also backfill anyone who is nowadays
# eligible but was not when versions were published earlier.
created = backfill_assignments_for_document(document)
_log('document_published', f'{document.title} (assignments: {created})')
flash(
_('"%(title)s" is now downloadable by team members.', title=document.title),
'success',
)
else:
_log('document_unpublished', document.title)
flash(
_('"%(title)s" is no longer downloadable.', title=document.title),
'success',
)
return respond_after_change(url_for('admin.documents'))
@admin_bp.route('/documents/version/<int:version_id>/download')
@login_required
def download_document_version(version_id):
"""Admin download of a stored document revision."""
if not require_admin():
return redirect(url_for('main.dashboard'))
version = db.get_or_404(DocumentVersion, version_id)
try:
document = open_document(version.file_path)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('admin.documents'))
return send_file(document, as_attachment=True, download_name=version.original_filename)
@admin_bp.route('/documents/<int:doc_id>')
@login_required
def document_detail(doc_id):
"""Revisions and per-person completion for one document."""
if not require_admin():
return redirect(url_for('main.dashboard'))
document = db.get_or_404(Document, doc_id)
versions = document.versions.order_by(DocumentVersion.version).all()
assignments = (
db.session.query(DocumentAssignment, User)
.join(User, User.id == DocumentAssignment.user_id)
.filter(DocumentAssignment.document_id == document.id)
.order_by(User.username)
.all()
)
return render_template(
'pages/admin_document_detail.html',
document=document,
versions=versions,
assignments=assignments,
kinds=DOCUMENT_KINDS,
)
+326 -112
View File
@@ -3,33 +3,33 @@
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 flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
)
from sqlalchemy import func
from sqlalchemy import false, func, select
from sqlalchemy.orm import aliased
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
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')
@@ -43,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)
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'),
)
avg_scores = (
db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.filter(Evaluation.id.in_(visible_ids))
.group_by(Evaluation.player_id)
.all()
)
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,
'last_eval': row.last_eval,
'evaluations': [],
}
)
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()
)
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,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
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)
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 {}
)
player_scores = {}
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,
)
@@ -157,12 +348,12 @@ def evaluate_player(tryout_id, player_id):
)
if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id,
@@ -216,7 +407,7 @@ def evaluate_player(tryout_id, player_id):
evaluation.position_recommendation = data['position_recommendation']
db.session.commit()
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_evaluation_form()
@@ -291,23 +482,31 @@ def batch_evaluate(tryout_id):
player = User.query.get(pid)
if not player or not isinstance(player, Player):
continue
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=pid,
).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=pid,
).first()
is not None
)
if not is_registered:
continue
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.id,
).first()
existing_scores = {
field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA
}
players.append({
'player': player,
'existing': existing,
'existing_scores': existing_scores,
})
players.append(
{
'player': player,
'existing': existing,
'existing_scores': existing_scores,
}
)
if not players:
flash('No valid players selected for evaluation.', 'danger')
@@ -315,32 +514,47 @@ def batch_evaluate(tryout_id):
if request.method == 'POST':
saved = 0
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
try:
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id,
player_id=pid,
evaluator_id=current_user.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')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/batch_evaluate.html',
tryout=tryout, players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS)
return render_template(
'pages/batch_evaluate.html',
tryout=tryout,
players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS,
)
+229 -97
View File
@@ -5,7 +5,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons.
from datetime import datetime, timedelta
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask import Blueprint, flash, jsonify, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
@@ -26,11 +26,14 @@ from app.models import (
PlayerDisponibility,
Scout,
Team,
TeamMatch,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema
@@ -74,6 +77,25 @@ def default_end_time(date, start_time):
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
def calendar_event_time_fields(date, start_time, end_time):
"""FullCalendar date/time fields for a match.
A date-only event renders in the all-day row; a ``start``/``end`` with a
time places it on the clock for the same calendar day. Matches, team
matches and one-on-one sessions are stored as a date plus optional time
columns, so the feed translates that into the shape FullCalendar expects.
"""
if not start_time:
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
fields = {
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
'allDay': False,
}
if end_time:
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
return fields
def create_participants(match, data):
"""Attach participants to a match, per its type.
@@ -165,13 +187,17 @@ def api_events():
president — and then issue one MatchParticipant query per match to find
out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation.
Tryout matches, regular-season team matches and approved one-on-one
sessions share this feed. Timed rows carry `start`/`end` so
FullCalendar places them on the clock instead of the all-day row.
"""
events = []
tryouts = get_visible_tryouts_for_user()
window_start, window_end = calendar_window(request.args)
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start:
query = query.filter(Match.date >= window_start)
@@ -198,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,
@@ -225,30 +250,69 @@ def api_events():
user_participant = mine_by_match.get(match.id)
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed
if user_participant
else False,
},
}
)
event = {
'id': f'match_{match.id}',
'title': match.title,
'type': 'match',
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed
if user_participant
else False,
},
}
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
events.append(event)
# Regular-season team matches: a separate table from tryout matches, but
# the same calendar. Visible to the roles that can see the org team.
if isinstance(current_user, (Admin, Manager)):
team_query = TeamMatch.query
elif isinstance(current_user, (Coach, Player)):
team_ids = [team.id for team in visible_org_teams(current_user)]
team_query = (
TeamMatch.query.filter(TeamMatch.org_team_id.in_(team_ids))
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
team_query = TeamMatch.query.filter(TeamMatch.id == -1)
if window_start:
team_query = team_query.filter(TeamMatch.date >= window_start)
if window_end:
team_query = team_query.filter(TeamMatch.date <= window_end)
for team_match in team_query.options(joinedload(TeamMatch.org_team)).all():
start_time_str = team_match.start_time.strftime('%H:%M') if team_match.start_time else None
end_time_str = team_match.end_time.strftime('%H:%M') if team_match.end_time else None
event = {
'id': f'team_match_{team_match.id}',
'title': team_match.title,
'type': 'team_match',
'extendedProps': {
'location': team_match.location or 'TBD',
'status': team_match.status,
'description': team_match.description or '',
'org_team': team_match.org_team.name if team_match.org_team else '',
'opponent': team_match.opponent or '',
'start_time': start_time_str,
'end_time': end_time_str,
},
}
event.update(
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
)
events.append(event)
# Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player):
@@ -263,23 +327,21 @@ def api_events():
one_on_ones = []
for ooo in one_on_ones:
events.append(
{
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
}
)
event = {
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'type': 'one_on_one',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
}
event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
events.append(event)
return jsonify(events)
@@ -351,25 +413,24 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str,
'end_time': end_time_str,
},
}
)
event = {
'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,
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str,
'end_time': end_time_str,
},
}
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
events.append(event)
return jsonify(events)
@@ -381,11 +442,11 @@ def create_match(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all()
all_players = registered_players(tryout_id)
@@ -443,7 +504,7 @@ def create_match(tryout_id):
)
flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return rerender()
@@ -457,11 +518,11 @@ def edit_match(match_id):
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('matches.calendar'))
return respond_after_change(url_for('matches.calendar'))
if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
all_players = registered_players(tryout.id)
@@ -505,6 +566,12 @@ def edit_match(match_id):
flash_validation_errors(err)
return rerender()
# Snapshot the date/time before we overwrite them, so we can detect
# whether only these fields changed and choose the right Discord message.
old_date = match.date
old_start = match.start_time
old_end = match.end_time
# Assigned only once the whole form has been accepted. Assigning as
# each field was read meant a form rejected halfway had already
# changed the record in the session.
@@ -516,41 +583,106 @@ def edit_match(match_id):
match.location = data['location']
match.status = data['status']
time_changed = (
match.date != old_date or match.start_time != old_start or match.end_time != old_end
)
# Build the desired participant set and reconcile it against the
# existing participants, rather than deleting and recreating everyone.
# This preserves attendance_confirmed for players who stay.
desired = set()
if match.match_type == 'team_vs_team':
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
for team_id in (data['team1_id'], data['team2_id']):
if team_id:
for member in TeamMember.query.filter_by(team_id=team_id).all():
desired.add(member.player_id)
elif match.match_type == 'player_vs_player':
for pid in data['team1_player_ids']:
desired.add(pid)
for pid in data['team2_player_ids']:
desired.add(pid)
else:
for pid in data['player_ids']:
desired.add(pid)
existing = {
p.player_id: p for p in MatchParticipant.query.filter_by(match_id=match.id).all()
}
removed_ids = []
notified_player_ids = []
notified_participant_ids = []
kept_ids = set()
if match.match_type == 'team_vs_team':
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
if teams_changed:
MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data)
for player_id, participant in list(existing.items()):
if player_id in desired:
kept_ids.add(player_id)
notified_player_ids.append(player_id)
notified_participant_ids.append(participant.id)
else:
# Same teams: the roster stands, but everyone is told again,
# because the date or the time may have moved.
for team_id in (match.team1_id, match.team2_id):
if team_id:
notified_player_ids.extend(
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
)
else:
MatchParticipant.query.filter_by(match_id=match.id).delete()
notified_player_ids, notified_participant_ids = create_participants(match, data)
removed_ids.append(participant.id)
db.session.delete(participant)
# New players get new participant rows.
for player_id in desired - set(existing.keys()):
participant = MatchParticipant(match_id=match.id, player_id=player_id)
db.session.add(participant)
db.session.flush()
notified_player_ids.append(player_id)
notified_participant_ids.append(participant.id)
kept_ids.add(player_id)
# When the date or time changed, reset attendance on everyone who
# stayed. They'll be asked to re-confirm via the modified message.
if time_changed:
for participant in MatchParticipant.query.filter_by(match_id=match.id).all():
if participant.player_id in kept_ids:
participant.attendance_confirmed = False
db.session.commit()
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=zip_participants(notified_player_ids, notified_participant_ids),
fallback_id=match.id,
)
# Purge Discord pending entries for participants that no longer exist.
if removed_ids:
from app.discord_bot import purge_pending_for_participants
purge_pending_for_participants(removed_ids)
# Decide whether and what to notify.
if time_changed:
# All current participants get the "modified" message.
all_current = list(zip(notified_player_ids, notified_participant_ids))
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=all_current,
fallback_id=match.id,
change_kind='modified',
)
elif desired - set(existing.keys()):
# Only the newly added players are told about the match.
new_player_ids = desired - set(existing.keys())
new_pairs = [
(pid, nid)
for pid, nid in zip(notified_player_ids, notified_participant_ids)
if pid in new_player_ids
]
notify_participants(
title=match.title,
date=match.date,
start_time=match.start_time,
end_time=match.end_time,
participants=new_pairs,
fallback_id=match.id,
change_kind='scheduled',
)
# else: nothing changed that needs a notification.
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return rerender()
@@ -586,10 +718,10 @@ def delete_match(match_id):
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('matches.calendar'))
return respond_after_change(url_for('matches.calendar'))
if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Notes outlive the match they were taken during: a coach's observation
# keeps its value, and deleting it here would destroy unrelated content.
@@ -602,7 +734,7 @@ def delete_match(match_id):
db.session.delete(match)
db.session.commit()
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
def get_players_available_at_time(date_str, time_str):
-661
View File
@@ -1,661 +0,0 @@
"""Player Evolution — Objective management routes.
Allows coaches and staff to create, update, delete, and list
KPI-driven objectives for individual players or teams.
"""
import datetime
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 app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
DEFAULT_OBJECTIVE_TEMPLATES,
OBJECTIVE_CHECK_FREQUENCIES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_STATUSES,
OBJECTIVE_TYPES,
Admin,
Coach,
Manager,
Objective,
ObjectiveComment,
ObjectiveEntry,
OrgTeam,
PersonalNote,
TeamNote,
User,
)
from app.pagination import paginate
from app.permissions import (
can_manage_objective,
can_manage_objectives,
coach_org_team_ids,
coach_player_ids,
)
from app.validators import ObjectiveSchema
objectives_bp = Blueprint('objectives', __name__, url_prefix='/objectives')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_objective(obj_id):
"""Load an objective or 404."""
return db.get_or_404(Objective, obj_id)
def _can_edit(user, objective):
"""Short-hand permission check with flash fallback."""
if can_manage_objective(user, objective):
return True
flash(_('You do not have permission to manage this objective.'), 'danger')
return False
# ---------------------------------------------------------------------------
# List / Dashboard
# ---------------------------------------------------------------------------
@objectives_bp.route('')
@login_required
def list_objectives():
"""List all objectives the current user may see."""
user = current_user
can_edit = can_manage_objectives(user)
# ── Query base ──────────────────────────────────────────────────────
base = Objective.query
# Scope: players only see their own, coaches/admins see theirs, etc.
if isinstance(user, Manager):
base = base.filter(Objective.created_by == user.id)
elif isinstance(user, Coach):
team_ids = coach_org_team_ids(user)
player_ids = coach_player_ids(user)
conditions = [Objective.created_by == user.id]
if team_ids:
conditions.append(Objective.team_id.in_(team_ids))
if player_ids:
conditions.append(Objective.player_id.in_(player_ids))
base = base.filter(db.or_(*conditions))
elif isinstance(user, Admin):
pass # all
elif hasattr(user, 'role') and user.role == 'player':
base = base.filter(Objective.player_id == user.id)
else:
base = base.filter(Objective.created_by == user.id)
# ── Tab / filter ────────────────────────────────────────────────────
tab = request.args.get('tab', 'all')
if tab == 'kpi':
base = base.filter(Objective.kpi_metric.isnot(None))
elif tab in ('modeles', 'templates'):
base = base.filter(Objective.is_template.is_(True))
search = request.args.get('search', '').strip()
if search:
base = base.filter(Objective.title.ilike(f'%{search}%'))
f_status = request.args.get('status', '').strip()
f_priority = request.args.get('priority', '').strip()
f_type = request.args.get('type', '').strip()
f_team = request.args.get('team', type=int)
if f_status:
base = base.filter(Objective.status == f_status)
if f_priority:
base = base.filter(Objective.priority == f_priority)
if f_type:
base = base.filter(Objective.type == f_type)
if f_team:
base = base.filter(Objective.team_id == f_team)
sort_column = request.args.get('sort', 'due_date')
sort_order = request.args.get('order', 'asc')
if sort_order not in ('asc', 'desc'):
sort_order = 'asc'
sort_map = {
'title': Objective.title,
'priority': Objective.priority,
'status': Objective.status,
'progress': Objective.progress,
'due_date': Objective.due_date,
'created_at': Objective.created_at,
}
sort_expr = sort_map.get(sort_column, Objective.due_date)
if sort_order == 'desc':
sort_expr = sort_expr.desc()
else:
sort_expr = sort_expr.asc()
objectives_page = paginate(base.order_by(sort_expr, Objective.id))
all_q = Objective.query
count_total = all_q.count()
count_kpi = all_q.filter(Objective.kpi_metric.isnot(None)).count()
count_templates = all_q.filter(Objective.is_template.is_(True)).count()
success_rate = 0.0
total_with_status = all_q.count()
if total_with_status > 0:
completed = all_q.filter(Objective.status == 'completed').count()
success_rate = round((completed / total_with_status) * 100, 1)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
players = User.query.filter(User.role == 'player').order_by(User.username).all()
templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all()
return render_template(
'pages/objectives.html',
objectives=objectives_page,
pagination=objectives_page,
can_edit=can_edit,
tab=tab,
search=search,
f_status=f_status,
f_priority=f_priority,
f_type=f_type,
f_team=f_team,
sort_column=sort_column,
sort_order=sort_order,
count_total=count_total,
count_kpi=count_kpi,
count_templates=count_templates,
success_rate=success_rate,
objective_types=OBJECTIVE_TYPES,
objective_statuses=OBJECTIVE_STATUSES,
objective_priorities=OBJECTIVE_PRIORITIES,
objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES,
teams=teams,
players=players,
templates=templates,
)
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
# New / Edit form page
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# View (player-facing detail page)
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>')
@login_required
def view_objective(objective_id):
"""Player-facing detail page for a specific objective."""
from app.permissions import can_view_objective as _can_view
objective = _resolve_objective(objective_id)
if not _can_view(current_user, objective):
flash(_('You do not have permission to view this objective.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
can_edit = can_manage_objectives(current_user)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
comments = (
ObjectiveComment.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveComment.created_at.desc())
.all()
)
personal_notes = []
if objective.player_id:
personal_notes = (
PersonalNote.query.filter_by(player_id=objective.player_id)
.order_by(PersonalNote.created_at.desc())
.limit(10)
.all()
)
team_notes = []
if objective.team_id:
team_notes = (
TeamNote.query.filter_by(org_team_id=objective.team_id)
.order_by(TeamNote.created_at.desc())
.limit(10)
.all()
)
return render_template(
'pages/objective_view.html',
objective=objective,
entries=entries,
comments=comments,
personal_notes=personal_notes,
team_notes=team_notes,
can_edit=can_edit,
objective_types=OBJECTIVE_TYPES,
objective_statuses=OBJECTIVE_STATUSES,
objective_priorities=OBJECTIVE_PRIORITIES,
objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES,
)
def _form_context():
"""Shared context for the create/edit form page."""
teams = OrgTeam.query.order_by(OrgTeam.name).all()
players = User.query.filter(User.role == 'player').order_by(User.username).all()
templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all()
return {
'objective_types': OBJECTIVE_TYPES,
'objective_statuses': OBJECTIVE_STATUSES,
'objective_priorities': OBJECTIVE_PRIORITIES,
'objective_frequencies': OBJECTIVE_CHECK_FREQUENCIES,
'teams': teams,
'players': players,
'templates': templates,
}
@objectives_bp.route('/new')
@login_required
def new_objective():
"""Render the create-objective form page with preview."""
if not can_manage_objectives(current_user):
flash(_('You do not have permission to create objectives.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
ctx = _form_context()
ctx['objective'] = None
ctx['edit_mode'] = False
ctx['entries'] = []
ctx['comments'] = []
ctx['personal_notes'] = []
ctx['team_notes'] = []
return render_template('pages/objective_form.html', **ctx)
@objectives_bp.route('/<int:objective_id>/edit')
@login_required
def edit_objective(objective_id):
"""Render the edit-objective form page with preview."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
ctx = _form_context()
ctx['objective'] = objective
ctx['edit_mode'] = True
ctx['entries'] = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
ctx['comments'] = (
ObjectiveComment.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveComment.created_at.desc())
.all()
)
ctx['personal_notes'] = []
if objective.player_id:
ctx['personal_notes'] = (
PersonalNote.query.filter_by(player_id=objective.player_id)
.order_by(PersonalNote.created_at.desc())
.limit(10)
.all()
)
ctx['team_notes'] = []
if objective.team_id:
ctx['team_notes'] = (
TeamNote.query.filter_by(org_team_id=objective.team_id)
.order_by(TeamNote.created_at.desc())
.limit(10)
.all()
)
return render_template('pages/objective_form.html', **ctx)
# ---------------------------------------------------------------------------
@objectives_bp.route('/create', methods=['POST'])
@login_required
def create_objective():
"""Create a new objective."""
if not can_manage_objectives(current_user):
flash(_('You do not have permission to create objectives.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
try:
data = ObjectiveSchema().load(
form_payload(list_fields=(), optional_blank=('password', 'check_frequency'))
)
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.list_objectives'))
objective = Objective(
title=data['title'],
description=data.get('description'),
type=data['type'],
status=data['status'],
priority=data['priority'],
category=data.get('category'),
is_template=data.get('is_template', False),
player_id=data.get('player_id'),
team_id=data.get('team_id'),
kpi_metric=data.get('kpi_metric'),
kpi_target=data.get('kpi_target'),
kpi_unit=data.get('kpi_unit'),
kpi_precision=data.get('kpi_precision'),
start_date=data.get('start_date'),
due_date=data['due_date'],
check_frequency=data.get('check_frequency') or None,
progress=data.get('progress', 0),
tags=data.get('tags'),
created_by=current_user.id,
)
db.session.add(objective)
db.session.commit()
flash(_('Objective created successfully.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/update', methods=['POST'])
@login_required
def update_objective(objective_id):
"""Update an existing objective."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
try:
data = ObjectiveSchema().load(
form_payload(list_fields=(), optional_blank=('password', 'check_frequency')),
partial=('due_date',),
)
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.list_objectives'))
for field in (
'title',
'description',
'type',
'status',
'priority',
'category',
'is_template',
'player_id',
'team_id',
'kpi_metric',
'kpi_unit',
'kpi_precision',
'check_frequency',
'progress',
'tags',
):
val = data.get(field)
setattr(objective, field, val)
if 'kpi_target' in data:
objective.kpi_target = data['kpi_target']
if 'start_date' in data:
objective.start_date = data['start_date']
if 'due_date' in data:
objective.due_date = data['due_date']
db.session.commit()
flash(_('Objective updated successfully.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/delete', methods=['POST'])
@login_required
def delete_objective(objective_id):
"""Delete an objective."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
db.session.delete(objective)
db.session.commit()
flash(_('Objective deleted.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# JSON data endpoint for the edit modal
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/data')
@json_endpoint
@login_required
def objective_data(objective_id):
"""Return objective details as JSON for the edit modal."""
from flask import jsonify
objective = _resolve_objective(objective_id)
return jsonify(
{
'id': objective.id,
'title': objective.title,
'description': objective.description,
'type': objective.type,
'status': objective.status,
'priority': objective.priority,
'category': objective.category,
'is_template': objective.is_template,
'player_id': objective.player_id,
'team_id': objective.team_id,
'kpi_metric': objective.kpi_metric,
'kpi_target': str(objective.kpi_target) if objective.kpi_target is not None else None,
'kpi_unit': objective.kpi_unit,
'kpi_precision': objective.kpi_precision,
'start_date': objective.start_date.isoformat() if objective.start_date else None,
'due_date': objective.due_date.isoformat() if objective.due_date else None,
'check_frequency': objective.check_frequency,
'progress': objective.progress,
'tags': objective.tags,
}
)
# ---------------------------------------------------------------------------
# Seed default templates (idempotent)
# ---------------------------------------------------------------------------
def seed_default_templates():
"""Create the default Objective templates if they don't exist yet.
Idempotent — safe to call on every startup.
Skips if no admin user exists yet (first run seeding may not have run).
"""
admin = User.query.filter(User.role == 'admin').first()
if not admin:
return
existing = {t.title for t in Objective.query.filter_by(is_template=True).all()}
for (
priority,
title,
description,
kpi_metric,
kpi_target,
kpi_unit,
typ,
category,
) in DEFAULT_OBJECTIVE_TEMPLATES:
if title in existing:
continue
obj = Objective(
title=title,
description=description,
type=typ,
status='active',
priority=priority,
category=category,
is_template=True,
kpi_metric=kpi_metric,
kpi_target=kpi_target,
kpi_unit=kpi_unit,
progress=0,
due_date=datetime.date(2099, 12, 31),
created_by=admin.id,
)
db.session.add(obj)
db.session.commit()
# ---------------------------------------------------------------------------
# KPI Entries — measurements for the quantitative graph
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/entries', methods=['GET'])
@login_required
@json_endpoint
def list_entries(objective_id):
"""Return KPI entries for an objective as JSON."""
from flask import jsonify
objective = _resolve_objective(objective_id)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
return jsonify(
[
{
'id': e.id,
'value': str(e.value),
'recorded_at': e.recorded_at.isoformat(),
'note': e.note,
}
for e in entries
]
)
@objectives_bp.route('/<int:objective_id>/entries', methods=['POST'])
@login_required
def add_entry(objective_id):
"""Record a KPI measurement for an objective.
Accepts an optional numeric value (for quantitative KPI tracking)
and an optional note. If neither is provided the entry is rejected.
The assigned player may record entries on their own objectives.
"""
from datetime import datetime
from app.permissions import can_add_objective_entry
objective = _resolve_objective(objective_id)
if not can_add_objective_entry(current_user, objective):
flash(_('You do not have permission to record entries for this objective.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
raw_value = request.form.get('value', '').strip()
note = request.form.get('note', '').strip()
value = None
if raw_value:
try:
value = float(raw_value)
except (ValueError, TypeError):
flash(_('Invalid measurement value.'), 'danger')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
if value is None and not note:
flash(_('Provide a value, a note, or both.'), 'warning')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
entry = ObjectiveEntry(
objective_id=objective.id,
value=value,
recorded_at=datetime.now(),
note=note or None,
recorded_by=current_user.id,
)
db.session.add(entry)
db.session.commit()
flash(_('Measurement recorded.'), 'success')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
flash(_('Measurement recorded.'), 'success')
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
# ---------------------------------------------------------------------------
# Comments — coach feedback on an objective
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/comments', methods=['POST'])
@login_required
def add_comment(objective_id):
"""Add a coach comment on an objective."""
objective = _resolve_objective(objective_id)
if not can_manage_objectives(current_user):
flash(_('You do not have permission to comment.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
from app.validators import NoteContentSchema
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
comment = ObjectiveComment(
objective_id=objective.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(comment)
db.session.commit()
flash(_('Comment added.'), 'success')
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
# ---------------------------------------------------------------------------
# Trend / prediction data for the chart
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/trend')
@login_required
@json_endpoint
def trend_data(objective_id):
"""Return the trend + prediction data for an objective's chart."""
from flask import jsonify
from app.objectives_analytics import compute_trend
objective = _resolve_objective(objective_id)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
timestamps = [e.recorded_at for e in entries]
values = [float(e.value) for e in entries]
target = float(objective.kpi_target) if objective.kpi_target is not None else None
trend = compute_trend(timestamps, values, target)
return jsonify(trend)
+17 -12
View File
@@ -3,7 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask import Blueprint, flash, jsonify, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
@@ -13,6 +13,7 @@ from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin,
AppSettings,
Coach,
Manager,
OrgTeam,
@@ -20,10 +21,10 @@ from app.models import (
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings,
)
from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.responders import respond_after_change
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive
@@ -121,12 +122,14 @@ def create_match(team_id):
"""Create a new regular-season team match."""
team = db.get_or_404(OrgTeam, team_id)
if season_locked():
flash('The regular season is not active. Begin a season before scheduling matches.', 'warning')
return redirect(url_for('team_matches.list_matches'))
flash(
'The regular season is not active. Begin a season before scheduling matches.', 'warning'
)
return respond_after_change(url_for('team_matches.list_matches'))
if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
return respond_after_change(url_for('team_matches.list_matches'))
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
prefill_date = request.args.get('date', '')
@@ -219,7 +222,7 @@ def create_match(team_id):
flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
)
return redirect(url_for('team_matches.list_matches'))
return respond_after_change(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -233,7 +236,7 @@ def edit_match(match_id):
if not can_manage_team_match(team):
flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
return respond_after_change(url_for('team_matches.list_matches'))
if request.method == 'POST':
# The three date and time fields used to be checked one at a time,
@@ -263,7 +266,7 @@ def edit_match(match_id):
db.session.commit()
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches'))
return respond_after_change(url_for('team_matches.list_matches'))
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
@@ -277,17 +280,19 @@ def delete_match(match_id):
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
if season_locked():
flash('The regular season is not active. Begin a season before deleting matches.', 'warning')
return redirect(url_for('team_matches.list_matches'))
flash(
'The regular season is not active. Begin a season before deleting matches.', 'warning'
)
return respond_after_change(url_for('team_matches.list_matches'))
if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
return respond_after_change(url_for('team_matches.list_matches'))
db.session.delete(team_match)
db.session.commit()
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches'))
return respond_after_change(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams')
+56 -46
View File
@@ -27,6 +27,8 @@ from app.models import (
User,
)
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
@@ -76,7 +78,7 @@ def my_teams():
"""View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player):
flash(_('This page is for players.'), 'info')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant
@@ -183,18 +185,18 @@ def create_team():
"""Create a new organization team."""
if not current_user.can_manage_teams():
flash(_('You do not have permission to create teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
name = data['name']
if OrgTeam.query.filter_by(name=name).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager)
@@ -214,8 +216,11 @@ 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 redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
@@ -225,18 +230,18 @@ def edit_team(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
name = data['name']
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
team.name = name
@@ -268,8 +273,10 @@ 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 redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@@ -297,7 +304,7 @@ def delete_team(team_id):
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
name = team.name
@@ -328,7 +335,7 @@ def delete_team(team_id):
db.session.delete(team)
db.session.commit()
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
@@ -338,19 +345,19 @@ def add_coach(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if not data['coach_id']:
flash(_('Please select a coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
coach = db.session.get(User, data['coach_id'])
if not coach or not _assignable(coach, Coach):
flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first():
flash(
@@ -361,17 +368,18 @@ def add_coach(team_id):
),
'info',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
team.coaches.append(coach)
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',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
@@ -381,19 +389,19 @@ def add_manager(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if not data['manager_id']:
flash(_('Please select a manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
manager = db.session.get(User, data['manager_id'])
if not manager or not _assignable(manager, Manager):
flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first():
flash(
@@ -404,17 +412,18 @@ def add_manager(team_id):
),
'info',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
team.managers.append(manager)
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',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
@@ -424,11 +433,11 @@ def remove_coach(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if data['coach_id']:
coach = db.session.get(User, data['coach_id'])
@@ -442,7 +451,7 @@ def remove_coach(team_id):
db.session.commit()
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
@@ -452,11 +461,11 @@ def remove_manager(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if data['manager_id']:
manager = db.session.get(User, data['manager_id'])
@@ -470,7 +479,7 @@ def remove_manager(team_id):
db.session.commit()
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
@@ -480,20 +489,20 @@ def add_player(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
data = _posted(TeamPlayerSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
status = data['status']
player = db.session.get(User, data['player_id'])
if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing:
@@ -501,13 +510,14 @@ def add_player(team_id):
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
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 redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
@@ -517,7 +527,7 @@ def remove_player(team_id, player_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
@@ -526,7 +536,7 @@ def remove_player(team_id, player_id):
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
db.session.delete(tp)
db.session.commit()
@@ -534,7 +544,7 @@ def remove_player(team_id, player_id):
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@@ -569,19 +579,19 @@ def add_team_note(team_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
@@ -591,12 +601,12 @@ def add_player_note(team_id, player_id):
team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
@@ -604,16 +614,16 @@ def add_player_note(team_id, player_id):
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams'))
return respond_after_change(url_for('teams.list_teams'))
+224 -46
View File
@@ -16,6 +16,7 @@ from app.models import (
ESPORT_GAMES,
GAME_POSITIONS,
Admin,
AppSettings,
Coach,
Evaluation,
Manager,
@@ -30,14 +31,15 @@ from app.models import (
Tryout,
TryoutRegistration,
User,
AppSettings,
)
from app.responders import respond_after_change
from app.time_utils import utc_now_naive
from app.validators import (
PlayerSelectionSchema,
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberPositionSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
@@ -140,7 +142,10 @@ def list_tryouts():
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.list_tryouts'))
if not can_manage():
@@ -204,7 +209,10 @@ def edit_tryout(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout):
@@ -273,9 +281,7 @@ def view_tryout(tryout_id):
can_view = False
if isinstance(current_user, Admin):
can_view = True
elif isinstance(current_user, Manager):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
elif isinstance(current_user, (Manager, Coach)):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = (
@@ -341,7 +347,12 @@ def view_tryout(tryout_id):
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows:
members_by_team.setdefault(row.team_id, []).append(
{'player': member_players.get(row.player_id), 'position': row.position}
{
'player': member_players.get(row.player_id),
'position': row.position,
'member_id': row.id,
'player_id': row.player_id,
}
)
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
@@ -467,31 +478,31 @@ def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register."""
if not isinstance(current_user, Player):
flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout = locked_tryout_or_404(tryout_id)
if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
).first()
if existing:
flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration)
db.session.commit()
flash(_('Successfully registered for tryout!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@@ -500,8 +511,11 @@ def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
@@ -510,12 +524,12 @@ def update_status(tryout_id):
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout.status = data['status']
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@@ -524,8 +538,11 @@ def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
@@ -538,12 +555,12 @@ def update_registration_status(tryout_id, player_id):
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration.status = data['status']
db.session.commit()
flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@@ -552,21 +569,24 @@ def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = locked_tryout_or_404(tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try:
data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Same two checks as the team roster (SEC-16): the right role, and an
# account that has not been deactivated. The select this comes from now
@@ -574,7 +594,7 @@ def register_player(tryout_id):
player = db.session.get(User, data['player_id'])
if not player or not isinstance(player, Player) or not player.is_active_account:
flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
if existing:
@@ -582,19 +602,19 @@ def register_player(tryout_id):
_('%(username)s is already registered for this tryout.', username=player.username),
'info',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration)
db.session.commit()
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@@ -603,8 +623,11 @@ def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
@@ -634,7 +657,7 @@ def remove_player(tryout_id, player_id):
db.session.commit()
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@@ -643,24 +666,27 @@ def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@@ -670,12 +696,15 @@ def add_to_team(tryout_id, team_id):
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to
@@ -687,7 +716,7 @@ def add_to_team(tryout_id, team_id):
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_id']
# Only players registered for this tryout may be placed on its teams.
@@ -697,7 +726,7 @@ def add_to_team(tryout_id, team_id):
)
if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
@@ -707,7 +736,153 @@ def add_to_team(tryout_id, team_id):
db.session.add(member)
db.session.commit()
flash(_('Player added to team!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
@login_required
def edit_team(tryout_id, team_id):
"""Rename a tryout-specific team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team.name = data['team_name']
db.session.commit()
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(tryout_id, team_id):
"""Delete a tryout-specific team and its memberships."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
name = team.name
# Personal note references outlive the team.
PersonalNote.query.filter_by(team_id=team_id).update(
{'team_id': None}, synchronize_session=False
)
# Matches referencing this team: null the FK so the match survives.
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
# Clean participants for those matches (they are no longer in that team).
for match in Match.query.filter(
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
).all():
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
db.session.delete(team)
db.session.commit()
flash(_('Team "%(name)s" deleted.', name=name), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
@login_required
def remove_from_team(tryout_id, team_id, player_id):
"""Remove a player from a tryout team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
synchronize_session=False
)
# Also remove the player from any match participants that were tied to this team.
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(_('Player removed from team.'), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route(
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
)
@login_required
def update_member_position(tryout_id, team_id, member_id):
"""Set or clear a team member's in-game position (role)."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
member = db.get_or_404(TeamMember, member_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id or member.team_id != team_id:
abort(404)
try:
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
member.position = data['position'] or None
db.session.commit()
flash(_('Position updated.'), 'success')
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@@ -716,8 +891,11 @@ def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger')
@@ -757,4 +935,4 @@ def delete_tryout(tryout_id):
db.session.delete(tryout)
db.session.commit()
flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts'))
return redirect(url_for('tryouts.list_tryouts'))
+1
View File
@@ -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
+17 -16
View File
@@ -10,6 +10,7 @@ from marshmallow import ValidationError
from werkzeug.utils import secure_filename
from app.extensions import db
from app.responders import respond_after_change
from app.models import Admin, Coach, Contract, Manager, Player, User
from app.models.admin_settings import AppSettings
from app.permissions import can_manage_player_contract, coach_player_ids
@@ -102,11 +103,11 @@ def upload_contract():
"""Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)):
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
players = manageable_players()
@@ -125,13 +126,13 @@ def upload_contract():
if not can_manage_player_contract(current_user, player_id):
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
return redirect(url_for('users.upload_contract'))
return respond_after_change(url_for('users.upload_contract'))
file = request.files.get('contract_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error:
flash(error, 'danger')
return redirect(url_for('users.upload_contract'))
return respond_after_change(url_for('users.upload_contract'))
player = db.get_or_404(User, player_id)
player_teams = player.get_org_teams()
@@ -154,7 +155,7 @@ def upload_contract():
flash(
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
)
return redirect(url_for('users.upload_contract'))
return respond_after_change(url_for('users.upload_contract'))
contract = Contract(
player_id=player_id,
@@ -171,7 +172,7 @@ def upload_contract():
_('Contract uploaded successfully for %(username)s!', username=player.username),
'success',
)
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
return render_template('pages/upload_contract.html', players=players)
@@ -183,17 +184,17 @@ def upload_signed_contract(contract_id):
contract = db.get_or_404(Contract, contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
if document_uploads_locked():
flash(_('Document uploads are currently disabled.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
flash(error, 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
# Legacy local records keep their signed copy beside the original. Drive
@@ -208,7 +209,7 @@ def upload_signed_contract(contract_id):
stored_signed_path = store_uploaded_document(file, signed_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
contract.signed_filename = signed_filename
contract.signed_file_path = stored_signed_path
@@ -216,7 +217,7 @@ def upload_signed_contract(contract_id):
contract.signed_at = utc_now_naive()
db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
@users_bp.route('/contracts/<int:contract_id>/download')
@@ -226,12 +227,12 @@ def download_contract(contract_id):
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
try:
document = open_document(contract.file_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.original_filename)
@@ -242,13 +243,13 @@ def download_signed_contract(contract_id):
contract = db.get_or_404(Contract, contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
try:
document = open_document(contract.signed_file_path)
except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
return redirect(url_for('users.list_contracts'))
return respond_after_change(url_for('users.list_contracts'))
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
+210
View File
@@ -0,0 +1,210 @@
"""Member-facing document centre.
Players, coaches and managers see the published documents they must sign
or acknowledge, complete them, and download the file drawn from the single
canonical copy in storage. Coaches and managers additionally see progress
for the members of the teams they are attached to.
"""
from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from app.extensions import db
from app.models import (
DOCUMENT_KIND_ROLES,
Admin,
Coach,
Document,
DocumentAssignment,
DocumentVersion,
Manager,
)
from app.permissions import visible_org_teams
from app.responders import respond_after_change
from app.routes.users._shared import ALLOWED_SIGNED_EXTENSIONS, pdf_upload_error
from app.routes.users.blueprint import users_bp
from app.services.documents import (
backfill_assignments_for_user,
primary_team_for_user,
user_eligible_for_kind,
user_has_any_team,
)
from app.storage import GoogleDriveStorageError, open_document, store_signed_document
from app.time_utils import utc_now_naive
def _team_members(team):
"""Active players, coaches and managers of one team, de-duplicated."""
members = {}
for player in team.players:
if player.is_active_account:
members[player.id] = player
for staff in list(team.get_coaches()) + list(team.get_managers()):
if staff.is_active_account:
members[staff.id] = staff
return sorted(members.values(), key=lambda user: user.username.lower())
@users_bp.route('/documents')
@login_required
def list_my_documents():
"""The current member's obligations, plus team progress for staff."""
backfill_assignments_for_user(current_user)
if isinstance(current_user, Admin):
return redirect(url_for('admin.documents'))
if not user_has_any_team(current_user):
return render_template('pages/my_documents.html', documents=[], teams_progress=None)
published = (
Document.query.filter(Document.is_published.is_(True))
.order_by(Document.kind, Document.title)
.all()
)
documents = []
for doc in published:
if current_user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
assignment = DocumentAssignment.query.filter_by(
document_id=doc.id, user_id=current_user.id
).first()
documents.append({'document': doc, 'assignment': assignment})
teams_progress = None
if isinstance(current_user, (Coach, Manager)):
teams_progress = []
for team in visible_org_teams(current_user):
rows = []
for member in _team_members(team):
statuses = {}
for doc in published:
if member.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
assignment = DocumentAssignment.query.filter_by(
document_id=doc.id, user_id=member.id
).first()
statuses[doc.id] = assignment.status if assignment else None
rows.append({'user': member, 'statuses': statuses})
teams_progress.append({'team': team, 'members': rows, 'documents': published})
return render_template(
'pages/my_documents.html',
documents=documents,
teams_progress=teams_progress,
)
@users_bp.route('/documents/assignments/<int:assignment_id>/sign', methods=['POST'])
@login_required
def sign_document(assignment_id):
"""Upload the signed copy of a pending signature document."""
assignment = db.get_or_404(DocumentAssignment, assignment_id)
if assignment.user_id != current_user.id:
flash(_('You do not have permission to sign this document.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.completed:
flash(_('This document has already been completed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.requires_signature:
flash(_('This document is acknowledged, not signed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.current_version:
flash(_('This document has no version published yet.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
flash(error, 'danger')
return respond_after_change(url_for('users.list_my_documents'))
team = primary_team_for_user(current_user)
if team is None:
flash(_('You must be part of a team before signing documents.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
version = assignment.document.current_version
# The kind prefix keeps separate documents from colliding: a player signs
# both the player contract and the team agreement, each at version 1.
signed_filename = f'{assignment.document.kind}-v{version.version}.pdf'
try:
stored_path = store_signed_document(
file,
team_name=team.name,
role=current_user.role,
username=current_user.username,
filename=signed_filename,
)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('users.list_my_documents'))
assignment.status = 'signed'
assignment.signed_filename = signed_filename
assignment.signed_file_path = stored_path
assignment.completed_version_id = version.id
assignment.completed_at = utc_now_naive()
assignment.filed_team_id = team.id
db.session.commit()
flash(_('Document signed successfully!'), 'success')
return respond_after_change(url_for('users.list_my_documents'))
@users_bp.route('/documents/assignments/<int:assignment_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge_document(assignment_id):
"""Mark a read-and-acknowledge document (code of conduct, ethics)."""
assignment = db.get_or_404(DocumentAssignment, assignment_id)
if assignment.user_id != current_user.id:
flash(_('You do not have permission to acknowledge this document.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.completed:
flash(_('This document has already been completed.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if assignment.document.requires_signature:
flash(_('This document must be signed, not acknowledged.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
if not assignment.document.current_version:
flash(_('This document has no version published yet.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
assignment.status = 'acknowledged'
assignment.completed_version_id = assignment.document.current_version.id
assignment.completed_at = utc_now_naive()
db.session.commit()
flash(_('Document acknowledged.'), 'success')
return respond_after_change(url_for('users.list_my_documents'))
@users_bp.route('/documents/version/<int:version_id>/download')
@login_required
def download_document(version_id):
"""Download the canonical file, gated on the admin's published flag."""
version = db.get_or_404(DocumentVersion, version_id)
allowed = isinstance(current_user, Admin) or (
version.document.is_published
and user_eligible_for_kind(current_user, version.document.kind)
)
if not allowed:
flash(_('This document is not available to you.'), 'danger')
return respond_after_change(url_for('users.list_my_documents'))
try:
file_object = open_document(version.file_path)
except GoogleDriveStorageError:
flash(
_('Document storage is temporarily unavailable. Please try again later.'),
'danger',
)
return respond_after_change(url_for('users.list_my_documents'))
return send_file(file_object, as_attachment=True, download_name=version.original_filename)
+20 -19
View File
@@ -10,6 +10,7 @@ from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.responders import respond_after_change
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
@@ -176,14 +177,14 @@ def manage_team_notes():
org_teams = coach_org_teams(current_user)
if not org_teams:
flash(_('You are not assigned to a team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
org_team = org_teams[0]
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
note = TeamNote(
org_team_id=org_team.id,
@@ -194,7 +195,7 @@ def manage_team_notes():
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
@@ -214,17 +215,17 @@ def manage_personal_notes():
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
@@ -234,7 +235,7 @@ def manage_personal_notes():
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
@@ -254,44 +255,44 @@ def add_personal_note():
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = db.get_or_404(User, player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if data['match_id']:
match = db.get_or_404(Match, data['match_id'])
if not current_user.can_manage_this_tryout(match.tryout):
flash(_('You cannot use that match as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
flash(_('That player did not participate in the selected match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if data['tryout_id']:
tryout = db.get_or_404(Tryout, data['tryout_id'])
if not current_user.can_manage_this_tryout(tryout):
flash(_('You cannot use that tryout as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
flash(_('That player is not registered for the selected tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if data['team_id']:
team = db.get_or_404(Team, data['team_id'])
if not current_user.can_manage_this_tryout(team.tryout):
flash(_('You cannot use that team as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
flash(_('That player is not on the selected team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
@@ -304,7 +305,7 @@ def add_personal_note():
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
@@ -323,7 +324,7 @@ def add_note_from_tryout(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list
@@ -387,7 +388,7 @@ def add_note_from_match(match_id):
match_obj = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
# Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
+12 -11
View File
@@ -6,6 +6,7 @@ from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.responders import respond_after_change
from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp
@@ -64,13 +65,13 @@ def one_on_one():
if request.method == 'POST':
if not coach:
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one'))
return respond_after_change(url_for('users.one_on_one'))
try:
data = OneOnOneRequestSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.one_on_one'))
return respond_after_change(url_for('users.one_on_one'))
date_obj = data['date']
start_time = data['start_time']
@@ -90,7 +91,7 @@ def one_on_one():
if not is_available:
flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one'))
return respond_after_change(url_for('users.one_on_one'))
request_obj = OneOnOneRequest(
player_id=current_user.id,
@@ -118,7 +119,7 @@ def one_on_one():
)
flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one'))
return respond_after_change(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
from datetime import date as date_cls
@@ -169,11 +170,11 @@ def accept_one_on_one(request_id):
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
player = request_obj.player
request_obj.status = 'approved'
@@ -204,7 +205,7 @@ def accept_one_on_one(request_id):
),
'success',
)
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@@ -219,17 +220,17 @@ def reject_one_on_one(request_id):
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
try:
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason']
player = request_obj.player
@@ -264,4 +265,4 @@ def reject_one_on_one(request_id):
),
'info',
)
return redirect(url_for('users.notes_dashboard'))
return respond_after_change(url_for('users.notes_dashboard'))
+2 -1
View File
@@ -6,6 +6,7 @@ from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db, hash_password
from app.responders import respond_after_change
from app.logging_config import log_auth_event
from app.models import (
ESPORT_GAMES,
@@ -129,7 +130,7 @@ def edit_profile():
db.session.commit()
flash(_('Profile updated successfully!'), 'success')
return redirect(url_for('users.profile'))
return respond_after_change(url_for('users.profile'))
return render_template(
'pages/edit_profile.html',
+137
View File
@@ -0,0 +1,137 @@
"""Document lifecycle logic: eligibility, assignment backfill, primary team.
The rules this encodes are the club's:
- Only *active* members who belong to at least one team receive an
obligation. A player not placed on a team signs nothing.
- Obligations are role-scoped. A player on two teams signs the player
contract once; ``UNIQUE(document_id, user_id)`` is the guarantee, and the
team folder below is only where the signed copy is filed.
- Publishing a version (or enabling a document) backfills pending
assignments for everyone currently eligible. Joining a team backfills
that one person across every published document matching their role.
"""
from app.extensions import db
from app.models import (
DOCUMENT_KIND_ROLES,
Coach,
Document,
DocumentAssignment,
Manager,
OrgTeam,
Player,
User,
)
def user_org_team_ids(user):
"""Ids of the organisation teams the user is a member of.
Role-appropriate: placements for a player, the coach/managers
relationships (many-to-many *and* legacy single column) for staff. An
admin or a scout belongs to none, regardless of how many teams exist.
"""
from app.permissions import coach_org_teams, manager_org_teams
if isinstance(user, Player):
return [tp.org_team_id for tp in user.team_placements]
if isinstance(user, Coach):
return [team.id for team in coach_org_teams(user)]
if isinstance(user, Manager):
return [team.id for team in manager_org_teams(user)]
return []
def user_has_any_team(user):
"""Whether the user belongs to at least one organisation team."""
return bool(user_org_team_ids(user))
def primary_team_for_user(user):
"""The canonical team used for filing a signed copy, or None.
A member can sit on several teams but every signed file is filed under
one team only. The choice is deterministic: the team with the lowest
id among the user's memberships.
"""
team_ids = user_org_team_ids(user)
if not team_ids:
return None
return db.session.get(OrgTeam, min(team_ids))
def user_eligible_for_kind(user, kind):
"""Whether *user* must complete documents of *kind*."""
if kind not in DOCUMENT_KIND_ROLES:
return False
if not user.is_active_account:
return False
if user.role not in DOCUMENT_KIND_ROLES[kind]:
return False
return user_has_any_team(user)
def eligible_user_ids(kind):
"""Ids of every user currently eligible for *kind*, ordered by id."""
roles = DOCUMENT_KIND_ROLES[kind]
users = (
User.query.filter(User.role.in_(roles), User.is_active_account.is_(True))
.order_by(User.id)
.all()
)
return [u.id for u in users if user_has_any_team(u)]
def backfill_assignments_for_document(document):
"""Create a pending assignment for every currently eligible user.
Idempotent: anyone who already has an assignment for this document is
left alone. Returns how many new assignments were created.
"""
existing = {a.user_id for a in document.assignments.all()}
created = 0
for user_id in eligible_user_ids(document.kind):
if user_id in existing:
continue
db.session.add(
DocumentAssignment(
document_id=document.id,
user_id=user_id,
status='pending',
)
)
created += 1
if created:
db.session.commit()
return created
def backfill_assignments_for_user(user):
"""Create pending assignments for *user* across published documents.
Called when a member joins (or is reactivated into) a team. Only
published documents are backfilled: an essentially invisible draft
creates no obligations. Returns how many new assignments were created.
"""
if not user.is_active_account or not user_has_any_team(user):
return 0
documents = Document.query.filter(Document.is_published.is_(True)).order_by(Document.id).all()
created = 0
for doc in documents:
if user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
continue
exists = DocumentAssignment.query.filter_by(document_id=doc.id, user_id=user.id).first()
if exists:
continue
db.session.add(
DocumentAssignment(
document_id=doc.id,
user_id=user.id,
status='pending',
)
)
created += 1
if created:
db.session.commit()
return created
+6 -1
View File
@@ -60,7 +60,9 @@ def zip_participants(player_ids, participant_ids):
yield player_id, participant_ids[index] if index < len(participant_ids) else None
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
def notify_participants(
*, title, date, start_time, end_time, participants, fallback_id, change_kind='scheduled'
):
"""Tell each participant that a match has been scheduled or changed.
Args:
@@ -73,6 +75,8 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
attendance lands on the right row.
fallback_id: Reference to use when a participant row has no id —
the match's own, which the bot can still act on.
change_kind: 'scheduled' (new match / new player added) or
'modified' (date/time changed for existing players).
Returns:
int: How many notifications were handed to the bot.
@@ -91,6 +95,7 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
event_date=event_date,
event_time=event_time,
reference_id=participant_id or fallback_id,
change_kind=change_kind,
)
sent += 1
return sent
+189 -305
View File
@@ -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;
@@ -1788,296 +1892,6 @@ a:hover { color: var(--primary-dark); }
box-shadow: var(--shadow-md);
}
/* ------------------------------------------------------------------- */
/* Player Evolution — Objective Management */
/* ------------------------------------------------------------------- */
/* Wider variant for the create/edit modal with five sections */
.modal-lg .modal-content {
max-width: 720px;
}
/* Section grouping inside the form */
.form-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--gray-200);
}
.form-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.form-section h4 {
font-size: 0.95rem;
color: var(--primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
/* Toolbar (tabs + search/filter bar) */
.objectives-toolbar {
display: flex;
flex-direction: column;
gap: 12px;
}
.objectives-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--gray-200);
padding-bottom: 4px;
}
.tab-link {
padding: 6px 16px;
font-size: 0.9rem;
color: var(--gray-500);
text-decoration: none;
border-radius: var(--radius) var(--radius) 0 0;
transition: all 0.2s;
border: none;
background: transparent;
cursor: pointer;
}
.tab-link:hover {
color: var(--primary);
background: var(--gray-100);
}
.tab-link.active {
color: var(--primary);
font-weight: 600;
border-bottom: 2px solid var(--primary);
margin-bottom: -5px;
}
.objectives-search-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.objectives-search-bar .form-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
align-items: flex-end;
}
/* Tag badges inside objective cells */
.objective-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.objective-tags .badge {
font-size: 0.7rem;
padding: 2px 6px;
}
/* Progress bar in the table */
.progress-bar-container {
width: 80px;
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--danger), var(--warning), var(--success));
border-radius: 3px;
transition: width 0.3s ease;
}
/* Score-input range mirror (reused from evaluation forms) */
.score-input {
display: flex;
align-items: center;
gap: 8px;
}
.score-input input[type="range"] {
flex: 1;
}
.range-value {
font-weight: 600;
font-size: 1rem;
/* ------------------------------------------------------------------- */
/* Objective Form — two-column editor layout */
/* ------------------------------------------------------------------- */
.objective-editor {
display: grid;
grid-template-columns: 1fr 360px;
gap: 20px;
align-items: start;
}
.objective-editor-form .card-body {
max-height: 80vh;
overflow-y: auto;
}
.objective-editor-preview {
position: sticky;
top: 20px;
}
/* Preview card stats */
.preview-stats {
margin: 12px 0;
}
.preview-stat {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid var(--gray-100);
font-size: 0.85rem;
}
.preview-label {
color: var(--gray-500);
}
.preview-value {
font-weight: 600;
color: var(--gray-800);
}
.progress-section {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.progress-section .progress-bar-container {
flex: 1;
}
/* Template list items */
.template-item {
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: border-color 0.2s;
}
.template-item:hover {
border-color: var(--primary);
}
.template-item h5 {
margin: 0 0 4px 0;
font-size: 0.9rem;
}
.template-item p {
margin: 0 0 8px 0;
font-size: 0.8rem;
}
/* ------------------------------------------------------------------- */
/* Objective form — comments, entries, prediction info */
/* ------------------------------------------------------------------- */
.comments-list {
max-height: 300px;
overflow-y: auto;
}
.comment-item {
padding: 8px 0;
border-bottom: 1px solid var(--gray-100);
}
.comment-item:last-child {
border-bottom: none;
}
.comment-item p {
margin: 4px 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
}
#predictionInfo {
font-size: 0.85rem;
padding: 8px;
background: var(--gray-50);
border-radius: var(--radius);
}
#predictionInfo .confidence-high { color: var(--success); }
#predictionInfo .confidence-medium { color: var(--warning); }
#predictionInfo .confidence-low { color: var(--danger); }
/* Form-inline for measurement entry */
.form-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
}
.form-inline .form-group {
margin-bottom: 0;
}
/* Hide template/team fields initially */
#playerField, #teamField {
transition: opacity 0.2s;
}
/* Chart sizing */
#progressChart {
background: transparent;
}
/* Responsive: stack on small screens */
@media (max-width: 900px) {
.objective-editor {
grid-template-columns: 1fr;
}
.objective-editor-preview {
position: static;
}
}
min-width: 24px;
text-align: center;
color: var(--primary);
}
/* Actions cell in table */
.actions-cell {
white-space: nowrap;
display: flex;
gap: 4px;
}
/* Required field indicator */
.required {
color: var(--danger);
}
/* Reused stat card col-4 / col-6 helpers */
.col-4 { flex: 0 0 calc(33.333% - 12px); }
.col-6 { flex: 0 0 calc(50% - 12px); }
.col-12 { flex: 0 0 100%; }
.card-header .drag-handle {
margin-left: auto;
margin-right: 0;
@@ -2412,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);
}
+193 -16
View File
@@ -420,22 +420,9 @@ document.addEventListener('DOMContentLoaded', function() {
initDraggableBlocks();
/**
* Auto-dismiss flash alerts after 5 seconds.
* Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
*/
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(function(alert) {
setTimeout(function() {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function() {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
armAlertAutoDismiss();
});
/* =========================================================================
Declarative behaviours — replacing inline event handlers
@@ -593,7 +580,7 @@ document.addEventListener('input', function (event) {
document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) {
control.form.submit();
submitForm(control.form);
}
});
@@ -613,3 +600,193 @@ document.addEventListener('change', function (event) {
window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value));
});
/* =========================================================================
AJAX forms — server-rendered changes without a full page reload
=========================================================================
A <form method="post" data-ajax> is submitted with fetch() instead of a
navigation. The route answers a JSON payload (see app/responders.py) that
says where to fetch the refreshed content and which flash messages to
show; the content region is then swapped in place, so scroll position and
open state survive between edits.
A stale edit — the page changed since this copy was loaded — is answered
409, and the user is asked to reload rather than silently overwrite the
other person's change.
========================================================================= */
let TT_REVISION = null;
function armAlertAutoDismiss() {
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(function (alert) {
if (alert.getAttribute('data-armed')) return;
alert.setAttribute('data-armed', '1');
setTimeout(function () {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function () {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
}
function fetchRevision() {
return fetch('/api/revision', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (data) {
TT_REVISION = data.revision;
return data.revision;
}).catch(function () {
// Revision tracking is best-effort; a failed lookup just disables it.
});
}
function showFlashMessages(messages) {
const container = document.querySelector('.flash-messages');
if (!container || !messages || !messages.length) return;
messages.forEach(function (m) {
const alert = document.createElement('div');
alert.className = 'alert alert-' + (m.category || 'info') + ' alert-dismissible';
const span = document.createElement('span');
span.textContent = m.text;
const close = document.createElement('button');
close.type = 'button';
close.className = 'alert-close';
close.setAttribute('data-action', 'dismiss-alert');
close.setAttribute('aria-label', 'Dismiss');
close.innerHTML = '&times;';
alert.appendChild(span);
alert.appendChild(close);
container.appendChild(alert);
});
armAlertAutoDismiss();
}
function replaceContent(html, url) {
const holder = document.createElement('div');
holder.innerHTML = html;
const next = holder.querySelector('#page-content');
const target = document.getElementById('page-content');
if (next && target) {
target.innerHTML = next.innerHTML;
}
if (url && window.history && window.history.replaceState) {
window.history.replaceState(null, '', url);
}
initDraggableBlocks();
document.dispatchEvent(new Event('fragment:updated'));
}
function refreshPage(url) {
return fetch(url, { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) { replaceContent(html, url); });
}
function submitAjaxForm(form) {
const headers = {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
};
const token = form.querySelector('input[name="csrf_token"]');
if (token) headers['X-CSRFToken'] = token.value;
if (TT_REVISION !== null && TT_REVISION !== undefined) {
headers['X-Revision'] = String(TT_REVISION);
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
body: new FormData(form),
headers: headers,
credentials: 'same-origin'
}).then(function (response) {
return response.json().then(function (data) {
return { status: response.status, data: data };
}).catch(function () {
return { status: response.status, data: null };
});
}).then(function (result) {
if (result.status === 409) {
const message = (result.data && result.data.message) ||
'This page was changed by someone else. Reload to see the latest changes.';
if (window.confirm(message)) {
window.location.reload();
}
return null;
}
if (result.status >= 400) {
const message = (result.data && (result.data.message || result.data.error)) ||
'Something went wrong. Please try again.';
window.alert(message);
return null;
}
return result.data;
}).then(function (data) {
if (!data) return;
if (data.revision !== undefined && data.revision !== null) {
TT_REVISION = data.revision;
}
showFlashMessages(data.messages);
if (data.redirect) {
return refreshPage(data.redirect);
}
return undefined;
}).catch(function (err) {
console.error('AJAX form submission failed:', err);
});
}
function submitForm(form) {
if (form.hasAttribute('data-ajax')) {
submitAjaxForm(form);
} else {
form.submit();
}
}
function checkForRemoteChanges() {
return fetchRevision().then(function (rev) {
if (TT_REVISION !== null && rev !== TT_REVISION) {
TT_REVISION = rev;
return refreshPage(window.location.href);
}
return undefined;
}).catch(function () { /* best-effort */ });
}
// data-submit-on-change and data-ajax both funnel through submitForm so a
// <select> change uses fetch() when the form is marked, and a plain submit
// otherwise. (form.submit() does not fire the submit event, so intercepting
// only 'submit' would miss the selects.)
document.addEventListener('submit', function (event) {
const form = event.target.closest('form[data-ajax]');
if (!form || event.defaultPrevented) return;
event.preventDefault();
submitAjaxForm(form);
});
// Refresh when the user returns to the tab: if someone else changed the data,
// this brings the view up to date without a constant polling loop.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
checkForRemoteChanges();
}
});
window.addEventListener('focus', function () {
checkForRemoteChanges();
});
document.addEventListener('DOMContentLoaded', function () {
fetchRevision();
});
window.TT = window.TT || {};
window.TT.refreshPage = refreshPage;
+73
View File
@@ -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):
+1 -1
View File
@@ -118,4 +118,4 @@ def main():
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
+7 -7
View File
@@ -68,12 +68,6 @@
</a>
</li>
{% endif %}
<li>
<a href="{{ url_for('objectives.list_objectives') }}" class="{% if request.endpoint and 'objectives' in request.endpoint %}active{% endif %}">
<i class="fas fa-bullseye"></i>
<span>{{ _('Objectives') }}</span>
</a>
</li>
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
@@ -119,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>
@@ -179,7 +179,7 @@
{% endif %}
{% endwith %}
</div>
<div class="content">
<div class="content" id="page-content">
{% block content %}{% endblock %}
</div>
</div>
+1 -1
View File
@@ -27,7 +27,7 @@
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="
<form method="POST" data-ajax action="
{% if context_type == 'tryout' %}
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
{% elif context_type == 'match' %}
+4 -4
View File
@@ -73,7 +73,7 @@
Only admins can create or modify tryouts. Coaches and managers are locked out.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
<form method="POST" data-ajax action="{{ url_for('admin.toggle_tryouts') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
@@ -100,7 +100,7 @@
Only admins can upload or sign contracts. Other roles are blocked.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_document_uploads') }}">
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_uploads') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.document_uploads_enabled %}warning{% else %}success{% endif %}">
{% if settings.document_uploads_enabled %}Disable Uploads{% else %}Enable Uploads{% endif %}
@@ -126,7 +126,7 @@
{% if not settings.season_active %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.start_season') }}">
<form method="POST" data-ajax action="{{ url_for('admin.start_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Season Name</label>
@@ -140,7 +140,7 @@
</form>
{% else %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.end_season') }}">
<form method="POST" data-ajax action="{{ url_for('admin.end_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>End Date</label>
@@ -0,0 +1,134 @@
{% extends "layouts/base.html" %}
{% block title %}{{ document.title }} - Admin{% endblock %}
{% block page_title %}{{ document.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / <a href="{{ url_for('admin.documents') }}">{{ _('Documents') }}</a> / {{ document.title }}</span>{% endblock %}
{% block content %}
{% set kind_labels = {
'player': _('Player contract'),
'coach': _('Coach contract'),
'manager': _('Manager contract'),
'team': _('Team agreement'),
'code_of_conduct': _('Code of conduct'),
'ethics_code': _('Ethics code'),
} %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-upload"></i> {{ _('Publish a new version') }}</h3>
</div>
<div class="card-body">
<p class="text-muted">
{{ _('Type') }}: <strong>{{ kind_labels.get(document.kind, document.kind) }}</strong>
{% if document.requires_signature %} · {{ _('signed by the member') }}
{% else %} · {{ _('acknowledged by the member') }}{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.publish_document_version', doc_id=document.id) }}" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="version_file">{{ _('New version file (PDF)') }}</label>
<input type="file" id="version_file" name="version_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div>
<div class="form-group">
<label for="notes">{{ _('Version notes (optional)') }}</label>
<textarea id="notes" name="notes" rows="2" placeholder="{{ _('What changed in this version?') }}"></textarea>
</div>
<div class="form-actions">
<a href="{{ url_for('admin.documents') }}" class="btn btn-secondary">{{ _('Back') }}</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload"></i> {{ _('Publish version') }}
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-history"></i> {{ _('Versions') }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Version') }}</th>
<th>{{ _('File') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Published') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for version in versions %}
<tr>
<td>
{{ _('v%(number)s', number=version.version) }}
{% if document.current_version_id == version.id %}
<span class="badge badge-success">{{ _('Current') }}</span>{% endif %}
</td>
<td>{{ version.original_filename }}</td>
<td>{{ version.notes or '—' }}</td>
<td>{{ version.published_at.strftime('%Y-%m-%d %H:%M') if version.published_at else '—' }}</td>
<td>
<a href="{{ url_for('admin.download_document_version', version_id=version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
<i class="fas fa-download"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-muted">{{ _('No versions published yet.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-list-check"></i> {{ _('Completion') }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Member') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Completed at') }}</th>
</tr>
</thead>
<tbody>
{% for assignment, user in assignments %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.role | capitalize }}</td>
<td>
{% if assignment.status == 'signed' %}
<span class="badge badge-success">{{ _('Signed') }}</span>
{% elif assignment.status == 'acknowledged' %}
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
{% else %}
<span class="badge badge-warning">{{ _('Pending') }}</span>
{% endif %}
</td>
<td>{{ assignment.completed_at.strftime('%Y-%m-%d %H:%M') if assignment.completed_at else '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-muted">
{{ _('No one is assigned yet. Publish a version first.') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('Documents') }} - Admin{% endblock %}
{% block page_title %}{{ _('Document management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / {{ _('Documents') }}</span>{% endblock %}
{% block content %}
{% set kind_labels = {
'player': _('Player contract'),
'coach': _('Coach contract'),
'manager': _('Manager contract'),
'team': _('Team agreement'),
'code_of_conduct': _('Code of conduct'),
'ethics_code': _('Ethics code'),
} %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-plus-circle"></i> {{ _('Create a document') }}</h3>
</div>
<div class="card-body">
<form method="POST" data-ajax action="{{ url_for('admin.create_document') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="title">{{ _('Title') }}</label>
<input type="text" id="title" name="title" class="form-control" required
placeholder="{{ _('e.g. Player contract 2026-2027') }}">
</div>
<div class="form-group">
<label for="kind">{{ _('Type') }}</label>
<select id="kind" name="kind" class="form-select" required>
<option value="">{{ _('-- Select a type --') }}</option>
{% for kind in kinds %}
<option value="{{ kind }}">{{ kind_labels.get(kind, kind) }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
{{ _('The type decides who must complete the document and how.') }}
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i> {{ _('Create document') }}
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-file-contract"></i> {{ _('Existing documents') }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Title') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Version') }}</th>
<th>{{ _('Availability') }}</th>
<th>{{ _('Progress') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for document in documents %}
<tr>
<td><a href="{{ url_for('admin.document_detail', doc_id=document.id) }}">{{ document.title }}</a></td>
<td>{{ kind_labels.get(document.kind, document.kind) }}</td>
<td>
{% if document.current_version %}
{{ _('v%(number)s', number=document.current_version.version) }}
{% else %}{{ _('None') }}{% endif %}
</td>
<td>
{% if document.is_published %}
<span class="badge badge-success">{{ _('Downloadable') }}</span>
{% else %}
<span class="badge badge-secondary">{{ _('Hidden') }}</span>
{% endif %}
</td>
<td>
{% set completed, pending = document.completion_counts() %}
<span class="badge badge-info">{{ _('%(completed)s done', completed=completed) }}</span>
<span class="badge badge-warning">{{ _('%(pending)s pending', pending=pending) }}</span>
</td>
<td>
<div class="btn-group">
<a href="{{ url_for('admin.document_detail', doc_id=document.id) }}" class="btn btn-sm btn-primary" title="{{ _('Manage') }}">
<i class="fas fa-cog"></i> {{ _('Manage') }}
</a>
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_published', doc_id=document.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if document.is_published %}
<button type="submit" class="btn btn-sm btn-secondary" title="{{ _('Hide') }}">
<i class="fas fa-eye-slash"></i> {{ _('Hide') }}
</button>
{% else %}
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Make downloadable') }}">
<i class="fas fa-eye"></i> {{ _('Publish') }}
</button>
{% endif %}
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-muted">{{ _('No documents yet. Create one above.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
<form method="POST" data-ajax action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% for entry in players %}
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
+55 -2
View File
@@ -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;
@@ -346,6 +391,14 @@ function showEventModal(event) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
}
// Team match details
if (type === 'team_match') {
grid.appendChild(detailItem('Team', document.createTextNode(props.org_team || 'Team')));
if (props.opponent) {
grid.appendChild(detailItem('Opponent', document.createTextNode(props.opponent)));
}
}
if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
}
+1 -1
View File
@@ -25,7 +25,7 @@
</div>
{% endif %}
<form method="POST" action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
<form method="POST" data-ajax action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
+122 -54
View File
@@ -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 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>
{% endif %}
<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 %}
<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>
{% 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>
<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="{{ 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') }}&nbsp;: <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') }} &middot; {{ 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="15" class="text-center">
<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 %}
+1 -1
View File
@@ -22,7 +22,7 @@
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<form method="POST" data-ajax action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if is_practice %}
<input type="hidden" name="title" value="Practice">
+173
View File
@@ -0,0 +1,173 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('My Documents') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('My Documents') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / {{ _('My Documents') }}</span>{% endblock %}
{% block content %}
{% set kind_labels = {
'player': _('Player contract'),
'coach': _('Coach contract'),
'manager': _('Manager contract'),
'team': _('Team agreement'),
'code_of_conduct': _('Code of conduct'),
'ethics_code': _('Ethics code'),
} %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-contract"></i> {{ _('Documents to sign or acknowledge') }}</h3>
</div>
<div class="card-body">
{% if documents %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Document') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Version') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for entry in documents %}
<tr>
<td>{{ entry.document.title }}</td>
<td>{{ kind_labels.get(entry.document.kind, entry.document.kind) }}</td>
<td>
{% if entry.document.current_version %}
{{ _('Version %(number)s', number=entry.document.current_version.version) }}
{% else %}—{% endif %}
</td>
<td>
{% set assignment = entry.assignment %}
{% if assignment is not none and assignment.status == 'signed' %}
<span class="badge badge-success">{{ _('Signed') }}</span>
{% elif assignment is not none and assignment.status == 'acknowledged' %}
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
{% else %}
<span class="badge badge-warning">{{ _('Pending') }}</span>
{% endif %}
</td>
<td>
<div class="btn-group">
{% if entry.document.current_version %}
<a href="{{ url_for('users.download_document', version_id=entry.document.current_version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
<i class="fas fa-download"></i> {{ _('Download') }}
</a>
{% endif %}
{% if assignment is not none and assignment.status == 'pending' %}
{% if entry.document.requires_signature %}
<button class="btn btn-sm btn-warning" data-action="show-sign-document" data-assignment-id="{{ assignment.id }}" title="{{ _('Return signed') }}">
<i class="fas fa-upload"></i> {{ _('Sign') }}
</button>
{% else %}
<form method="POST" data-ajax action="{{ url_for('users.acknowledge_document', assignment_id=assignment.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" data-confirm="{{ _('Confirm that you have read and understood this document') }}">
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
</button>
</form>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif not teams_progress %}
<div class="empty-state">
<i class="fas fa-file-contract"></i>
<h4>{{ _('No documents for now') }}</h4>
<p>{{ _('Nothing has been published for you yet. Check back later.') }}</p>
</div>
{% endif %}
</div>
</div>
{% if teams_progress %}
{% for block in teams_progress %}
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ _('Team progress') }} — {{ block.team.name }}</h3>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Member') }}</th>
<th>{{ _('Role') }}</th>
{% for doc in block.documents %}<th>{{ doc.title }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for member in block.members %}
<tr>
<td>{{ member.user.username }}</td>
<td>{{ member.user.role | capitalize }}</td>
{% for doc in block.documents %}
{% set status = member.statuses.get(doc.id) %}
<td>
{% if status == 'signed' %}<span class="badge badge-success">{{ _('Signed') }}</span>
{% elif status == 'acknowledged' %}<span class="badge badge-success">{{ _('Acknowledged') }}</span>
{% elif status == 'pending' %}<span class="badge badge-warning">{{ _('Pending') }}</span>
{% else %}<span class="text-muted">{{ _('Not required') }}</span>{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
{% endif %}
<!-- Upload signed document modal -->
<div id="signDocumentModal" class="modal hidden">
<div class="modal-backdrop" data-action="hide-sign-document"></div>
<div class="modal-content">
<div class="modal-header">
<h3>{{ _('Upload Signed Document') }}</h3>
<button class="modal-close" data-action="hide-sign-document">&times;</button>
</div>
<div class="modal-body">
<form id="signDocumentForm" method="POST" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="signed_file">{{ _('Select Signed Document File') }}</label>
<input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" data-action="hide-sign-document">{{ _('Cancel') }}</button>
<button type="submit" class="btn btn-primary">{{ _('Upload Signed Document') }}</button>
</div>
</form>
</div>
</div>
</div>
<script nonce="{{ csp_nonce }}">
function showSignDocumentForm(assignmentId) {
document.getElementById('signDocumentForm').action = '/users/documents/assignments/' + assignmentId + '/sign';
document.getElementById('signDocumentModal').classList.remove('hidden');
}
function hideSignDocumentForm() {
document.getElementById('signDocumentModal').classList.add('hidden');
}
registerActions({
'show-sign-document': function (element) {
showSignDocumentForm(element.getAttribute('data-assignment-id'));
},
'hide-sign-document': hideSignDocumentForm,
});
</script>
{% endblock %}
+1 -1
View File
@@ -192,7 +192,7 @@ function togglePresence(btn) {
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
location.reload();
window.TT.refreshPage(window.location.pathname);
})
.catch(function(error) {
console.error('Error:', error);
+4 -4
View File
@@ -13,7 +13,7 @@
<span class="badge badge-esport">{{ org_team.name }}</span>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
<form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
@@ -41,7 +41,7 @@
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
<form method="POST" data-ajax action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
@@ -145,7 +145,7 @@
</td>
<td>
{% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<form method="POST" data-ajax action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}">
<i class="fas fa-check"></i> {{ _('Accept') }}
@@ -177,7 +177,7 @@
<h4><i class="fas fa-times-circle"></i> {{ _('Reject One on One Request') }}</h4>
<button type="button" class="modal-close" data-action="hide-reject-modal">&times;</button>
</div>
<form id="rejectForm" method="POST" action="">
<form id="rejectForm" method="POST" data-ajax action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<div class="form-group">
-348
View File
@@ -1,348 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %} - UdeS team manager{% endblock %}
{% block page_title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {% if edit_mode %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %}</span>{% endblock %}
{% block content %}
<div class="objective-editor">
<div class="objective-editor-form">
<div class="card">
<div class="card-header"><h3><i class="fas fa-edit"></i> {% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('Create Objective') }}{% endif %}</h3></div>
<div class="card-body">
<form method="POST" action="{% if edit_mode %}{{ url_for('objectives.update_objective', objective_id=objective.id) }}{% else %}{{ url_for('objectives.create_objective') }}{% endif %}" class="form" id="objForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-section">
<h4><i class="fas fa-info-circle"></i> {{ _('1. General Information') }}</h4>
<div class="form-row">
<div class="form-group col-6">
<label for="obj_type">{{ _('Type') }}</label>
<select name="type" id="obj_type" class="form-select" onchange="updatePreview();toggleAssignment()">
{% for val, label in objective_types %}<option value="{{ val }}"{% if objective and objective.type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="obj_status">{{ _('Status') }}</label>
<select name="status" id="obj_status" class="form-select" onchange="updatePreview()">
{% for val, label in objective_statuses %}<option value="{{ val }}"{% if objective and objective.status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="obj_title">{{ _('Title') }} <span class="required">*</span></label>
<input type="text" name="title" id="obj_title" class="form-input" required value="{{ objective.title if objective else '' }}" placeholder="{{ _('e.g. Improve headshot accuracy') }}" oninput="updatePreview()">
</div>
<div class="form-group">
<label for="obj_description">{{ _('Description') }}</label>
<textarea name="description" id="obj_description" rows="3" class="form-textarea" placeholder="{{ _('Describe the objective...') }}" oninput="updatePreview()">{{ objective.description if objective else '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group col-6" id="playerField">
<label for="obj_player_id">{{ _('Assigned to (Player)') }}</label>
<select name="player_id" id="obj_player_id" class="form-select" onchange="updatePreview()">
<option value="">{{ _('-- Select a player --') }}</option>
{% for p in players %}<option value="{{ p.id }}"{% if objective and objective.player_id == p.id %} selected{% endif %}>{{ p.username }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6" id="teamField">
<label for="obj_team_id">{{ _('Team') }}</label>
<select name="team_id" id="obj_team_id" class="form-select" onchange="updatePreview()">
<option value="">{{ _('-- Select a team --') }}</option>
{% for t in teams %}<option value="{{ t.id }}"{% if objective and objective.team_id == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" name="is_template" id="obj_is_template" value="1"{% if objective and objective.is_template %} checked{% endif %} onchange="updatePreview();toggleAssignment()"> {{ _('This is a template (reusable pattern)') }}</label>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-chart-line"></i> {{ _('2. KPI & Target') }}</h4>
<div class="form-row">
<div class="form-group col-4">
<label for="obj_kpi_metric">{{ _('KPI metric') }}</label>
<input type="text" name="kpi_metric" id="obj_kpi_metric" class="form-input" value="{{ objective.kpi_metric if objective else '' }}" placeholder="{{ _('e.g. HS rate, KDA, CS/min') }}" oninput="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_kpi_target">{{ _('Target value') }}</label>
<input type="number" step="0.01" name="kpi_target" id="obj_kpi_target" class="form-input" value="{{ objective.kpi_target if objective else '' }}" placeholder="{{ _('e.g. 40') }}" oninput="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_kpi_unit">{{ _('Unit') }}</label>
<input type="text" name="kpi_unit" id="obj_kpi_unit" class="form-input" value="{{ objective.kpi_unit if objective else '' }}" placeholder="{{ _('e.g. points, percentage') }}" oninput="updatePreview()">
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-calendar-alt"></i> {{ _('3. Deadline & Priority') }}</h4>
<div class="form-row">
<div class="form-group col-4">
<label for="obj_start_date">{{ _('Start date') }}</label>
<input type="date" name="start_date" id="obj_start_date" class="form-input" value="{{ objective.start_date.strftime('%Y-%m-%d') if objective and objective.start_date else '' }}" onchange="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_due_date">{{ _('Deadline') }} <span class="required">*</span></label>
<input type="date" name="due_date" id="obj_due_date" class="form-input" required value="{{ objective.due_date.strftime('%Y-%m-%d') if objective else '' }}" onchange="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_priority">{{ _('Priority') }}</label>
<select name="priority" id="obj_priority" class="form-select" onchange="updatePreview()">
{% for val, label in objective_priorities %}<option value="{{ val }}"{% if objective and objective.priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-bell"></i> {{ _('4. Tracking & Alerts') }}</h4>
<div class="form-row">
<div class="form-group col-6">
<label for="obj_check_frequency">{{ _('Check frequency') }}</label>
<select name="check_frequency" id="obj_check_frequency" class="form-select">
<option value="">{{ _('-- None --') }}</option>
{% for val, label in objective_frequencies %}<option value="{{ val }}"{% if objective and objective.check_frequency == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="obj_progress">{{ _('Progress') }} (<span id="progressVal">{{ objective.progress if objective else 0 }}</span>%)</label>
<input type="range" id="obj_progress" name="progress" min="0" max="100" value="{{ objective.progress if objective else 0 }}" class="form-range" oninput="document.getElementById('progressVal').textContent=this.value;updatePreview()">
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-cog"></i> {{ _('5. Advanced Settings') }}</h4>
<div class="form-group">
<label for="obj_category">{{ _('Category') }}</label>
<input type="text" name="category" id="obj_category" class="form-input" value="{{ objective.category if objective else '' }}" placeholder="{{ _('e.g. Mechanics, Teamplay, Communication') }}">
</div>
<div class="form-group">
<label for="obj_tags">{{ _('Tags') }}</label>
<input type="text" name="tags" id="obj_tags" class="form-input" value="{{ objective.tags if objective else '' }}" placeholder="{{ _('Separate with commas') }}">
</div>
</div>
<div class="form-actions">
<a href="{{ url_for('objectives.list_objectives') }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> {% if edit_mode %}{{ _('Update') }}{% else %}{{ _('Create') }}{% endif %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="objective-editor-preview">
<div class="card mb-4" id="previewCard">
<div class="card-header"><h3><i class="fas fa-eye"></i> {{ _('Player Preview') }}</h3></div>
<div class="card-body">
<div id="previewContent">
<h4 id="previewTitle">{{ objective.title if objective else _('Objective title') }}</h4>
<p id="previewDesc" class="text-muted small">{{ objective.description if objective else '' }}</p>
<div class="objective-tags" id="previewTags">{% if objective and objective.tags %}{% for tag in objective.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}{% endif %}</div>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span id="previewAssignee" class="preview-value">{% if objective and objective.player %}{{ objective.player.username }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('KPI') }}</span><span id="previewKpi" class="preview-value">{% if objective and objective.kpi_metric %}{{ objective.kpi_metric }}: {{ objective.kpi_target }}{{ objective.kpi_unit }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span id="previewDeadline" class="preview-value">{% if objective and objective.due_date %}{{ objective.due_date.strftime('%Y-%m-%d') }}{% endif %}</span></div>
</div>
<div class="progress-section">
<span class="preview-label">{{ _('Progress') }}</span>
<div class="progress-bar-container" style="width:100%"><div class="progress-bar" id="previewBar" style="width:{{ objective.progress if objective else 0 }}%"></div></div>
<strong><span id="previewProgress">{{ objective.progress if objective else 0 }}</span>%</strong>
</div>
</div>
<div class="mt-3">
<span id="previewStatusBadge" class="badge">{% if objective %}{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}{% else %}{{ _('Active') }}{% endif %}</span>
<span id="previewPriorityBadge" class="badge">{% if objective %}{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(objective.priority,objective.priority) }}{% else %}{{ _('Medium') }}{% endif %}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if edit_mode and objective.kpi_target %}
<div class="mt-2 text-center" id="predictionInfo"></div>
{% endif %}
</div>
</div>
{% if edit_mode %}
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;">
<input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value') }}" required>
</div>
<div class="form-group" style="flex:2;">
<input type="text" name="note" class="form-input" placeholder="{{ _('Note (optional)') }}">
</div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}
<table class="table mt-2">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead>
<tbody>
{% for e in entries %}
<tr><td>{{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}</td><td><strong>{{ e.value }}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_comment', objective_id=objective.id) }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<textarea name="content" rows="2" class="form-textarea" placeholder="{{ _('Add a comment...') }}" required></textarea>
</div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-paper-plane"></i> {{ _('Comment') }}</button>
</form>
{% if comments %}
<div class="comments-list mt-2">
{% for c in comments %}
<div class="comment-item">
<strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<p>{{ c.content }}</p>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div>
<div class="card-body">
{% if personal_notes %}
<h5>{{ _('Player Notes') }}</h5>
{% for n in personal_notes %}
<div class="comment-item">
<strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span>
<p>{{ n.content[:200] }}</p>
</div>
{% endfor %}
{% endif %}
{% if team_notes %}
<h5 class="mt-2">{{ _('Team Notes') }}</h5>
{% for n in team_notes %}
<div class="comment-item">
<strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span>
<p>{{ n.content[:200] }}</p>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if templates %}
<div class="card">
<div class="card-header"><h3><i class="fas fa-copy"></i> {{ _('Templates') }}</h3></div>
<div class="card-body" id="templatesList">
{% for t in templates %}
<div class="template-item">
<h5>{{ t.title }}</h5>
<p class="text-muted small">{{ t.description[:120] if t.description else '' }}</p>
<button type="button" class="btn btn-sm btn-outline-primary use-template-btn" onclick="applyTemplate({{ t.id }})"><i class="fas fa-arrow-right"></i> {{ _('Use this template') }}</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var templateDataUrl = '{{ url_for("objectives.objective_data", objective_id=0) }}';
var templates = {};
{% for t in templates %}
(function(id) {
fetch(templateDataUrl.replace('/0/', '/' + id + '/')).then(function(r) { return r.json(); }).then(function(d) { templates[id] = d; });
})({{ t.id }});
{% endfor %}
function toggleAssignment() {
var t = document.getElementById('obj_type').value;
var isTmpl = document.getElementById('obj_is_template').checked;
var pf = document.getElementById('playerField');
var tf = document.getElementById('teamField');
if (isTmpl) { pf.style.display = 'none'; tf.style.display = 'none'; return; }
pf.style.display = t === 'team' ? 'none' : '';
tf.style.display = t === 'individual' ? 'none' : '';
}
function applyTemplate(id) {
var t = templates[id]; if (!t) return;
document.getElementById('obj_title').value = t.title || '';
document.getElementById('obj_description').value = t.description || '';
document.getElementById('obj_type').value = t.type || 'individual';
document.getElementById('obj_status').value = t.status || 'active';
document.getElementById('obj_priority').value = t.priority || 'medium';
document.getElementById('obj_kpi_metric').value = t.kpi_metric || '';
document.getElementById('obj_kpi_target').value = t.kpi_target || '';
document.getElementById('obj_kpi_unit').value = t.kpi_unit || '';
document.getElementById('obj_start_date').value = t.start_date || '';
document.getElementById('obj_due_date').value = '';
document.getElementById('obj_check_frequency').value = t.check_frequency || '';
document.getElementById('obj_category').value = t.category || '';
document.getElementById('obj_tags').value = t.tags || '';
document.getElementById('obj_progress').value = 0;
document.getElementById('progressVal').textContent = '0';
toggleAssignment(); updatePreview();
}
function updatePreview() {
var title = document.getElementById('obj_title').value || '';
var desc = document.getElementById('obj_description').value || '';
var status = document.getElementById('obj_status').value;
var priority = document.getElementById('obj_priority').value;
var kpiMetric = document.getElementById('obj_kpi_metric').value;
var kpiTarget = document.getElementById('obj_kpi_target').value;
var kpiUnit = document.getElementById('obj_kpi_unit').value;
var deadline = document.getElementById('obj_due_date').value;
var progress = document.getElementById('obj_progress').value;
var sel = document.getElementById('obj_player_id');
var playerName = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '-';
document.getElementById('previewTitle').textContent = title || '{{ _('Objective title') | e }}';
document.getElementById('previewDesc').textContent = desc;
document.getElementById('previewAssignee').textContent = playerName;
document.getElementById('previewKpi').textContent = kpiMetric ? kpiMetric + ': ' + kpiTarget + (kpiUnit || '') : '-';
document.getElementById('previewDeadline').textContent = deadline;
document.getElementById('previewBar').style.width = progress + '%';
document.getElementById('previewProgress').textContent = progress;
var sl = {}, sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'};
var pl = {}, pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'};
{% for val, label in objective_statuses %}sl['{{ val }}'] = '{{ label | e }}';{% endfor %}
{% for val, label in objective_priorities %}pl['{{ val }}'] = '{{ label | e }}';{% endfor %}
document.getElementById('previewStatusBadge').textContent = sl[status] || status;
document.getElementById('previewStatusBadge').className = 'badge ' + (sc[status] || 'badge-secondary');
document.getElementById('previewPriorityBadge').textContent = pl[priority] || priority;
document.getElementById('previewPriorityBadge').className = 'badge ' + (pc[priority] || 'badge-secondary');
drawChart(progress, kpiTarget);
}
function drawChart(progress, target) {
var svg = document.getElementById('progressChart');
var w = 400, h = 200, pt = 20, pr = 20, pb = 30, pl = 40;
var cw = w - pl - pr, ch = h - pt - pb;
var prog = Math.min(parseInt(progress) || 0, 100);
var py = pt + ch * (1 - prog / 100);
var tgt = parseFloat(target) || 100;
var tgtScale = Math.max(tgt, prog);
var tgtNorm = tgt / (tgtScale);
var ty = pt + ch * (1 - tgtNorm);
var html = '<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="transparent"/>';
html += '<line x1="' + pl + '" y1="' + (pt + ch) + '" x2="' + (pl + cw) + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<line x1="' + pl + '" y1="' + pt + '" x2="' + pl + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + 5) + '" fill="#999" font-size="10">' + tgtScale + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch / 2 + 4) + '" fill="#999" font-size="10">' + (tgtScale / 2).toFixed(1) + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch + 4) + '" fill="#999" font-size="10">0</text>';
html += '<text x="' + pl + '" y="' + (pt + ch + 18) + '" fill="#999" font-size="10">{{ _('Now') | e }}</text>';
html += '<text x="' + (pl + cw - 50) + '" y="' + (pt + ch + 18) + '" fill="#999" font-size="10">{{ _('Deadline') | e }}</text>';
html += '<line x1="' + pl + '" y1="' + ty + '" x2="' + (pl + cw) + '" y2="' + ty + '" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="4,3"/>';
html += '<text x="' + (pl + cw + 4) + '" y="' + (ty + 4) + '" fill="#4CAF50" font-size="9">{{ _('Target') | e }}</text>';
html += '<polygon points="' + pl + ',' + (pt + ch) + ' ' + pl + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + (pt + ch) + '" fill="rgba(59,130,246,0.15)"/>';
html += '<line x1="' + pl + '" y1="' + py + '" x2="' + (pl + (prog / 100) * cw) + '" y2="' + py + '" stroke="#3B82F6" stroke-width="2"/>';
html += '<circle cx="' + (pl + (prog / 100) * cw) + '" cy="' + py + '" r="4" fill="#3B82F6"/>';
html += '<text x="' + (pl + (prog / 100) * cw - 12) + '" y="' + (py - 8) + '" fill="#3B82F6" font-size="10" font-weight="bold">' + prog + '%</text>';
svg.innerHTML = html;
}
document.addEventListener('DOMContentLoaded', function() { toggleAssignment(); updatePreview(); });
</script>
{% endblock %}
-125
View File
@@ -1,125 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ objective.title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ objective.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {{ objective.title }}</span>{% endblock %}
{% block content %}
<div class="row">
<div class="col-8">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ objective.title }}</h3></div>
<div class="card-body">
<p>{{ objective.description or '' }}</p>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Type') }}</span><span class="preview-value">{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span class="preview-value">{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Category') }}</span><span class="preview-value">{{ objective.category or '-' }}</span></div>
</div>
<div class="mt-3">
<span id="statusBadge" class="badge">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span>
<span id="priorityBadge" class="badge">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if objective.kpi_target %}<div class="mt-2 text-center" id="predictionInfo"></div>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;"><input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value (optional)') }}"></div>
<div class="form-group" style="flex:2;"><input type="text" name="note" class="form-input" placeholder="{{ _('Note or comment') }}"></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}<table class="table mt-2"><thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead><tbody>
{% for e in entries %}<tr><td>{{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}</td><td><strong>{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>{% endfor %}</tbody></table>{% else %}<p class="text-muted mt-2">{{ _('No measurements recorded yet.') }}</p>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">{% if comments %}<div class="comments-list">{% for c in comments %}<div class="comment-item"><strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span><p>{{ c.content }}</p></div>{% endfor %}</div>{% else %}<p class="text-muted">{{ _('No comments yet.') }}</p>{% endif %}</div>
</div>
</div>
<div class="col-4">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-info-circle"></i> {{ _('Details') }}</h3></div>
<div class="card-body">
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Priority') }}</span><span class="preview-value">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Status') }}</span><span class="preview-value">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Start date') }}</span><span class="preview-value">{{ objective.start_date.strftime('%Y-%m-%d') if objective.start_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span class="preview-value">{{ objective.due_date.strftime('%Y-%m-%d') if objective.due_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Progress') }}</span><span class="preview-value">{{ objective.progress }}%</span></div>
</div>
{% if objective.kpi_target %}<div class="progress-section mt-2"><span class="preview-label">{{ _('KPI Target') }}</span><strong>{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}</strong></div>{% endif %}
{% if can_edit %}<a href="{{ url_for('objectives.edit_objective', objective_id=objective.id) }}" class="btn btn-sm btn-primary mt-3"><i class="fas fa-edit"></i> {{ _('Edit') }}</a>{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}<div class="card mb-4"><div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div><div class="card-body">{% if personal_notes %}<h5>{{ _('Personal Notes') }}</h5>{% for n in personal_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}{% if team_notes %}<h5 class="mt-2">{{ _('Team Notes') }}</h5>{% for n in team_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}</div></div>{% endif %}
</div>
</div>{% endblock %}
{% block scripts %}
<script>
function drawChart(progress, target) {
var svg = document.getElementById('progressChart');
if (!svg) return;
var w = 400, h = 200, pt = 20, pr = 20, pb = 30, pl = 40;
var cw = w - pl - pr, ch = h - pt - pb;
var prog = Math.min(parseInt(progress) || 0, 100);
var py = pt + ch * (1 - prog / 100);
var tgt = parseFloat(target) || 100;
var tgtScale = Math.max(tgt, prog);
var ty = pt + ch * (1 - tgt / tgtScale);
var html = '<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="transparent"/>';
html += '<line x1="' + pl + '" y1="' + (pt + ch) + '" x2="' + (pl + cw) + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<line x1="' + pl + '" y1="' + pt + '" x2="' + pl + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + 5) + '" fill="#999" font-size="10">' + tgtScale + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch / 2 + 4) + '" fill="#999" font-size="10">' + (tgtScale / 2).toFixed(1) + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch + 4) + '" fill="#999" font-size="10">0</text>';
html += '<line x1="' + pl + '" y1="' + ty + '" x2="' + (pl + cw) + '" y2="' + ty + '" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="4,3"/>';
html += '<text x="' + (pl + cw + 4) + '" y="' + (ty + 4) + '" fill="#4CAF50" font-size="9">{{ _('Target') | e }}</text>';
html += '<polygon points="' + pl + ',' + (pt + ch) + ' ' + pl + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + (pt + ch) + '" fill="rgba(59,130,246,0.15)"/>';
html += '<line x1="' + pl + '" y1="' + py + '" x2="' + (pl + (prog / 100) * cw) + '" y2="' + py + '" stroke="#3B82F6" stroke-width="2"/>';
html += '<circle cx="' + (pl + (prog / 100) * cw) + '" cy="' + py + '" r="4" fill="#3B82F6"/>';
svg.innerHTML = html;
fetchTrendAndEntries();
}
function fetchTrendAndEntries() {
var trendUrl = '{{ url_for("objectives.trend_data", objective_id=objective.id) }}';
var entriesUrl = '{{ url_for("objectives.list_entries", objective_id=objective.id) }}';
Promise.all([fetch(trendUrl).then(function(r){return r.json()}), fetch(entriesUrl).then(function(r){return r.json()})]).then(function(results) {
var trend = results[0], entries = results[1];
var infoEl = document.getElementById('predictionInfo');
if (trend.predicted_achievement_date) {
infoEl.innerHTML = '<strong>{{ _("Predicted achievement") }}:</strong> ' + new Date(trend.predicted_achievement_date).toLocaleDateString() + ' - <span class=\"' + (trend.confidence > 0.6 ? 'confidence-high' : trend.confidence > 0.3 ? 'confidence-medium' : 'confidence-low') + '\">{{ _("Confidence") }}: ' + (trend.confidence * 100).toFixed(0) + '%</span>';
}
if (entries && entries.length > 1) {
var svg = document.getElementById('progressChart');
var html = svg.innerHTML;
var w = 400, pl = 40, pt = 20, cw = 320, ch = 150;
var entryValues = entries.map(function(e){return parseFloat(e.value)||0;});
var maxVal = Math.max.apply(null, entryValues);
var range = maxVal || 1;
var stepX = cw / (entries.length - 1);
entryValues.forEach(function(v, i) {
var ex = pl + i * stepX;
var ey = pt + ch * (1 - v / range);
html += '<circle cx="' + ex + '" cy="' + ey + '" r="3" fill="#8B5CF6"/>';
if (i > 0) {
var pex = pl + (i - 1) * stepX;
var pey = pt + ch * (1 - entryValues[i-1] / range);
html += '<line x1="' + pex + '" y1="' + pey + '" x2="' + ex + '" y2="' + ey + '" stroke="#8B5CF6" stroke-width="1.5"/>';
}
});
svg.innerHTML = html;
}
}).catch(function(){});
}
document.addEventListener('DOMContentLoaded', function() { drawChart({{ objective.progress }}, '{{ objective.kpi_target if objective.kpi_target else '' }}'); });
</script>
{% endblock %}
-64
View File
@@ -1,64 +0,0 @@
{% extends "layouts/base.html" %}
{% import 'layouts/_pagination.html' as pager %}
{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Objective Management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / {{ _('Objectives') }}</span>{% endblock %}
{% block content %}
<div class="stats-grid mb-4">
<div class="stat-card"><div class="stat-icon bg-primary"><i class="fas fa-flag-checkered"></i></div><div class="stat-info"><h3>{{ count_total }}</h3><p>{{ _('Total') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-info"><i class="fas fa-chart-line"></i></div><div class="stat-info"><h3>{{ count_kpi }}</h3><p>{{ _('Custom KPIs') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-secondary"><i class="fas fa-copy"></i></div><div class="stat-info"><h3>{{ count_templates }}</h3><p>{{ _('Templates') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-success"><i class="fas fa-percentage"></i></div><div class="stat-info"><h3>{{ success_rate }}%</h3><p>{{ _('Success rate') }}</p></div></div>
</div>
<div class="card mb-4"><div class="card-body"><div class="objectives-toolbar">
<nav class="objectives-tabs" role="tablist">
<a href="{{ url_for('objectives.list_objectives', tab='all') }}" class="tab-link {% if tab == 'all' %}active{% endif %}">{{ _('All') }} ({{ count_total }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='kpi') }}" class="tab-link {% if tab == 'kpi' %}active{% endif %}">{{ _('Custom KPIs') }} ({{ count_kpi }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='modeles') }}" class="tab-link {% if tab in ('modeles','templates') %}active{% endif %}">{{ _('Templates') }} ({{ count_templates }})</a>
</nav>
<form method="GET" action="{{ url_for('objectives.list_objectives') }}" class="objectives-search-bar">
<input type="hidden" name="tab" value="{{ tab }}">
<div class="form-inline">
<div class="form-group" style="flex:1;min-width:200px;"><input type="text" name="search" class="form-input" placeholder="{{ _('Search an objective...') }}"></div>
<div class="form-group"><select name="status" class="form-select"><option value="">{{ _('Status') }}</option>{% for val, label in objective_statuses %}<option value="{{ val }}"{% if f_status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="priority" class="form-select"><option value="">{{ _('Priority') }}</option>{% for val, label in objective_priorities %}<option value="{{ val }}"{% if f_priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="type" class="form-select"><option value="">{{ _('Type') }}</option>{% for val, label in objective_types %}<option value="{{ val }}"{% if f_type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="team" class="form-select"><option value="">{{ _('Team') }}</option>{% for t in teams %}<option value="{{ t.id }}"{% if f_team == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-filter"></i> {{ _('Filters') }}</button>
</div></form>
</div></div></div>
<div class="card"><div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ _('Objectives') }}</h3>{% if can_edit %}<a href="{{ url_for('objectives.new_objective') }}" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('New Objective') }}</a>{% endif %}</div>
<div class="card-body"><div class="table-container"><table class="table objectives-table"><thead><tr>
<th>{{ _('Objective') }}</th><th>{{ _('Assigned to') }}</th><th>{{ _('Team') }}</th><th>{{ _('Priority') }}</th><th>{{ _('Status') }}</th><th>{{ _('Target KPI') }}</th><th>{{ _('Progress') }}</th><th>{{ _('Deadline') }}</th>{% if can_edit %}<th>{{ _('Actions') }}</th>{% endif %}
</tr></thead><tbody>
{% for obj in objectives.items %}<tr data-objective-id="{{ obj.id }}">
<td><strong>{{ obj.title }}</strong>{% if obj.tags %}<div class="objective-tags">{% for tag in obj.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}</div>{% endif %}{% if obj.kpi_metric %}<small class="text-muted d-block">{{ obj.kpi_metric }}</small>{% endif %}</td>
<td>{% if obj.player %}<span class="user-avatar avatar-sm">{{ obj.player.username[:2]|upper }}</span> {{ obj.player.username }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% if obj.team %}{{ obj.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}<span class="badge {{ pc.get(obj.priority,'badge-secondary') }}">{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}</span></td>
<td>{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}<span class="badge {{ sc.get(obj.status,'badge-secondary') }}">{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}</span></td>
<td>{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td><div class="progress-bar-container"><div class="progress-bar" style="width:{{ obj.progress }}%"></div></div><small>{{ obj.progress }}%</small></td>
<td>{% if obj.is_template %}-{% elif obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %}</td>
{% if can_edit %}<td class="actions-cell"><a href="{{ url_for('objectives.edit_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-primary" title="{{ _('Edit') }}"><i class="fas fa-edit"></i></a> <a href="{{ url_for('objectives.view_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-info" title="{{ _('View') }}"><i class="fas fa-eye"></i></a> <button class="btn btn-sm btn-outline-danger delete-obj-btn" data-obj-id="{{ obj.id }}" data-obj-title="{{ obj.title }}" title="{{ _('Delete') }}"><i class="fas fa-trash"></i></button></td>{% elif current_user.role == 'player' %}<td><a href="{{ url_for('objectives.view_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-info"><i class="fas fa-eye"></i> {{ _('View') }}</a></td>{% endif %}
</tr>{% else %}
<tr><td colspan="{{ 9 if can_edit else 8 }}" class="text-center"><div class="empty-state"><i class="fas fa-bullseye"></i><h3>{{ _('No objectives yet') }}</h3><p>{{ _('Create your first objective to start tracking player evolution.') }}</p></div></td></tr>{% endfor %}
</tbody></table></div>{{ pager.controls(pagination) }}</div></div>
<div id="deleteModal" class="modal hidden"><div class="modal-backdrop" onclick="hideDeleteModal();"></div><div class="modal-content"><div class="modal-header"><h3><i class="fas fa-trash-alt"></i> {{ _('Delete Objective') }}</h3><button class="modal-close" onclick="hideDeleteModal();">&times;</button></div><div class="modal-body"><p>{{ _('Are you sure you want to delete this objective?') }}</p><p><strong id="deleteObjTitle"></strong></p><form id="deleteForm" method="POST" action=""><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="form-actions"><button type="button" class="btn btn-secondary" onclick="hideDeleteModal();">{{ _('Cancel') }}</button><button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> {{ _('Delete') }}</button></div></form></div></div></div>
{% endblock %}
{% block scripts %}<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.delete-obj-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.getAttribute('data-obj-id');
var title = this.getAttribute('data-obj-title');
document.getElementById('deleteObjTitle').textContent = title;
document.getElementById('deleteForm').action = '{{ url_for("objectives.delete_objective", objective_id=0) }}'.replace('/0/', '/' + id + '/');
document.getElementById('deleteModal').classList.remove('hidden');
});
});
document.getElementById('deleteModal').querySelector('.modal-backdrop').addEventListener('click', hideDeleteModal);
});
function hideDeleteModal(){document.getElementById('deleteModal').classList.add('hidden');}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){hideDeleteModal();}});
</script>{% endblock %}
+1 -1
View File
@@ -124,7 +124,7 @@
</div>
<div class="card-body">
{% if coach %}
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
<form method="POST" data-ajax action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('users.manage_personal_notes') }}" class="form">
<form method="POST" data-ajax action="{{ url_for('users.manage_personal_notes') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
+134
View File
@@ -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') }} &middot; {{ 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') }}&nbsp;: <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') }} &middot; {{ 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 %}
+1 -1
View File
@@ -9,7 +9,7 @@
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
</div>
<div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
<form method="POST" data-ajax action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
+3 -3
View File
@@ -98,7 +98,7 @@
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<form method="POST" data-ajax action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i>
@@ -170,8 +170,8 @@ function togglePresence(btn) {
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
// Reload to update presence bar
location.reload();
// Update in place to preserve state
window.TT.refreshPage(window.location.pathname);
})
.catch(function(error) {
console.error('Error:', error);
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form">
<form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
+7 -7
View File
@@ -18,7 +18,7 @@
<h3>{{ _('Create New Team') }}</h3>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('teams.create_team') }}" class="form">
<form method="POST" data-ajax action="{{ url_for('teams.create_team') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
<div class="form-group col-4">
@@ -63,7 +63,7 @@
<button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</button>
<form method="POST" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}">
<form method="POST" data-ajax action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i> {{ _('Delete') }}
@@ -89,7 +89,7 @@
<span class="staff-tag">
{{ c.username }}
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
<form method="POST" data-ajax action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="coach_id" value="{{ c.id }}"/>
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">&times;</button>
@@ -111,7 +111,7 @@
<span class="staff-tag manager-tag">
{{ m.username }}
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
<form method="POST" data-ajax action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="manager_id" value="{{ m.id }}"/>
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">&times;</button>
@@ -180,7 +180,7 @@
<td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %}
<td>
<form method="POST" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
@@ -210,7 +210,7 @@
<hr class="my-3">
<div class="add-player-section">
<h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5>
<form method="POST" action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
<form method="POST" data-ajax action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required>
<option value="">{{ _('-- Select a player --') }}</option>
@@ -258,7 +258,7 @@
<button class="modal-close" data-action="hide-edit-form">&times;</button>
</div>
<div class="modal-body">
<form id="editTeamForm" method="POST" action="" class="form">
<form id="editTeamForm" method="POST" data-ajax action="" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="sync_staff" value="1"/>
+195 -53
View File
@@ -15,7 +15,7 @@
</a>
{% endif %}
{% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
<form method="POST" data-ajax action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" data-submit-on-change class="form-select">
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option>
@@ -108,7 +108,7 @@
{% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %}
<div class="card mb-4">
<div class="card-body text-center">
<form method="POST" action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
<form method="POST" data-ajax action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }}
@@ -124,7 +124,7 @@
<h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
<form method="POST" data-ajax action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required>
<option value="">{{ _('-- Select a player --') }}</option>
@@ -173,7 +173,7 @@
</td>
<td>
{% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
<form method="POST" data-ajax action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" data-submit-on-change class="form-select form-select-sm">
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option>
@@ -205,7 +205,7 @@
</a>
{% endif %}
{% if not tryout.is_ended %}
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
<form method="POST" data-ajax action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}">
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
@@ -238,7 +238,7 @@
<div class="card-body">
{% if can_edit and not tryout.is_ended %}
<div id="createTeamForm" class="hidden mb-3">
<form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
<form method="POST" data-ajax action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2">
<button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button>
@@ -249,7 +249,14 @@
{% for team in team_data %}
<div class="team-card mb-3">
<h4 class="team-name">{{ team.team.name }}</h4>
<div class="team-card-header">
<h4 class="team-name">{{ team.team.name }}</h4>
{% if can_edit and not tryout.is_ended %}
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</button>
{% endif %}
</div>
<ul class="team-members">
{% for member in team.members %}
<li>
@@ -263,32 +270,96 @@
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
{% endfor %}
</ul>
{% if can_edit and not tryout.is_ended and registered_players %}
{% set positions = game_positions.get(tryout.game, []) %}
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required>
<option value="">{{ _('Select player...') }}</option>
{% for p in registered_players %}
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
<option value="{{ p.id }}">{{ p.username }}</option>
{% endif %}
{% endfor %}
</select>
{% if positions %}
<select name="position" class="form-select mx-2">
<option value="">{{ _('-- Select Position --') }}</option>
{% for pos in positions %}
<option value="{{ pos }}">{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2">
{% endif %}
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
</form>
{% endif %}
</div>
{% if can_edit and not tryout.is_ended %}
{% set positions = game_positions.get(tryout.game, []) %}
<!-- Edit Team Modal -->
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">&times;</button>
</div>
<div class="modal-body">
<!-- Rename -->
<form method="POST" data-ajax action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<label for="renameInput{{ team.team.id }}" class="mr-2" style="white-space:nowrap;">{{ _('Team name') }}</label>
<input type="text" id="renameInput{{ team.team.id }}" name="team_name" value="{{ team.team.name }}" required class="form-input" style="flex:1;">
<button type="submit" class="btn btn-sm btn-primary ml-2">{{ _('Rename') }}</button>
</form>
<hr class="section-divider">
<!-- Roster -->
<h5 class="mb-2"><i class="fas fa-users"></i> {{ _('Roster') }}</h5>
<div class="roster-rows">
{% for member in team.members %}
<div class="roster-row">
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
<span class="roster-player-name">{{ member.player.username }}</span>
<form method="POST" data-ajax action="{{ url_for('tryouts.update_member_position', tryout_id=tryout.id, team_id=team.team.id, member_id=member.member_id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="position" class="form-select roster-role-select" data-submit-on-change>
<option value="">{{ _('No role') }}</option>
{% for pos in positions %}
<option value="{{ pos }}" {% if member.position == pos %}selected{% endif %}>{{ pos }}</option>
{% endfor %}
</select>
</form>
<form method="POST" data-ajax action="{{ url_for('tryouts.remove_from_team', tryout_id=tryout.id, team_id=team.team.id, player_id=member.player_id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from this team?', player=member.player.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger roster-remove-btn" title="{{ _('Remove from team') }}">
<i class="fas fa-user-minus"></i>
</button>
</form>
</div>
{% else %}
<p class="text-muted text-center">{{ _('No players assigned yet.') }}</p>
{% endfor %}
</div>
{% if registered_players %}
<hr class="section-divider">
<h5 class="mb-2"><i class="fas fa-user-plus"></i> {{ _('Add Player') }}</h5>
<form method="POST" data-ajax action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required style="flex:1;">
<option value="">{{ _('Select player...') }}</option>
{% for p in registered_players %}
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
<option value="{{ p.id }}">{{ p.username }}</option>
{% endif %}
{% endfor %}
</select>
{% if positions %}
<select name="position" class="form-select mx-2">
<option value="">{{ _('-- Select Position --') }}</option>
{% for pos in positions %}
<option value="{{ pos }}">{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
{% endif %}
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
</form>
{% endif %}
<hr class="section-divider" style="border-color: var(--danger);">
<div class="text-center">
<form method="POST" data-ajax action="{{ url_for('tryouts.delete_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="inline-form" data-confirm="{{ _('Delete this team? Players already in the team will be removed and matches referencing it will be unlinked.') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% else %}
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
{% endfor %}
@@ -458,6 +529,12 @@
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</a>
<form method="POST" data-ajax action="{{ url_for('matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i> {{ _('Delete') }}
</button>
</form>
{% endif %}
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
<i class="fas fa-sticky-note"></i> {{ _('Note') }}
@@ -568,8 +645,56 @@
transform: scale(1.08);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
/* Team card header — edit button aligned right */
.team-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.team-card-header .team-name {
margin-bottom: 0;
}
/* Roster rows inside modal */
.roster-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.roster-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--gray-50);
border-radius: var(--radius-sm);
}
.roster-player-name {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
}
.roster-role-select {
width: auto;
font-size: 0.75rem;
padding: 1px 4px;
}
.roster-remove-btn {
padding: 1px 5px;
font-size: 0.7rem;
}
[data-theme="dark"] .roster-row {
background: var(--bg-tertiary);
}
</style>
<script nonce="{{ csp_nonce }}">
function showModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
@@ -592,41 +717,58 @@ function toggleTryoutPresence(matchId, participantId, btn) {
btn.classList.add('presence-pending-btn');
btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
}
location.reload();
window.TT.refreshPage(window.location.pathname);
})
.catch(function(err) {
console.error('Error toggling tryout presence:', err);
});
}
// Mini calendar for matches
document.addEventListener('DOMContentLoaded', function() {
// Mini calendar for matches. Re-initialised on fragment:updated so the
// calendar survives AJAX edits that swap the page content in place.
var miniCalendar = null;
function initMiniCalendar() {
var miniCalendarEl = document.getElementById('mini-calendar');
if (miniCalendarEl) {
var miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next',
center: 'title',
right: 'dayGridMonth,timeGridWeek'
},
events: '/matches/api/events/{{ tryout.id }}',
height: '300px',
eventClick: function(info) {
if (info.event.extendedProps.match_id) {
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
}
}
});
miniCalendar.render();
if (!miniCalendarEl) {
return;
}
});
if (miniCalendar) {
miniCalendar.destroy();
miniCalendar = null;
}
miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next',
center: 'title',
right: 'dayGridMonth,timeGridWeek'
},
events: '/matches/api/events/{{ tryout.id }}',
height: '300px',
eventClick: function(info) {
if (info.event.extendedProps.match_id) {
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
}
}
});
miniCalendar.render();
}
document.addEventListener('DOMContentLoaded', initMiniCalendar);
document.addEventListener('fragment:updated', initMiniCalendar);
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'show-create-team': showCreateTeam,
'hide-create-team': hideCreateTeam,
'show-modal': function (element) {
showModal(element.getAttribute('data-modal-id'));
},
'hide-modal': function (element) {
hideModal(element.getAttribute('data-modal-id'));
},
'toggle-tryout-presence': function (element) {
toggleTryoutPresence(
element.getAttribute('data-match-id'),
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
+23 -133
View File
@@ -23,15 +23,7 @@ from marshmallow import (
validates_schema,
)
from app.models import (
ESPORT_GAMES,
GAME_PLATFORMS,
OBJECTIVE_CHECK_FREQUENCIES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_STATUSES,
OBJECTIVE_TYPES,
USER_TYPES,
)
from app.models import DOCUMENT_KINDS, ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
# =============================================================================
# Custom Validators
@@ -613,6 +605,18 @@ class TryoutTeamMemberSchema(StripMixin):
)
class TryoutTeamMemberPositionSchema(StripMixin):
"""Update a team member's in-game position (role)."""
position = fields.String(
load_default='',
validate=validate.Length(
max=50,
error=_l('Position must be 50 characters or less.'),
),
)
class NoteContentSchema(StripMixin):
"""Bounded text stored as a team or personal coaching note."""
@@ -1059,135 +1063,21 @@ class EvaluationSchema(StripMixin):
)
# =============================================================================
# Player Evolution — Objective / Plan Schema
# =============================================================================
class DocumentCreateSchema(StripMixin):
"""Validate the admin form that creates a new document.
class ObjectiveSchema(StripMixin):
"""A player-evolution objective — individual or team KPI goal.
Validates the form payload from the create / edit modal on the
objectives management page.
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=200, error=_l('Le titre est obligatoire.')),
error_messages={'required': _l('Le titre est obligatoire.')},
validate=validate.Length(min=1, max=255, error=_l('Title is required.')),
)
description = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=5000),
)
type = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_TYPES],
error=_l("Type d'objectif invalide."),
),
load_default='individual',
)
status = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_STATUSES],
error=_l('Statut invalide.'),
),
load_default='active',
)
priority = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_PRIORITIES],
error=_l('Priorité invalide.'),
),
load_default='medium',
)
category = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=50),
)
is_template = fields.Boolean(load_default=False)
player_id = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1),
error_messages={'invalid': _l('Joueur invalide.')},
)
team_id = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1),
error_messages={'invalid': _l('Équipe invalide.')},
)
kpi_metric = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=120),
)
kpi_target = fields.Decimal(
allow_none=True,
load_default=None,
places=2,
error_messages={'invalid': _l('Cible KPI invalide.')},
)
kpi_unit = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=20),
)
kpi_precision = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=0, max=10),
)
start_date = fields.Date(
allow_none=True,
load_default=None,
format='%Y-%m-%d',
error_messages={'invalid': _l('Date de début invalide (AAAA-MM-JJ).')},
)
due_date = fields.Date(
kind = fields.String(
required=True,
format='%Y-%m-%d',
error_messages={
'required': _l('La date d\'échéance est obligatoire.'),
'invalid': _l('Date d\'échéance invalide (AAAA-MM-JJ).'),
},
validate=validate.OneOf(DOCUMENT_KINDS, error=_l('That document type is not recognised.')),
)
check_frequency = fields.String(
allow_none=True,
load_default=None,
validate=validate.OneOf(
[''] + [v for v, _ in OBJECTIVE_CHECK_FREQUENCIES],
error=_l('Fréquence de vérification invalide.'),
),
)
progress = fields.Integer(
load_default=0,
validate=validate.Range(
min=0, max=100, error=_l('La progression doit être entre 0 et 100.')
),
)
tags = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=255),
)
@validates_schema
def validate_assignment(self, data, **kwargs):
"""Type-aware assignment: coerce to a clean state.
- Templates → no player or team.
- Team objectives → clear player_id.
- Individual objectives → clear team_id.
"""
if data.get('is_template'):
data['player_id'] = None
data['team_id'] = None
return
if data.get('type') == 'team':
data['player_id'] = None
else:
data['team_id'] = None
-66
View File
@@ -1,66 +0,0 @@
"""Database seeding script for Team Tryouts application.
Wipes all data and creates an admin user. Run manually with:
python -m app.supporting_scripts.seed
"""
from app.extensions import db, hash_password
from app.models import Admin
def seed_database():
"""Delete all existing data and create a single admin account."""
# Delete all data in FK-safe order
print("Deleting existing data...")
tables = [
'one_on_one_requests',
'player_disponibilities',
'coach_availabilities',
'match_participants',
'team_match_participants',
'matches',
'team_matches',
'team_players',
'team_members',
'teams',
'evaluations',
'tryout_registrations',
'tryouts',
'org_team_coaches',
'org_team_managers',
'team_notes',
'personal_notes',
'user_gamertags',
'org_teams',
'users',
]
for table in tables:
db.session.execute(db.text(f'DELETE FROM {table}'))
db.session.commit()
print("[OK] All data deleted.")
# Create the admin user
admin = Admin(
username='admin',
password_hash=hash_password('password'),
role='admin',
full_name='Admin',
email='[email protected]',
)
db.session.add(admin)
db.session.commit()
print("[OK] Created admin user")
print("\n=== Admin Credentials ===")
print("Username: admin")
print("Password: password")
print("\n[SUCCESS] Database reset and seeded!")
if __name__ == '__main__':
from app.app import create_app
app = create_app()
with app.app_context():
seed_database()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

+36
View File
@@ -0,0 +1,36 @@
"""Migration: Create indexes on date columns used by the 18:00 daily reminder.
Run this script to add indexes on the date columns that the Discord bot's
daily reminder queries (Match.date, TeamMatch.date, Tryout.date,
OneOnOneRequest.date). The app uses create_all() for new tables, but existing
production databases need these added explicitly.
Usage: python migrations/add_reminder_date_indexes.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
indexes = [
('ix_matches_date', 'matches', 'date'),
('ix_team_matches_date', 'team_matches', 'date'),
('ix_tryouts_date', 'tryouts', 'date'),
('ix_one_on_one_requests_date', 'one_on_one_requests', 'date'),
]
for index_name, table, column in indexes:
db.session.execute(text(
f'CREATE INDEX IF NOT EXISTS {index_name} ON {table} ({column})'
))
print(f'Index {index_name} on {table}({column}) ensured.')
db.session.commit()
print('Migration complete.')
-4
View File
@@ -72,10 +72,6 @@ if __name__ == '__main__':
_db.session.commit()
print('[INFO] Seeded 4 default users (password for all: password).')
# Seed default objective templates (idempotent)
from app.routes.objectives import seed_default_templates
seed_default_templates()
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
if debug_mode:
app.logger.warning(
-62
View File
@@ -1,62 +0,0 @@
import pathlib
tmpl = '''{% extends "layouts/base.html" %}
{% import 'layouts/_pagination.html' as pager %}
{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Objective Management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / {{ _('Objectives') }}</span>{% endblock %}
{% block content %}
<div class="stats-grid mb-4">
<div class="stat-card"><div class="stat-icon bg-primary"><i class="fas fa-flag-checkered"></i></div><div class="stat-info"><h3>{{ count_total }}</h3><p>{{ _('Total') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-info"><i class="fas fa-chart-line"></i></div><div class="stat-info"><h3>{{ count_kpi }}</h3><p>{{ _('Custom KPIs') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-secondary"><i class="fas fa-copy"></i></div><div class="stat-info"><h3>{{ count_templates }}</h3><p>{{ _('Templates') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-success"><i class="fas fa-percentage"></i></div><div class="stat-info"><h3>{{ success_rate }}%</h3><p>{{ _('Success rate') }}</p></div></div>
</div>
<div class="card mb-4"><div class="card-body"><div class="objectives-toolbar">
<nav class="objectives-tabs" role="tablist">
<a href="{{ url_for('objectives.list_objectives', tab='all') }}" class="tab-link {% if tab == 'all' %}active{% endif %}">{{ _('All') }} ({{ count_total }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='kpi') }}" class="tab-link {% if tab == 'kpi' %}active{% endif %}">{{ _('Custom KPIs') }} ({{ count_kpi }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='modeles') }}" class="tab-link {% if tab in ('modeles','templates') %}active{% endif %}">{{ _('Templates') }} ({{ count_templates }})</a>
</nav>
<form method="GET" action="{{ url_for('objectives.list_objectives') }}" class="objectives-search-bar">
<input type="hidden" name="tab" value="{{ tab }}">
<div class="form-inline">
<div class="form-group" style="flex:1;min-width:200px;"><input type="text" name="search" class="form-input" placeholder="{{ _('Search an objective...') }}"></div>
<div class="form-group"><select name="status" class="form-select"><option value="">{{ _('Status') }}</option>{% for val, label in objective_statuses %}<option value="{{ val }}"{% if f_status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="priority" class="form-select"><option value="">{{ _('Priority') }}</option>{% for val, label in objective_priorities %}<option value="{{ val }}"{% if f_priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="type" class="form-select"><option value="">{{ _('Type') }}</option>{% for val, label in objective_types %}<option value="{{ val }}"{% if f_type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="team" class="form-select"><option value="">{{ _('Team') }}</option>{% for t in teams %}<option value="{{ t.id }}"{% if f_team == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-filter"></i> {{ _('Filters') }}</button>
</div></form>
</div></div></div>
<div class="card"><div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ _('Objectives') }}</h3>{% if can_edit %}<a href="{{ url_for('objectives.new_objective') }}" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('New Objective') }}</a>{% endif %}</div>
<div class="card-body"><div class="table-container"><table class="table objectives-table"><thead><tr>
<th>{{ _('Objective') }}</th><th>{{ _('Assigned to') }}</th><th>{{ _('Team') }}</th><th>{{ _('Priority') }}</th><th>{{ _('Status') }}</th><th>{{ _('Target KPI') }}</th><th>{{ _('Progress') }}</th><th>{{ _('Deadline') }}</th>{% if can_edit %}<th>{{ _('Actions') }}</th>{% endif %}
</tr></thead><tbody>
'''
pathlib.Path('app/templates/pages/objectives.html').write_text(tmpl, encoding='utf-8')
print('LIST_PAGE_PART1_OK')
tmpl2 = '''{% for obj in objectives.items %}<tr data-objective-id="{{ obj.id }}">
<td><strong>{{ obj.title }}</strong>{% if obj.tags %}<div class="objective-tags">{% for tag in obj.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}</div>{% endif %}{% if obj.kpi_metric %}<small class="text-muted d-block">{{ obj.kpi_metric }}</small>{% endif %}</td>
<td>{% if obj.player %}<span class="user-avatar avatar-sm">{{ obj.player.username[:2]|upper }}</span> {{ obj.player.username }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% if obj.team %}{{ obj.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}<span class="badge {{ pc.get(obj.priority,'badge-secondary') }}">{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}</span></td>
<td>{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}<span class="badge {{ sc.get(obj.status,'badge-secondary') }}">{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}</span></td>
<td>{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td><div class="progress-bar-container"><div class="progress-bar" style="width:{{ obj.progress }}%"></div></div><small>{{ obj.progress }}%</small></td>
<td>{% if obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %}</td>
{% if can_edit %}<td class="actions-cell"><a href="{{ url_for('objectives.edit_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-primary" title="{{ _('Edit') }}"><i class="fas fa-edit"></i></a> <button class="btn btn-sm btn-outline-danger" onclick="showDeleteModal({{ obj.id }}, '{{ obj.title|e }}')" title="{{ _('Delete') }}"><i class="fas fa-trash"></i></button></td>{% endif %}
</tr>{% else %}
<tr><td colspan="{{ 9 if can_edit else 8 }}" class="text-center"><div class="empty-state"><i class="fas fa-bullseye"></i><h3>{{ _('No objectives yet') }}</h3><p>{{ _('Create your first objective to start tracking player evolution.') }}</p></div></td></tr>{% endfor %}
</tbody></table></div>{{ pager.controls(pagination) }}</div></div>
<div id="deleteModal" class="modal hidden"><div class="modal-backdrop" onclick="hideDeleteModal();"></div><div class="modal-content"><div class="modal-header"><h3><i class="fas fa-trash-alt"></i> {{ _('Delete Objective') }}</h3><button class="modal-close" onclick="hideDeleteModal();">&times;</button></div><div class="modal-body"><p>{{ _('Are you sure you want to delete this objective?') }}</p><p><strong id="deleteObjTitle"></strong></p><form id="deleteForm" method="POST" action=""><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="form-actions"><button type="button" class="btn btn-secondary" onclick="hideDeleteModal();">{{ _('Cancel') }}</button><button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> {{ _('Delete') }}</button></div></form></div></div></div>
{% endblock %}
{% block scripts %}<script>
function showDeleteModal(i,t){document.getElementById('deleteObjTitle').textContent=t;document.getElementById('deleteForm').action='{{ url_for("objectives.delete_objective", objective_id=0) }}'.replace('/0/','/'+i+'/');document.getElementById('deleteModal').classList.remove('hidden');}
function hideDeleteModal(){document.getElementById('deleteModal').classList.add('hidden');}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){hideDeleteModal();}});
</script>{% endblock %}
'''
with open('app/templates/pages/objectives.html', 'a', encoding='utf-8') as f:
f.write(tmpl2)
print('LIST_PAGE_DONE')
-69
View File
@@ -1,69 +0,0 @@
import pathlib
t = """{% extends "layouts/base.html" %}
{% block title %}{{ objective.title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ objective.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {{ objective.title }}</span>{% endblock %}
{% block content %}
<div class="row">
<div class="col-8">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ objective.title }}</h3></div>
<div class="card-body">
<p>{{ objective.description or '' }}</p>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Type') }}</span><span class="preview-value">{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span class="preview-value">{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Category') }}</span><span class="preview-value">{{ objective.category or '-' }}</span></div>
</div>
<div class="mt-3">
<span id="statusBadge" class="badge">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span>
<span id="priorityBadge" class="badge">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if objective.kpi_target %}<div class="mt-2 text-center" id="predictionInfo"></div>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;"><input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value (optional)') }}"></div>
<div class="form-group" style="flex:2;"><input type="text" name="note" class="form-input" placeholder="{{ _('Note or comment') }}"></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}<table class="table mt-2"><thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead><tbody>
{% for e in entries %}<tr><td>{{ e.recorded_at.strftime('%%Y-%%m-%%d %%H:%%M') }}</td><td><strong>{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>{% endfor %}</tbody></table>{% else %}<p class="text-muted mt-2">{{ _('No measurements recorded yet.') }}</p>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">{% if comments %}<div class="comments-list">{% for c in comments %}<div class="comment-item"><strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%%Y-%%m-%%d %%H:%%M') }}</span><p>{{ c.content }}</p></div>{% endfor %}</div>{% else %}<p class="text-muted">{{ _('No comments yet.') }}</p>{% endif %}</div>
</div>
</div>
<div class="col-4">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-info-circle"></i> {{ _('Details') }}</h3></div>
<div class="card-body">
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Priority') }}</span><span class="preview-value">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Status') }}</span><span class="preview-value">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Start date') }}</span><span class="preview-value">{{ objective.start_date.strftime('%%Y-%%m-%%d') if objective.start_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span class="preview-value">{{ objective.due_date.strftime('%%Y-%%m-%%d') if objective.due_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Progress') }}</span><span class="preview-value">{{ objective.progress }}%</span></div>
</div>
{% if objective.kpi_target %}<div class="progress-section mt-2"><span class="preview-label">{{ _('KPI Target') }}</span><strong>{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}</strong></div>{% endif %}
{% if can_edit %}<a href="{{ url_for('objectives.edit_objective', objective_id=objective.id) }}" class="btn btn-sm btn-primary mt-3"><i class="fas fa-edit"></i> {{ _('Edit') }}</a>{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}<div class="card mb-4"><div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div><div class="card-body">{% if personal_notes %}<h5>{{ _('Personal Notes') }}</h5>{% for n in personal_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%%Y-%%m-%%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}{% if team_notes %}<h5 class="mt-2">{{ _('Team Notes') }}</h5>{% for n in team_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%%Y-%%m-%%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}</div></div>{% endif %}
</div>
</div>{% endblock %}
"""
pathlib.Path('app/templates/pages/objective_view.html').write_text(t, encoding='utf-8')
print('OK')
+1 -1
View File
@@ -59,4 +59,4 @@ class TestAppSettingsBool:
AppSettings.set_bool('bool_key', True)
assert AppSettings.get('bool_key') == 'true'
AppSettings.set_bool('bool_key', False)
assert AppSettings.get('bool_key') == 'false'
assert AppSettings.get('bool_key') == 'false'
+1 -1
View File
@@ -58,4 +58,4 @@ class TestAuditLogRecord:
entry = AuditLog.query.filter_by(action='before_delete').first()
assert entry is not None
assert entry.user_id is None # FK set to NULL on delete
assert entry.user_id is None # FK set to NULL on delete
+14 -9
View File
@@ -41,7 +41,9 @@ class TestBackupCreate:
as_role('admin')
response = client.post(
'/admin/backup/create', data={'notes': 'test backup'}, follow_redirects=False,
'/admin/backup/create',
data={'notes': 'test backup'},
follow_redirects=False,
)
assert _redirected(response)
@@ -61,7 +63,9 @@ class TestBackupCreate:
as_role('admin')
response = client.post(
'/admin/backup/create', data={}, follow_redirects=True,
'/admin/backup/create',
data={},
follow_redirects=True,
)
html = response.data.decode()
assert 'Backup failed' in html
@@ -135,7 +139,8 @@ class TestBackupDownload:
record_id = record.id
response = client.get(
f'/admin/backup/{record_id}/download', follow_redirects=True,
f'/admin/backup/{record_id}/download',
follow_redirects=True,
)
html = response.data.decode()
assert 'missing from disk' in html.lower()
@@ -162,7 +167,8 @@ class TestBackupDelete:
record_id = record.id
response = client.post(
f'/admin/backup/{record_id}/delete', follow_redirects=False,
f'/admin/backup/{record_id}/delete',
follow_redirects=False,
)
assert _redirected(response)
@@ -214,14 +220,13 @@ class TestBackupRestore:
record_id = record.id
response = client.post(
f'/admin/backup/{record_id}/restore', follow_redirects=True,
f'/admin/backup/{record_id}/restore',
follow_redirects=True,
)
html = response.data.decode()
assert 'missing from disk' in html.lower()
def test_restore_creates_safety_backup_first(
self, app, client, as_role, monkeypatch, tmp_path
):
def test_restore_creates_safety_backup_first(self, app, client, as_role, monkeypatch, tmp_path):
import app.routes.admin as admin_module
# The restore route parses DATABASE_URL outside its try block.
@@ -282,4 +287,4 @@ class TestBackupRestore:
with app.app_context():
safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first()
assert safety is not None
assert safety.backup_type == 'pre_restore'
assert safety.backup_type == 'pre_restore'
+1 -1
View File
@@ -53,4 +53,4 @@ class TestAdminCsrfProtection:
# Flask-WTF returns 400 on missing CSRF token
assert response.status_code in (400, 302), (
f'{route} returned {response.status_code} without CSRF token'
)
)
+1 -1
View File
@@ -147,4 +147,4 @@ class TestAdminTemplateIntegrity:
def test_static_js_loads(self, client):
response = client.get('/static/js/main.js')
assert response.status_code == 200
assert 'javascript' in response.content_type or 'text/' in response.content_type
assert 'javascript' in response.content_type or 'text/' in response.content_type
+1 -1
View File
@@ -47,4 +47,4 @@ class TestAdminRouteRegistration:
return
assert _redirected(response), (
f'{route} answered {response.status_code} to an anonymous caller'
)
)
+4 -2
View File
@@ -154,7 +154,9 @@ class TestSeasonGateEnforcement:
as_role(role)
response = client.post(
f'/team-matches/{match_id}/delete', data={}, follow_redirects=True,
f'/team-matches/{match_id}/delete',
data={},
follow_redirects=True,
)
html = response.data.decode()
assert 'season is not active' in html.lower() or 'begin a season' in html.lower()
@@ -167,4 +169,4 @@ class TestSeasonGateEnforcement:
data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'},
follow_redirects=False,
)
assert _redirected(response) # success, not blocked
assert _redirected(response) # success, not blocked
+1 -1
View File
@@ -84,4 +84,4 @@ class TestAdminImages:
def test_esports_logo_image_loads(self, client):
# The original logo may still be referenced
response = client.get('/static/images/UdeS_logo.png')
assert response.status_code == 200
assert response.status_code == 200
+5 -5
View File
@@ -76,9 +76,7 @@ class TestTryoutGateEnforcement:
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
def test_non_admin_cannot_edit_tryout_when_closed(
self, app, client, as_role, make_user, role
):
def test_non_admin_cannot_edit_tryout_when_closed(self, app, client, as_role, make_user, role):
# Create a tryout as admin first
as_role('admin')
client.post(
@@ -119,7 +117,9 @@ class TestTryoutGateEnforcement:
as_role(role)
response = client.post(
f'/tryouts/{tryout_id}/delete', data={}, follow_redirects=True,
f'/tryouts/{tryout_id}/delete',
data={},
follow_redirects=True,
)
html = response.data.decode()
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
@@ -153,4 +153,4 @@ class TestTryoutGateEnforcement:
data={'title': 'Admin Edited', 'game': 'Valorant', 'date': '2026-12-01'},
follow_redirects=False,
)
assert _redirected(response) # success, not blocked
assert _redirected(response) # success, not blocked
+13 -4
View File
@@ -9,7 +9,12 @@ import pytest
from app.extensions import db
from app.models import (
AuditLog, BackupRecord, OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
AuditLog,
BackupRecord,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
)
@@ -21,7 +26,9 @@ class TestWipeConfirmation:
def test_wipe_without_confirmation_fails(self, app, client, as_role):
as_role('admin')
response = client.post(
'/admin/teams/wipe', data={}, follow_redirects=True,
'/admin/teams/wipe',
data={},
follow_redirects=True,
)
html = response.data.decode()
assert 'WIPE' in html
@@ -29,7 +36,9 @@ class TestWipeConfirmation:
def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role):
as_role('admin')
response = client.post(
'/admin/teams/wipe', data={'confirm': 'yes'}, follow_redirects=True,
'/admin/teams/wipe',
data={'confirm': 'yes'},
follow_redirects=True,
)
html = response.data.decode()
assert 'WIPE' in html
@@ -116,4 +125,4 @@ class TestWipeDataRemoval:
with app.app_context():
entry = AuditLog.query.filter_by(action='teams_wiped').first()
assert entry is not None
assert entry is not None
+103 -3
View File
@@ -168,7 +168,7 @@ class TestTheWindow:
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
).get_json()
assert _match_event(payload)['date'] == '2030-05-02'
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
def test_a_match_outside_the_window_is_not(
self, app, client, as_role, make_user, match_factory
@@ -192,7 +192,7 @@ class TestTheWindow:
payload = client.get('/matches/api/events').get_json()
assert _match_event(payload)['date'] == '2030-05-02'
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
def test_unparseable_bounds_are_ignored_rather_than_rejected(
self, app, client, as_role, make_user, match_factory
@@ -206,7 +206,7 @@ class TestTheWindow:
response = client.get('/matches/api/events?start=whenever&end=')
assert response.status_code == 200
assert _match_event(response.get_json())['date'] == '2030-05-02'
assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
def test_the_viewers_own_participation_still_comes_back(
self, app, client, as_role, make_user, match_factory
@@ -258,3 +258,103 @@ class TestTheWindowParser:
from flask import request
assert calendar_window(request.args) == (None, None)
class TestTimedEvents:
"""Matches with time fields render on the clock, not as all-day."""
def test_timed_match_has_start_and_end(self, app, client, as_role, make_user, match_factory):
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id)
ev = _match_event(client.get('/matches/api/events').get_json())
assert ev['start'] == '2030-05-02T18:00:00'
assert ev['end'] == '2030-05-02T19:00:00'
assert ev['allDay'] is False
def test_allday_match_has_date_when_no_time(
self, app, client, as_role, make_user, match_factory
):
from datetime import date
from app.extensions import db
from app.models import Match, MatchParticipant, Tryout, TryoutRegistration
player_id = make_user('player')
admin_id = as_role('admin')
with app.app_context():
tryout = Tryout(
title='All-day tryout',
game='Valorant',
date=date(2030, 5, 1),
created_by=admin_id,
status='upcoming',
)
db.session.add(tryout)
db.session.flush()
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
match = Match(
tryout_id=tryout.id,
title='All-day scrim',
description=None,
date=date(2030, 5, 2),
start_time=None,
end_time=None,
match_type='player_scrim',
created_by=admin_id,
)
db.session.add(match)
db.session.flush()
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
db.session.commit()
payload = client.get('/matches/api/events').get_json()
ev = next(e for e in payload if e['title'] == 'All-day scrim')
assert ev['date'] == '2030-05-02'
assert ev['allDay'] is True
assert 'start' not in ev
class TestTeamMatchCalendarEvents:
"""Regular-season team matches appear on the main calendar."""
def test_team_matches_appear_for_admin(self, app, client, as_role, make_user):
from datetime import date, time
from app.extensions import db
from app.models import OrgTeam, TeamMatch
admin_id = as_role('admin')
with app.app_context():
team = OrgTeam(name='Test Org', created_by=admin_id)
db.session.add(team)
db.session.flush()
tm = TeamMatch(
org_team_id=team.id,
title='Semifinal',
opponent='Enemy Team',
date=date(2030, 6, 15),
start_time=time(20, 0),
end_time=time(21, 30),
location='Main arena',
status='scheduled',
created_by=admin_id,
)
db.session.add(tm)
db.session.commit()
payload = client.get('/matches/api/events').get_json()
team_events = [e for e in payload if e['type'] == 'team_match']
assert len(team_events) == 1
ev = team_events[0]
assert ev['title'] == 'Semifinal'
assert ev['start'] == '2030-06-15T20:00:00'
assert ev['allDay'] is False
assert ev['extendedProps']['org_team'] == 'Test Org'
assert ev['extendedProps']['opponent'] == 'Enemy Team'
+58
View File
@@ -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
+248
View File
@@ -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
+15
View File
@@ -69,6 +69,21 @@ class TestNotifying:
assert [call['reference_id'] for call in sent] == [11, 22]
assert {call['event_date'] for call in sent} == {'2030-04-01'}
assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'}
assert {call['change_kind'] for call in sent} == {'scheduled'}
def test_modified_kind_is_forwarded(self, sent):
"""A modified notification carries 'modified' to the bot."""
scheduling.notify_participants(
title='Scrim',
date=date(2030, 4, 1),
start_time=time(19, 0),
end_time=time(21, 0),
participants=[(1, 11)],
fallback_id=99,
change_kind='modified',
)
assert sent[0]['change_kind'] == 'modified'
def test_a_participant_without_a_row_falls_back_to_the_match(self, sent):
"""A Discord reaction resolves the reference back to a record; the