Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f468217745 |
@@ -0,0 +1,40 @@
|
|||||||
|
"""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')
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""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')
|
||||||
+4
-52
@@ -26,10 +26,8 @@ from sqlalchemy import text
|
|||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from app import i18n
|
from app import i18n
|
||||||
from app.api import json_endpoint
|
|
||||||
from app.extensions import babel, csrf, db, limiter, login_manager
|
from app.extensions import babel, csrf, db, limiter, login_manager
|
||||||
from app.pagination import page_url
|
from app.pagination import page_url
|
||||||
from app.responders import current_revision, is_ajax_submit
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -349,42 +347,6 @@ def create_app(config=None):
|
|||||||
response.headers['X-Request-Id'] = g.get('request_id', '-')
|
response.headers['X-Request-Id'] = g.get('request_id', '-')
|
||||||
return response
|
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
|
@app.url_defaults
|
||||||
def version_static_urls(endpoint, values):
|
def version_static_urls(endpoint, values):
|
||||||
"""Stamp every static URL with the file's modification time.
|
"""Stamp every static URL with the file's modification time.
|
||||||
@@ -441,7 +403,6 @@ def create_app(config=None):
|
|||||||
|
|
||||||
configure_logging(app)
|
configure_logging(app)
|
||||||
|
|
||||||
from app.routes.admin import admin_bp
|
|
||||||
from app.routes.auth import auth_bp
|
from app.routes.auth import auth_bp
|
||||||
from app.routes.evaluations import evaluations_bp
|
from app.routes.evaluations import evaluations_bp
|
||||||
from app.routes.main import main_bp
|
from app.routes.main import main_bp
|
||||||
@@ -450,6 +411,8 @@ def create_app(config=None):
|
|||||||
from app.routes.teams import teams_bp
|
from app.routes.teams import teams_bp
|
||||||
from app.routes.tryouts import tryouts_bp
|
from app.routes.tryouts import tryouts_bp
|
||||||
from app.routes.users import users_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(auth_bp)
|
||||||
app.register_blueprint(tryouts_bp)
|
app.register_blueprint(tryouts_bp)
|
||||||
@@ -460,6 +423,7 @@ def create_app(config=None):
|
|||||||
app.register_blueprint(matches_bp)
|
app.register_blueprint(matches_bp)
|
||||||
app.register_blueprint(team_matches_bp)
|
app.register_blueprint(team_matches_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(objectives_bp)
|
||||||
|
|
||||||
# Register custom Jinja filters
|
# Register custom Jinja filters
|
||||||
app.jinja_env.filters['nl2br'] = nl2br
|
app.jinja_env.filters['nl2br'] = nl2br
|
||||||
@@ -569,18 +533,6 @@ def create_app(config=None):
|
|||||||
|
|
||||||
return jsonify(health_data), 200
|
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
|
# Custom Error Handlers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -775,4 +727,4 @@ def create_app(config=None):
|
|||||||
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
|
# 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
|
# 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
|
# module defines the factory; run.py starts it for development, wsgi.py for
|
||||||
# production (ARCH-007).
|
# production (ARCH-007).
|
||||||
+3
-50
@@ -492,19 +492,6 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
await self._send_schedule_notification(**item['data'])
|
await self._send_schedule_notification(**item['data'])
|
||||||
elif kind == 'one_on_one_response':
|
elif kind == 'one_on_one_response':
|
||||||
await self._send_one_on_one_response_dm(**item['data'])
|
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:
|
else:
|
||||||
logger.error('Unknown queue item type %r; the notification was dropped', kind)
|
logger.error('Unknown queue item type %r; the notification was dropped', kind)
|
||||||
|
|
||||||
@@ -1005,12 +992,6 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
player.id,
|
player.id,
|
||||||
reference_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:
|
except SQLAlchemyError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -1059,11 +1040,6 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
db.session.delete(row)
|
db.session.delete(row)
|
||||||
else:
|
else:
|
||||||
row.status = 'no_show'
|
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:
|
except SQLAlchemyError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -1246,11 +1222,10 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
async def send_match_reminder(self, player, match):
|
async def send_match_reminder(self, player, match):
|
||||||
"""Send match reminder to player. True if it was delivered."""
|
"""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 = (
|
message = (
|
||||||
"🔔 **Match Reminder**\n\n"
|
"🔔 **Match Reminder**\n\n"
|
||||||
f"Your match **{match.title}** is scheduled for tomorrow:\n"
|
f"Your match **{match.title}** is scheduled for tomorrow:\n"
|
||||||
f"**Date:** {date_str}\n"
|
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
|
||||||
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
|
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"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
|
||||||
f"**Location:** {match.location or 'TBD'}\n\n"
|
f"**Location:** {match.location or 'TBD'}\n\n"
|
||||||
@@ -1266,11 +1241,10 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
async def send_tryout_reminder(self, player, tryout):
|
async def send_tryout_reminder(self, player, tryout):
|
||||||
"""Send tryout reminder to player. True if it was delivered."""
|
"""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 = (
|
message = (
|
||||||
"🔔 **Tryout Reminder**\n\n"
|
"🔔 **Tryout Reminder**\n\n"
|
||||||
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
|
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
|
||||||
f"**Date:** {date_str}\n"
|
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
|
||||||
f"**Location:** {tryout.location or 'TBD'}\n\n"
|
f"**Location:** {tryout.location or 'TBD'}\n\n"
|
||||||
"Please confirm your attendance in the app."
|
"Please confirm your attendance in the app."
|
||||||
)
|
)
|
||||||
@@ -1340,11 +1314,10 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
async def send_one_on_one_reminder(self, player, session):
|
async def send_one_on_one_reminder(self, player, session):
|
||||||
"""Send One on One reminder to player. True if it was delivered."""
|
"""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 = (
|
message = (
|
||||||
"🔔 **One on One Reminder**\n\n"
|
"🔔 **One on One Reminder**\n\n"
|
||||||
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
|
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
|
||||||
f"**Date:** {date_str}\n"
|
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
|
||||||
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\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"
|
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
|
||||||
"Please prepare for your session!"
|
"Please prepare for your session!"
|
||||||
@@ -1460,26 +1433,6 @@ def send_schedule_notification(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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(
|
def send_one_on_one_response(
|
||||||
player_discord_id: str,
|
player_discord_id: str,
|
||||||
player_full_name: str,
|
player_full_name: str,
|
||||||
|
|||||||
+15
-6
@@ -415,14 +415,23 @@ def start() -> str:
|
|||||||
# 3. Create the application database (idempotent).
|
# 3. Create the application database (idempotent).
|
||||||
_create_database()
|
_create_database()
|
||||||
|
|
||||||
# 4. Register cleanup.
|
# 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.
|
||||||
if not _started:
|
if not _started:
|
||||||
_started = True
|
_started = True
|
||||||
atexit.register(_stop)
|
if os.environ.get('WERKZEUG_RUN_MAIN', '').lower() != 'true':
|
||||||
with contextlib.suppress(ValueError, OSError):
|
atexit.register(_stop)
|
||||||
signal.signal(signal.SIGINT, _stop)
|
with contextlib.suppress(ValueError, OSError):
|
||||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
signal.signal(signal.SIGINT, _stop)
|
||||||
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||||
|
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
||||||
|
|
||||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||||
return DATABASE_URL
|
return DATABASE_URL
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ from app.models._constants import (
|
|||||||
PLATFORM_CODES,
|
PLATFORM_CODES,
|
||||||
TRN_URLS,
|
TRN_URLS,
|
||||||
EVALUATION_CRITERIA,
|
EVALUATION_CRITERIA,
|
||||||
|
OBJECTIVE_TYPES,
|
||||||
|
OBJECTIVE_STATUSES,
|
||||||
|
OBJECTIVE_PRIORITIES,
|
||||||
|
OBJECTIVE_CHECK_FREQUENCIES,
|
||||||
|
DEFAULT_OBJECTIVE_TEMPLATES,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -97,3 +102,18 @@ from app.models.one_on_one_request import OneOnOneRequest
|
|||||||
from app.models.admin_settings import AppSettings
|
from app.models.admin_settings import AppSettings
|
||||||
from app.models.backup_record import BackupRecord
|
from app.models.backup_record import BackupRecord
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Layer 12: player evolution objectives
|
||||||
|
# =========================================================================
|
||||||
|
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
|
||||||
|
|||||||
@@ -69,6 +69,79 @@ PLATFORM_CODES = {
|
|||||||
|
|
||||||
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
|
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 = {
|
TRN_URLS = {
|
||||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||||
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
|
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
|
||||||
|
|||||||
@@ -53,4 +53,4 @@ class AppSettings(db.Model):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def set_bool(key, value):
|
def set_bool(key, value):
|
||||||
"""Store a boolean setting as 'true' / 'false'."""
|
"""Store a boolean setting as 'true' / 'false'."""
|
||||||
AppSettings.set(key, 'true' if value else 'false')
|
AppSettings.set(key, 'true' if value else 'false')
|
||||||
@@ -11,7 +11,7 @@ class BaseMatch(db.Model):
|
|||||||
|
|
||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
date = db.Column(db.Date, nullable=False, index=True)
|
date = db.Column(db.Date, nullable=False)
|
||||||
start_time = db.Column(db.Time, nullable=True)
|
start_time = db.Column(db.Time, nullable=True)
|
||||||
end_time = db.Column(db.Time, nullable=True)
|
end_time = db.Column(db.Time, nullable=True)
|
||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""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],
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""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])
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""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])
|
||||||
@@ -12,7 +12,7 @@ class OneOnOneRequest(db.Model):
|
|||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
coach_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)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
date = db.Column(db.Date, nullable=False, index=True)
|
date = db.Column(db.Date, nullable=False)
|
||||||
start_time = db.Column(db.Time, nullable=False)
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
end_time = db.Column(db.Time, nullable=False)
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
points = db.Column(db.Text, nullable=True)
|
points = db.Column(db.Text, nullable=True)
|
||||||
|
|||||||
@@ -13,25 +13,21 @@ class Tryout(db.Model):
|
|||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
game = db.Column(db.String(50), nullable=False)
|
game = db.Column(db.String(50), nullable=False)
|
||||||
date = db.Column(db.Date, nullable=False, index=True)
|
date = db.Column(db.Date, nullable=False)
|
||||||
end_date = db.Column(db.Date, nullable=True)
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
location = db.Column(db.String(200), nullable=True)
|
location = db.Column(db.String(200), nullable=True)
|
||||||
status = db.Column(db.String(20), default='upcoming')
|
status = db.Column(db.String(20), default='upcoming')
|
||||||
max_players = db.Column(db.Integer, nullable=True)
|
max_players = db.Column(db.Integer, nullable=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
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)
|
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
manager_id = db.Column(
|
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
||||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
|
||||||
) # deprecated, kept for migration
|
|
||||||
coach_id = db.Column(
|
coach_id = db.Column(
|
||||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||||
) # deprecated, kept for migration
|
) # deprecated, kept for migration
|
||||||
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||||
manager = db.relationship(
|
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
|
||||||
'User', foreign_keys=[manager_id], backref='managed_tryouts'
|
|
||||||
) # deprecated
|
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
||||||
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
|
||||||
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
@@ -343,3 +343,73 @@ def coach_manages_tryout(coach, tryout):
|
|||||||
if tryout.target_org_team_id:
|
if tryout.target_org_team_id:
|
||||||
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
||||||
return False
|
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)
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"""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)
|
|
||||||
+37
-66
@@ -9,35 +9,19 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint, render_template, redirect, url_for, flash, request,
|
||||||
flash,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
send_file,
|
send_file,
|
||||||
url_for,
|
|
||||||
)
|
)
|
||||||
from flask_login import current_user, login_required
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin,
|
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
|
||||||
AppSettings,
|
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
|
||||||
AuditLog,
|
|
||||||
BackupRecord,
|
|
||||||
OrgTeam,
|
|
||||||
TeamMatch,
|
|
||||||
TeamMatchParticipant,
|
|
||||||
TeamPlayer,
|
|
||||||
Tryout,
|
|
||||||
User,
|
|
||||||
)
|
)
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.supporting_scripts.backup import (
|
from app.supporting_scripts.backup import (
|
||||||
BackupError,
|
BACKUP_DIR, BackupError, backup_database, backup_documents,
|
||||||
backup_database,
|
create_backup_dir, parse_database_url, verify_backup,
|
||||||
create_backup_dir,
|
|
||||||
parse_database_url,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
@@ -72,7 +56,6 @@ def _log(action, details=None):
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('')
|
@admin_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -113,7 +96,6 @@ def dashboard():
|
|||||||
# Backups
|
# Backups
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _create_backup_record(backup_type='manual', notes=None):
|
def _create_backup_record(backup_type='manual', notes=None):
|
||||||
"""Run a database backup and return a BackupRecord, or raise BackupError."""
|
"""Run a database backup and return a BackupRecord, or raise BackupError."""
|
||||||
create_backup_dir()
|
create_backup_dir()
|
||||||
@@ -148,11 +130,11 @@ def create_backup():
|
|||||||
except BackupError as e:
|
except BackupError as e:
|
||||||
flash(f'Backup failed: {e}', 'danger')
|
flash(f'Backup failed: {e}', 'danger')
|
||||||
_log('backup_failed', f'Error: {e}')
|
_log('backup_failed', f'Error: {e}')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
|
_log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
|
||||||
flash(f'Backup created successfully: {record.filename}', 'success')
|
flash(f'Backup created successfully: {record.filename}', 'success')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/backup/<int:backup_id>/download')
|
@admin_bp.route('/backup/<int:backup_id>/download')
|
||||||
@@ -165,7 +147,7 @@ def download_backup(backup_id):
|
|||||||
record = BackupRecord.query.get_or_404(backup_id)
|
record = BackupRecord.query.get_or_404(backup_id)
|
||||||
if not os.path.exists(record.file_path):
|
if not os.path.exists(record.file_path):
|
||||||
flash('Backup file is missing from disk.', 'danger')
|
flash('Backup file is missing from disk.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
_log('backup_downloaded', f'File: {record.filename}')
|
_log('backup_downloaded', f'File: {record.filename}')
|
||||||
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
|
return send_file(record.file_path, as_attachment=True, download_name=record.filename)
|
||||||
@@ -184,13 +166,13 @@ def delete_backup(backup_id):
|
|||||||
os.remove(record.file_path)
|
os.remove(record.file_path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
flash(f'Could not remove backup file: {e}', 'danger')
|
flash(f'Could not remove backup file: {e}', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
_log('backup_deleted', f'File: {record.filename}')
|
_log('backup_deleted', f'File: {record.filename}')
|
||||||
db.session.delete(record)
|
db.session.delete(record)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Backup {record.filename} deleted.', 'success')
|
flash(f'Backup {record.filename} deleted.', 'success')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
||||||
@@ -207,7 +189,7 @@ def restore_backup(backup_id):
|
|||||||
record = BackupRecord.query.get_or_404(backup_id)
|
record = BackupRecord.query.get_or_404(backup_id)
|
||||||
if not os.path.exists(record.file_path):
|
if not os.path.exists(record.file_path):
|
||||||
flash('Backup file is missing from disk.', 'danger')
|
flash('Backup file is missing from disk.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
# Create a safety backup of the current state before restoring.
|
# Create a safety backup of the current state before restoring.
|
||||||
try:
|
try:
|
||||||
@@ -217,31 +199,22 @@ def restore_backup(backup_id):
|
|||||||
)
|
)
|
||||||
except BackupError as e:
|
except BackupError as e:
|
||||||
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
|
flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
# Restore using pg_restore
|
# Restore using pg_restore
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from app.supporting_scripts.backup import (
|
from app.supporting_scripts.backup import (
|
||||||
PG_RESTORE,
|
PG_RESTORE, dump_environment, parse_database_url,
|
||||||
dump_environment,
|
|
||||||
parse_database_url,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
conn = parse_database_url(os.getenv('DATABASE_URL'))
|
||||||
cmd = [
|
cmd = [
|
||||||
PG_RESTORE,
|
PG_RESTORE,
|
||||||
'--host',
|
'--host', conn['host'],
|
||||||
conn['host'],
|
'--port', conn['port'],
|
||||||
'--port',
|
'--username', conn['user'],
|
||||||
conn['port'],
|
'--dbname', conn['dbname'],
|
||||||
'--username',
|
'--clean', '--if-exists', '--no-owner',
|
||||||
conn['user'],
|
|
||||||
'--dbname',
|
|
||||||
conn['dbname'],
|
|
||||||
'--clean',
|
|
||||||
'--if-exists',
|
|
||||||
'--no-owner',
|
|
||||||
record.file_path,
|
record.file_path,
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
@@ -257,21 +230,20 @@ def restore_backup(backup_id):
|
|||||||
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
|
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
|
||||||
flash(f'Restore failed: {e}', 'danger')
|
flash(f'Restore failed: {e}', 'danger')
|
||||||
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
|
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
_log('backup_restored', f'File: {record.filename}')
|
_log('backup_restored', f'File: {record.filename}')
|
||||||
flash(
|
flash(
|
||||||
f'Backup {record.filename} restored successfully. The database has been rolled back.',
|
f'Backup {record.filename} restored successfully. The database has been rolled back.',
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tryouts open/close toggle
|
# Tryouts open/close toggle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/toggle-tryouts', methods=['POST'])
|
@admin_bp.route('/toggle-tryouts', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_tryouts():
|
def toggle_tryouts():
|
||||||
@@ -286,14 +258,13 @@ def toggle_tryouts():
|
|||||||
state = 'opened' if new_value else 'closed'
|
state = 'opened' if new_value else 'closed'
|
||||||
_log('tryouts_toggled', f'Tryouts {state}')
|
_log('tryouts_toggled', f'Tryouts {state}')
|
||||||
flash(f'Tryouts are now {state}.', 'success')
|
flash(f'Tryouts are now {state}.', 'success')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Season lifecycle
|
# Season lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/season/start', methods=['POST'])
|
@admin_bp.route('/season/start', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def start_season():
|
def start_season():
|
||||||
@@ -303,19 +274,19 @@ def start_season():
|
|||||||
|
|
||||||
if AppSettings.get_bool('season_active', default=False):
|
if AppSettings.get_bool('season_active', default=False):
|
||||||
flash('A season is already active. End it before starting a new one.', 'danger')
|
flash('A season is already active. End it before starting a new one.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
name = request.form.get('season_name', '').strip()
|
name = request.form.get('season_name', '').strip()
|
||||||
start_date = request.form.get('season_start', '').strip()
|
start_date = request.form.get('season_start', '').strip()
|
||||||
if not start_date:
|
if not start_date:
|
||||||
flash('A season start date is required.', 'danger')
|
flash('A season start date is required.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
datetime.strptime(start_date, '%Y-%m-%d')
|
datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash('Invalid season start date format.', 'danger')
|
flash('Invalid season start date format.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
AppSettings.set('season_name', name or 'Untitled Season')
|
AppSettings.set('season_name', name or 'Untitled Season')
|
||||||
AppSettings.set('season_start', start_date)
|
AppSettings.set('season_start', start_date)
|
||||||
@@ -324,7 +295,7 @@ def start_season():
|
|||||||
|
|
||||||
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
|
_log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}')
|
||||||
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
|
flash(f'Season "{name or "Untitled Season"}" has begun.', 'success')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/season/end', methods=['POST'])
|
@admin_bp.route('/season/end', methods=['POST'])
|
||||||
@@ -336,18 +307,18 @@ def end_season():
|
|||||||
|
|
||||||
if not AppSettings.get_bool('season_active', default=False):
|
if not AppSettings.get_bool('season_active', default=False):
|
||||||
flash('No season is currently active.', 'danger')
|
flash('No season is currently active.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
end_date = request.form.get('season_end', '').strip()
|
end_date = request.form.get('season_end', '').strip()
|
||||||
if not end_date:
|
if not end_date:
|
||||||
flash('A season end date is required.', 'danger')
|
flash('A season end date is required.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
datetime.strptime(end_date, '%Y-%m-%d')
|
datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash('Invalid season end date format.', 'danger')
|
flash('Invalid season end date format.', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
name = AppSettings.get('season_name')
|
name = AppSettings.get('season_name')
|
||||||
AppSettings.set('season_end', end_date)
|
AppSettings.set('season_end', end_date)
|
||||||
@@ -355,9 +326,7 @@ def end_season():
|
|||||||
|
|
||||||
_log('season_ended', f'Season: {name}, End: {end_date}')
|
_log('season_ended', f'Season: {name}, End: {end_date}')
|
||||||
flash(f'Season "{name}" has ended.', 'success')
|
flash(f'Season "{name}" has ended.', 'success')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
|
@admin_bp.route('/toggle-document-uploads', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_document_uploads():
|
def toggle_document_uploads():
|
||||||
@@ -372,14 +341,16 @@ def toggle_document_uploads():
|
|||||||
state = 'enabled' if new_value else 'disabled'
|
state = 'enabled' if new_value else 'disabled'
|
||||||
_log('document_uploads_toggled', f'Document uploads {state}')
|
_log('document_uploads_toggled', f'Document uploads {state}')
|
||||||
flash(f'Document uploads are now {state}.', 'success')
|
flash(f'Document uploads are now {state}.', 'success')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Wipe teams for new season
|
# Wipe teams for new season
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/teams/wipe', methods=['POST'])
|
@admin_bp.route('/teams/wipe', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def wipe_teams():
|
def wipe_teams():
|
||||||
@@ -395,7 +366,7 @@ def wipe_teams():
|
|||||||
confirm = request.form.get('confirm', '').strip()
|
confirm = request.form.get('confirm', '').strip()
|
||||||
if confirm != 'WIPE':
|
if confirm != 'WIPE':
|
||||||
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
|
flash("Type 'WIPE' in the confirmation box to proceed.", 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
# Safety backup before destructive operation
|
# Safety backup before destructive operation
|
||||||
try:
|
try:
|
||||||
@@ -405,7 +376,7 @@ def wipe_teams():
|
|||||||
)
|
)
|
||||||
except BackupError as e:
|
except BackupError as e:
|
||||||
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
|
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
roster_count = TeamPlayer.query.count()
|
roster_count = TeamPlayer.query.count()
|
||||||
match_count = TeamMatch.query.count()
|
match_count = TeamMatch.query.count()
|
||||||
@@ -422,4 +393,4 @@ def wipe_teams():
|
|||||||
f'and {match_count} regular-season matches.',
|
f'and {match_count} regular-season matches.',
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('admin.dashboard'))
|
return redirect(url_for('admin.dashboard'))
|
||||||
+26
-32
@@ -7,13 +7,20 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from 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 func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
EVALUATION_CRITERIA,
|
|
||||||
GAME_POSITIONS,
|
GAME_POSITIONS,
|
||||||
Admin,
|
Admin,
|
||||||
Evaluation,
|
Evaluation,
|
||||||
@@ -23,7 +30,6 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import paginate
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.validators import EvaluationSchema
|
from app.validators import EvaluationSchema
|
||||||
|
|
||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||||
@@ -151,12 +157,12 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
)
|
)
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
if not isinstance(player, Player):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only evaluate players.'), 'danger')
|
flash(_('Can only evaluate players.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing_eval = Evaluation.query.filter_by(
|
existing_eval = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
@@ -210,7 +216,7 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
evaluation.position_recommendation = data['position_recommendation']
|
evaluation.position_recommendation = data['position_recommendation']
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_evaluation_form()
|
return render_evaluation_form()
|
||||||
|
|
||||||
@@ -285,31 +291,23 @@ def batch_evaluate(tryout_id):
|
|||||||
player = User.query.get(pid)
|
player = User.query.get(pid)
|
||||||
if not player or not isinstance(player, Player):
|
if not player or not isinstance(player, Player):
|
||||||
continue
|
continue
|
||||||
is_registered = (
|
is_registered = TryoutRegistration.query.filter_by(
|
||||||
TryoutRegistration.query.filter_by(
|
tryout_id=tryout_id, player_id=pid,
|
||||||
tryout_id=tryout_id,
|
).first() is not None
|
||||||
player_id=pid,
|
|
||||||
).first()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
continue
|
continue
|
||||||
existing = Evaluation.query.filter_by(
|
existing = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
|
||||||
player_id=pid,
|
|
||||||
evaluator_id=current_user.id,
|
|
||||||
).first()
|
).first()
|
||||||
existing_scores = {
|
existing_scores = {
|
||||||
field_name: getattr(existing, field_name) if existing else None
|
field_name: getattr(existing, field_name) if existing else None
|
||||||
for field_name, _ in EVALUATION_CRITERIA
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
}
|
}
|
||||||
players.append(
|
players.append({
|
||||||
{
|
'player': player,
|
||||||
'player': player,
|
'existing': existing,
|
||||||
'existing': existing,
|
'existing_scores': existing_scores,
|
||||||
'existing_scores': existing_scores,
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
flash('No valid players selected for evaluation.', 'danger')
|
flash('No valid players selected for evaluation.', 'danger')
|
||||||
@@ -331,8 +329,7 @@ def batch_evaluate(tryout_id):
|
|||||||
_apply_evaluation(existing, scores, comments, position)
|
_apply_evaluation(existing, scores, comments, position)
|
||||||
else:
|
else:
|
||||||
evaluation = Evaluation(
|
evaluation = Evaluation(
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id, player_id=pid,
|
||||||
player_id=pid,
|
|
||||||
evaluator_id=current_user.id,
|
evaluator_id=current_user.id,
|
||||||
)
|
)
|
||||||
_apply_evaluation(evaluation, scores, comments, position)
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
@@ -341,12 +338,9 @@ def batch_evaluate(tryout_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_template(
|
return render_template('pages/batch_evaluate.html',
|
||||||
'pages/batch_evaluate.html',
|
tryout=tryout, players=players,
|
||||||
tryout=tryout,
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
players=players,
|
game_positions=GAME_POSITIONS)
|
||||||
evaluation_criteria=EVALUATION_CRITERIA,
|
|
||||||
game_positions=GAME_POSITIONS,
|
|
||||||
)
|
|
||||||
|
|||||||
+74
-161
@@ -5,7 +5,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons.
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, render_template, request, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
@@ -26,14 +26,11 @@ from app.models import (
|
|||||||
PlayerDisponibility,
|
PlayerDisponibility,
|
||||||
Scout,
|
Scout,
|
||||||
Team,
|
Team,
|
||||||
TeamMatch,
|
|
||||||
TeamMember,
|
TeamMember,
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
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.services.scheduling import notify_participants, zip_participants
|
||||||
from app.validators import MatchEditSchema, MatchSchema
|
from app.validators import MatchEditSchema, MatchSchema
|
||||||
|
|
||||||
@@ -77,25 +74,6 @@ def default_end_time(date, start_time):
|
|||||||
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).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):
|
def create_participants(match, data):
|
||||||
"""Attach participants to a match, per its type.
|
"""Attach participants to a match, per its type.
|
||||||
|
|
||||||
@@ -187,17 +165,13 @@ def api_events():
|
|||||||
president — and then issue one MatchParticipant query per match to find
|
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
|
out whether the viewer was in it. The calendar's cost grew with the
|
||||||
whole history, on every navigation.
|
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 = []
|
events = []
|
||||||
tryouts = get_visible_tryouts_for_user()
|
tryouts = get_visible_tryouts_for_user()
|
||||||
window_start, window_end = calendar_window(request.args)
|
|
||||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||||
|
|
||||||
if tryouts_by_id:
|
if tryouts_by_id:
|
||||||
|
window_start, window_end = calendar_window(request.args)
|
||||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||||
if window_start:
|
if window_start:
|
||||||
query = query.filter(Match.date >= window_start)
|
query = query.filter(Match.date >= window_start)
|
||||||
@@ -251,71 +225,30 @@ def api_events():
|
|||||||
|
|
||||||
user_participant = mine_by_match.get(match.id)
|
user_participant = mine_by_match.get(match.id)
|
||||||
|
|
||||||
event = {
|
events.append(
|
||||||
'id': f'match_{match.id}',
|
{
|
||||||
'title': match.title,
|
'id': f'match_{match.id}',
|
||||||
'type': 'match',
|
'title': match.title,
|
||||||
'color': match_color,
|
'date': match.date.strftime('%Y-%m-%d'),
|
||||||
'extendedProps': {
|
'type': 'match',
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'color': match_color,
|
||||||
'status': match.status,
|
'extendedProps': {
|
||||||
'description': match.description or '',
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'match_type': match.match_type,
|
'status': match.status,
|
||||||
'tryout_id': tryout.id,
|
'description': match.description or '',
|
||||||
'match_id': match.id,
|
'match_type': match.match_type,
|
||||||
'start_time': start_time_str,
|
'tryout_id': tryout.id,
|
||||||
'end_time': end_time_str,
|
'match_id': match.id,
|
||||||
'participants': participants_str,
|
'start_time': start_time_str,
|
||||||
'user_participant_id': user_participant.id if user_participant else None,
|
'end_time': end_time_str,
|
||||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
'participants': participants_str,
|
||||||
if user_participant
|
'user_participant_id': user_participant.id if user_participant else None,
|
||||||
else False,
|
'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',
|
|
||||||
'color': '#3b82f6',
|
|
||||||
'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)
|
# Add approved One on One sessions for the current user (player or coach)
|
||||||
if isinstance(current_user, Player):
|
if isinstance(current_user, Player):
|
||||||
@@ -330,22 +263,23 @@ def api_events():
|
|||||||
one_on_ones = []
|
one_on_ones = []
|
||||||
|
|
||||||
for ooo in one_on_ones:
|
for ooo in one_on_ones:
|
||||||
event = {
|
events.append(
|
||||||
'id': f'one_on_one_{ooo.id}',
|
{
|
||||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
'id': f'one_on_one_{ooo.id}',
|
||||||
'type': 'one_on_one',
|
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||||
'color': '#8b5cf6',
|
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||||
'extendedProps': {
|
'type': 'one_on_one',
|
||||||
'location': 'Discord / Voice Chat',
|
'color': '#8b5cf6',
|
||||||
'status': 'approved',
|
'extendedProps': {
|
||||||
'description': ooo.points or 'One on One session',
|
'location': 'Discord / Voice Chat',
|
||||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
'status': 'approved',
|
||||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
'description': ooo.points or 'One on One session',
|
||||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
'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)
|
return jsonify(events)
|
||||||
|
|
||||||
@@ -417,24 +351,25 @@ def api_events_for_tryout(tryout_id):
|
|||||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
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
|
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||||
|
|
||||||
event = {
|
events.append(
|
||||||
'id': f'match_{match.id}',
|
{
|
||||||
'title': match.title,
|
'id': f'match_{match.id}',
|
||||||
'type': 'match',
|
'title': match.title,
|
||||||
'color': match_color,
|
'date': match.date.strftime('%Y-%m-%d'),
|
||||||
'extendedProps': {
|
'type': 'match',
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'color': match_color,
|
||||||
'status': match.status,
|
'extendedProps': {
|
||||||
'match_type': match.match_type,
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'tryout_id': tryout.id,
|
'status': match.status,
|
||||||
'match_id': match.id,
|
'match_type': match.match_type,
|
||||||
'participants': participants_str,
|
'tryout_id': tryout.id,
|
||||||
'start_time': start_time_str,
|
'match_id': match.id,
|
||||||
'end_time': end_time_str,
|
'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)
|
return jsonify(events)
|
||||||
|
|
||||||
@@ -446,11 +381,11 @@ def create_match(tryout_id):
|
|||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if tryout.is_ended:
|
if tryout.is_ended:
|
||||||
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||||
all_players = registered_players(tryout_id)
|
all_players = registered_players(tryout_id)
|
||||||
@@ -508,7 +443,7 @@ def create_match(tryout_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
flash(_('Match scheduled successfully!'), 'success')
|
flash(_('Match scheduled successfully!'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return rerender()
|
return rerender()
|
||||||
|
|
||||||
@@ -522,11 +457,11 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||||
return respond_after_change(url_for('matches.calendar'))
|
return redirect(url_for('matches.calendar'))
|
||||||
|
|
||||||
if tryout.is_ended:
|
if tryout.is_ended:
|
||||||
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||||
all_players = registered_players(tryout.id)
|
all_players = registered_players(tryout.id)
|
||||||
@@ -584,12 +519,6 @@ def edit_match(match_id):
|
|||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
|
|
||||||
# Capture old participant ids before any deletion, so we can purge
|
|
||||||
# their pending Discord notifications once the change is committed.
|
|
||||||
stale_participant_ids = [
|
|
||||||
p.id for p in MatchParticipant.query.filter_by(match_id=match.id).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
||||||
if teams_changed:
|
if teams_changed:
|
||||||
@@ -600,33 +529,17 @@ def edit_match(match_id):
|
|||||||
else:
|
else:
|
||||||
# Same teams: the roster stands, but everyone is told again,
|
# Same teams: the roster stands, but everyone is told again,
|
||||||
# because the date or the time may have moved.
|
# because the date or the time may have moved.
|
||||||
# Reuse the existing participant ids so reference_id is always
|
|
||||||
# a real participant row, never the match id (fallback).
|
|
||||||
for team_id in (match.team1_id, match.team2_id):
|
for team_id in (match.team1_id, match.team2_id):
|
||||||
if team_id:
|
if team_id:
|
||||||
for member in TeamMember.query.filter_by(team_id=team_id).all():
|
notified_player_ids.extend(
|
||||||
notified_player_ids.append(member.player_id)
|
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
||||||
existing = MatchParticipant.query.filter_by(
|
)
|
||||||
match_id=match.id, player_id=member.player_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
notified_participant_ids.append(existing.id)
|
|
||||||
stale_participant_ids = [
|
|
||||||
pid for pid in stale_participant_ids if pid != existing.id
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Purge Discord pending entries for participants that no longer exist.
|
|
||||||
# This runs after the commit so the bot only sees the durable state.
|
|
||||||
if stale_participant_ids:
|
|
||||||
from app.discord_bot import purge_pending_for_participants
|
|
||||||
|
|
||||||
purge_pending_for_participants(stale_participant_ids)
|
|
||||||
|
|
||||||
notify_participants(
|
notify_participants(
|
||||||
title=match.title,
|
title=match.title,
|
||||||
date=match.date,
|
date=match.date,
|
||||||
@@ -637,7 +550,7 @@ def edit_match(match_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
return rerender()
|
return rerender()
|
||||||
|
|
||||||
@@ -673,10 +586,10 @@ def delete_match(match_id):
|
|||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
return respond_after_change(url_for('matches.calendar'))
|
return redirect(url_for('matches.calendar'))
|
||||||
if tryout.is_ended:
|
if tryout.is_ended:
|
||||||
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
|
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
# Notes outlive the match they were taken during: a coach's observation
|
# Notes outlive the match they were taken during: a coach's observation
|
||||||
# keeps its value, and deleting it here would destroy unrelated content.
|
# keeps its value, and deleting it here would destroy unrelated content.
|
||||||
@@ -689,7 +602,7 @@ def delete_match(match_id):
|
|||||||
db.session.delete(match)
|
db.session.delete(match)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Match deleted successfully.'), 'success')
|
flash(_('Match deleted successfully.'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
|
|
||||||
def get_players_available_at_time(date_str, time_str):
|
def get_players_available_at_time(date_str, time_str):
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
"""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)
|
||||||
+12
-17
@@ -3,7 +3,7 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, render_template, request, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
@@ -13,7 +13,6 @@ from app.extensions import db
|
|||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin,
|
Admin,
|
||||||
AppSettings,
|
|
||||||
Coach,
|
Coach,
|
||||||
Manager,
|
Manager,
|
||||||
OrgTeam,
|
OrgTeam,
|
||||||
@@ -21,10 +20,10 @@ from app.models import (
|
|||||||
TeamMatch,
|
TeamMatch,
|
||||||
TeamMatchParticipant,
|
TeamMatchParticipant,
|
||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
|
AppSettings,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import paginate
|
||||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.routes.matches import default_end_time
|
from app.routes.matches import default_end_time
|
||||||
from app.services.scheduling import notify_participants, zip_participants
|
from app.services.scheduling import notify_participants, zip_participants
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
@@ -122,14 +121,12 @@ def create_match(team_id):
|
|||||||
"""Create a new regular-season team match."""
|
"""Create a new regular-season team match."""
|
||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if season_locked():
|
if season_locked():
|
||||||
flash(
|
flash('The regular season is not active. Begin a season before scheduling matches.', 'warning')
|
||||||
'The regular season is not active. Begin a season before scheduling matches.', 'warning'
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
)
|
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
||||||
prefill_date = request.args.get('date', '')
|
prefill_date = request.args.get('date', '')
|
||||||
@@ -222,7 +219,7 @@ def create_match(team_id):
|
|||||||
flash(
|
flash(
|
||||||
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
|
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||||
|
|
||||||
@@ -236,7 +233,7 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to edit this match.'), 'danger')
|
flash(_('You do not have permission to edit this match.'), 'danger')
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# The three date and time fields used to be checked one at a time,
|
# The three date and time fields used to be checked one at a time,
|
||||||
@@ -266,7 +263,7 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||||
@@ -280,19 +277,17 @@ def delete_match(match_id):
|
|||||||
team_match = db.get_or_404(TeamMatch, match_id)
|
team_match = db.get_or_404(TeamMatch, match_id)
|
||||||
team = team_match.org_team
|
team = team_match.org_team
|
||||||
if season_locked():
|
if season_locked():
|
||||||
flash(
|
flash('The regular season is not active. Begin a season before deleting matches.', 'warning')
|
||||||
'The regular season is not active. Begin a season before deleting matches.', 'warning'
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
)
|
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
db.session.delete(team_match)
|
db.session.delete(team_match)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Match deleted successfully.'), 'success')
|
flash(_('Match deleted successfully.'), 'success')
|
||||||
return respond_after_change(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/api/manageable-teams')
|
@team_matches_bp.route('/api/manageable-teams')
|
||||||
|
|||||||
+46
-47
@@ -27,7 +27,6 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.permissions import visible_org_teams
|
from app.permissions import visible_org_teams
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ def my_teams():
|
|||||||
"""View the player's own teams with upcoming matches."""
|
"""View the player's own teams with upcoming matches."""
|
||||||
if not isinstance(current_user, Player):
|
if not isinstance(current_user, Player):
|
||||||
flash(_('This page is for players.'), 'info')
|
flash(_('This page is for players.'), 'info')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
from app.models import TeamMatch, TeamMatchParticipant
|
from app.models import TeamMatch, TeamMatchParticipant
|
||||||
|
|
||||||
@@ -184,18 +183,18 @@ def create_team():
|
|||||||
"""Create a new organization team."""
|
"""Create a new organization team."""
|
||||||
if not current_user.can_manage_teams():
|
if not current_user.can_manage_teams():
|
||||||
flash(_('You do not have permission to create teams.'), 'danger')
|
flash(_('You do not have permission to create teams.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
name = data['name']
|
name = data['name']
|
||||||
if OrgTeam.query.filter_by(name=name).first():
|
if OrgTeam.query.filter_by(name=name).first():
|
||||||
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
coach = _staff_member(data['coach_id'], Coach)
|
coach = _staff_member(data['coach_id'], Coach)
|
||||||
manager = _staff_member(data['manager_id'], Manager)
|
manager = _staff_member(data['manager_id'], Manager)
|
||||||
@@ -216,7 +215,7 @@ def create_team():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
||||||
@@ -226,18 +225,18 @@ def edit_team(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to edit this team.'), 'danger')
|
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
name = data['name']
|
name = data['name']
|
||||||
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
|
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
|
||||||
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
team.name = name
|
team.name = name
|
||||||
|
|
||||||
@@ -270,7 +269,7 @@ def edit_team(team_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
||||||
@@ -298,7 +297,7 @@ def delete_team(team_id):
|
|||||||
|
|
||||||
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
|
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')
|
flash(_('You do not have permission to delete teams.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
name = team.name
|
name = team.name
|
||||||
|
|
||||||
@@ -329,7 +328,7 @@ def delete_team(team_id):
|
|||||||
db.session.delete(team)
|
db.session.delete(team)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
||||||
@@ -339,19 +338,19 @@ def add_coach(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
if not data['coach_id']:
|
if not data['coach_id']:
|
||||||
flash(_('Please select a coach.'), 'danger')
|
flash(_('Please select a coach.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
coach = db.session.get(User, data['coach_id'])
|
coach = db.session.get(User, data['coach_id'])
|
||||||
if not coach or not _assignable(coach, Coach):
|
if not coach or not _assignable(coach, Coach):
|
||||||
flash(_('Only coaches can be assigned as coach.'), 'danger')
|
flash(_('Only coaches can be assigned as coach.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if team.coaches.filter_by(id=coach.id).first():
|
if team.coaches.filter_by(id=coach.id).first():
|
||||||
flash(
|
flash(
|
||||||
@@ -362,7 +361,7 @@ def add_coach(team_id):
|
|||||||
),
|
),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
team.coaches.append(coach)
|
team.coaches.append(coach)
|
||||||
if not team.coach_id:
|
if not team.coach_id:
|
||||||
@@ -372,7 +371,7 @@ def add_coach(team_id):
|
|||||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
||||||
@@ -382,19 +381,19 @@ def add_manager(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
if not data['manager_id']:
|
if not data['manager_id']:
|
||||||
flash(_('Please select a manager.'), 'danger')
|
flash(_('Please select a manager.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
manager = db.session.get(User, data['manager_id'])
|
manager = db.session.get(User, data['manager_id'])
|
||||||
if not manager or not _assignable(manager, Manager):
|
if not manager or not _assignable(manager, Manager):
|
||||||
flash(_('Only managers can be assigned as manager.'), 'danger')
|
flash(_('Only managers can be assigned as manager.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if team.managers.filter_by(id=manager.id).first():
|
if team.managers.filter_by(id=manager.id).first():
|
||||||
flash(
|
flash(
|
||||||
@@ -405,7 +404,7 @@ def add_manager(team_id):
|
|||||||
),
|
),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
team.managers.append(manager)
|
team.managers.append(manager)
|
||||||
if not team.manager_id:
|
if not team.manager_id:
|
||||||
@@ -415,7 +414,7 @@ def add_manager(team_id):
|
|||||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
||||||
@@ -425,11 +424,11 @@ def remove_coach(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if data['coach_id']:
|
if data['coach_id']:
|
||||||
coach = db.session.get(User, data['coach_id'])
|
coach = db.session.get(User, data['coach_id'])
|
||||||
@@ -443,7 +442,7 @@ def remove_coach(team_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
|
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
||||||
@@ -453,11 +452,11 @@ def remove_manager(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
if data['manager_id']:
|
if data['manager_id']:
|
||||||
manager = db.session.get(User, data['manager_id'])
|
manager = db.session.get(User, data['manager_id'])
|
||||||
@@ -471,7 +470,7 @@ def remove_manager(team_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
|
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
||||||
@@ -481,20 +480,20 @@ def add_player(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamPlayerSchema())
|
data = _posted(TeamPlayerSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
if not data['player_id']:
|
if not data['player_id']:
|
||||||
flash(_('Please select a player.'), 'danger')
|
flash(_('Please select a player.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
status = data['status']
|
status = data['status']
|
||||||
player = db.session.get(User, data['player_id'])
|
player = db.session.get(User, data['player_id'])
|
||||||
if not player or not _assignable(player, Player):
|
if not player or not _assignable(player, Player):
|
||||||
flash(_('Can only assign players to teams.'), 'danger')
|
flash(_('Can only assign players to teams.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -502,13 +501,13 @@ def add_player(team_id):
|
|||||||
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||||
db.session.add(tp)
|
db.session.add(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||||
@@ -518,7 +517,7 @@ def remove_player(team_id, player_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
@@ -527,7 +526,7 @@ def remove_player(team_id, player_id):
|
|||||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
db.session.delete(tp)
|
db.session.delete(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -535,7 +534,7 @@ def remove_player(team_id, player_id):
|
|||||||
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||||
@@ -570,19 +569,19 @@ def add_team_note(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
|
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team notes added successfully!'), 'success')
|
flash(_('Team notes added successfully!'), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||||
@@ -592,12 +591,12 @@ def add_player_note(team_id, player_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
team = db.get_or_404(OrgTeam, team_id)
|
||||||
if not current_user.can_manage_this_org_team(team):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
if not isinstance(player, Player):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
if not tp:
|
if not tp:
|
||||||
@@ -605,16 +604,16 @@ def add_player_note(team_id, player_id):
|
|||||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
|
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||||
return respond_after_change(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|||||||
+46
-224
@@ -16,7 +16,6 @@ from app.models import (
|
|||||||
ESPORT_GAMES,
|
ESPORT_GAMES,
|
||||||
GAME_POSITIONS,
|
GAME_POSITIONS,
|
||||||
Admin,
|
Admin,
|
||||||
AppSettings,
|
|
||||||
Coach,
|
Coach,
|
||||||
Evaluation,
|
Evaluation,
|
||||||
Manager,
|
Manager,
|
||||||
@@ -31,15 +30,14 @@ from app.models import (
|
|||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
User,
|
||||||
|
AppSettings,
|
||||||
)
|
)
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import (
|
from app.validators import (
|
||||||
PlayerSelectionSchema,
|
PlayerSelectionSchema,
|
||||||
TryoutRegistrationStatusSchema,
|
TryoutRegistrationStatusSchema,
|
||||||
TryoutSchema,
|
TryoutSchema,
|
||||||
TryoutStatusSchema,
|
TryoutStatusSchema,
|
||||||
TryoutTeamMemberPositionSchema,
|
|
||||||
TryoutTeamMemberSchema,
|
TryoutTeamMemberSchema,
|
||||||
TryoutTeamSchema,
|
TryoutTeamSchema,
|
||||||
)
|
)
|
||||||
@@ -142,10 +140,7 @@ def list_tryouts():
|
|||||||
def create_tryout():
|
def create_tryout():
|
||||||
"""Create a new tryout event. Requires Admin or Manager."""
|
"""Create a new tryout event. Requires Admin or Manager."""
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
if not can_manage():
|
if not can_manage():
|
||||||
@@ -209,10 +204,7 @@ def edit_tryout(tryout_id):
|
|||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
@@ -281,7 +273,9 @@ def view_tryout(tryout_id):
|
|||||||
can_view = False
|
can_view = False
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Admin):
|
||||||
can_view = True
|
can_view = True
|
||||||
elif isinstance(current_user, (Manager, Coach)):
|
elif isinstance(current_user, Manager):
|
||||||
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
|
elif isinstance(current_user, Coach):
|
||||||
can_view = current_user.can_manage_this_tryout(tryout)
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
elif isinstance(current_user, Player):
|
elif isinstance(current_user, Player):
|
||||||
is_registered = (
|
is_registered = (
|
||||||
@@ -347,12 +341,7 @@ def view_tryout(tryout_id):
|
|||||||
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
||||||
for row in member_rows:
|
for row in member_rows:
|
||||||
members_by_team.setdefault(row.team_id, []).append(
|
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]
|
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
|
||||||
|
|
||||||
@@ -478,31 +467,31 @@ def register_for_tryout(tryout_id):
|
|||||||
"""Register a player for a tryout. Only Players can self-register."""
|
"""Register a player for a tryout. Only Players can self-register."""
|
||||||
if not isinstance(current_user, Player):
|
if not isinstance(current_user, Player):
|
||||||
flash(_('Only players can register for tryouts.'), 'danger')
|
flash(_('Only players can register for tryouts.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
tryout = locked_tryout_or_404(tryout_id)
|
tryout = locked_tryout_or_404(tryout_id)
|
||||||
|
|
||||||
if tryout.status not in ['upcoming', 'in_progress']:
|
if tryout.status not in ['upcoming', 'in_progress']:
|
||||||
flash(_('This tryout is not accepting registrations.'), 'danger')
|
flash(_('This tryout is not accepting registrations.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing = TryoutRegistration.query.filter_by(
|
existing = TryoutRegistration.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=current_user.id
|
tryout_id=tryout_id, player_id=current_user.id
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
flash(_('You are already registered for this tryout.'), 'info')
|
flash(_('You are already registered for this tryout.'), 'info')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if tryout.max_players:
|
if tryout.max_players:
|
||||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||||
if count >= tryout.max_players:
|
if count >= tryout.max_players:
|
||||||
flash(_('This tryout is full.'), 'danger')
|
flash(_('This tryout is full.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
|
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
|
||||||
db.session.add(registration)
|
db.session.add(registration)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Successfully registered for tryout!'), 'success')
|
flash(_('Successfully registered for tryout!'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
|
||||||
@@ -511,11 +500,8 @@ def update_status(tryout_id):
|
|||||||
"""Update the status of a tryout."""
|
"""Update the status of a tryout."""
|
||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
@@ -524,12 +510,12 @@ def update_status(tryout_id):
|
|||||||
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
tryout.status = data['status']
|
tryout.status = data['status']
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
|
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
|
||||||
@@ -538,11 +524,8 @@ def update_registration_status(tryout_id, player_id):
|
|||||||
"""Update a registration's attendance status."""
|
"""Update a registration's attendance status."""
|
||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
@@ -555,12 +538,12 @@ def update_registration_status(tryout_id, player_id):
|
|||||||
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
|
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
registration.status = data['status']
|
registration.status = data['status']
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Registration status updated.'), 'success')
|
flash(_('Registration status updated.'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
||||||
@@ -569,24 +552,21 @@ def register_player(tryout_id):
|
|||||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||||
tryout = locked_tryout_or_404(tryout_id)
|
tryout = locked_tryout_or_404(tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
try:
|
try:
|
||||||
data = PlayerSelectionSchema().load(form_payload())
|
data = PlayerSelectionSchema().load(form_payload())
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if not data['player_id']:
|
if not data['player_id']:
|
||||||
flash(_('Please select a player.'), 'danger')
|
flash(_('Please select a player.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
# Same two checks as the team roster (SEC-16): the right role, and an
|
# 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
|
# account that has not been deactivated. The select this comes from now
|
||||||
@@ -594,7 +574,7 @@ def register_player(tryout_id):
|
|||||||
player = db.session.get(User, data['player_id'])
|
player = db.session.get(User, data['player_id'])
|
||||||
if not player or not isinstance(player, Player) or not player.is_active_account:
|
if not player or not isinstance(player, Player) or not player.is_active_account:
|
||||||
flash(_('Can only register players.'), 'danger')
|
flash(_('Can only register players.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -602,19 +582,19 @@ def register_player(tryout_id):
|
|||||||
_('%(username)s is already registered for this tryout.', username=player.username),
|
_('%(username)s is already registered for this tryout.', username=player.username),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if tryout.max_players:
|
if tryout.max_players:
|
||||||
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
|
||||||
if count >= tryout.max_players:
|
if count >= tryout.max_players:
|
||||||
flash(_('This tryout is full.'), 'danger')
|
flash(_('This tryout is full.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
|
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
|
||||||
db.session.add(registration)
|
db.session.add(registration)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
|
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||||
@@ -623,11 +603,8 @@ def remove_player(tryout_id, player_id):
|
|||||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
@@ -657,7 +634,7 @@ def remove_player(tryout_id, player_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
|
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
|
||||||
@@ -666,27 +643,24 @@ def create_team(tryout_id):
|
|||||||
"""Create a tryout-specific team."""
|
"""Create a tryout-specific team."""
|
||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
|
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
|
||||||
db.session.add(team)
|
db.session.add(team)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
|
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
|
||||||
@@ -696,15 +670,12 @@ def add_to_team(tryout_id, team_id):
|
|||||||
team = db.get_or_404(Team, team_id)
|
team = db.get_or_404(Team, team_id)
|
||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
# The two ids arrive independently in the URL. Without this check, being
|
# 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
|
# allowed to manage tryout A was enough to modify a team belonging to
|
||||||
@@ -716,7 +687,7 @@ def add_to_team(tryout_id, team_id):
|
|||||||
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
|
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
player_id = data['player_id']
|
player_id = data['player_id']
|
||||||
|
|
||||||
# Only players registered for this tryout may be placed on its teams.
|
# Only players registered for this tryout may be placed on its teams.
|
||||||
@@ -726,7 +697,7 @@ def add_to_team(tryout_id, team_id):
|
|||||||
)
|
)
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -736,153 +707,7 @@ def add_to_team(tryout_id, team_id):
|
|||||||
db.session.add(member)
|
db.session.add(member)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Player added to team!'), 'success')
|
flash(_('Player added to team!'), 'success')
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(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'])
|
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
||||||
@@ -891,11 +716,8 @@ def delete_tryout(tryout_id):
|
|||||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if tryouts_locked():
|
if tryouts_locked():
|
||||||
flash(
|
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
'danger',
|
|
||||||
)
|
|
||||||
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||||
@@ -935,4 +757,4 @@ def delete_tryout(tryout_id):
|
|||||||
db.session.delete(tryout)
|
db.session.delete(tryout)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Tryout deleted successfully.'), 'success')
|
flash(_('Tryout deleted successfully.'), 'success')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
@@ -10,7 +10,6 @@ from marshmallow import ValidationError
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app.extensions import db
|
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 import Admin, Coach, Contract, Manager, Player, User
|
||||||
from app.models.admin_settings import AppSettings
|
from app.models.admin_settings import AppSettings
|
||||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||||
@@ -103,11 +102,11 @@ def upload_contract():
|
|||||||
"""Upload a contract for a player."""
|
"""Upload a contract for a player."""
|
||||||
if not isinstance(current_user, (Admin, Manager, Coach)):
|
if not isinstance(current_user, (Admin, Manager, Coach)):
|
||||||
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
if document_uploads_locked():
|
if document_uploads_locked():
|
||||||
flash(_('Document uploads are currently disabled.'), 'danger')
|
flash(_('Document uploads are currently disabled.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
players = manageable_players()
|
players = manageable_players()
|
||||||
|
|
||||||
@@ -126,13 +125,13 @@ def upload_contract():
|
|||||||
|
|
||||||
if not can_manage_player_contract(current_user, player_id):
|
if not can_manage_player_contract(current_user, player_id):
|
||||||
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
|
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
|
||||||
return respond_after_change(url_for('users.upload_contract'))
|
return redirect(url_for('users.upload_contract'))
|
||||||
|
|
||||||
file = request.files.get('contract_file')
|
file = request.files.get('contract_file')
|
||||||
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||||
if error:
|
if error:
|
||||||
flash(error, 'danger')
|
flash(error, 'danger')
|
||||||
return respond_after_change(url_for('users.upload_contract'))
|
return redirect(url_for('users.upload_contract'))
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
player_teams = player.get_org_teams()
|
player_teams = player.get_org_teams()
|
||||||
@@ -155,7 +154,7 @@ def upload_contract():
|
|||||||
flash(
|
flash(
|
||||||
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
|
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('users.upload_contract'))
|
return redirect(url_for('users.upload_contract'))
|
||||||
|
|
||||||
contract = Contract(
|
contract = Contract(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -172,7 +171,7 @@ def upload_contract():
|
|||||||
_('Contract uploaded successfully for %(username)s!', username=player.username),
|
_('Contract uploaded successfully for %(username)s!', username=player.username),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
return render_template('pages/upload_contract.html', players=players)
|
return render_template('pages/upload_contract.html', players=players)
|
||||||
|
|
||||||
@@ -184,17 +183,17 @@ def upload_signed_contract(contract_id):
|
|||||||
contract = db.get_or_404(Contract, contract_id)
|
contract = db.get_or_404(Contract, contract_id)
|
||||||
if not contract.can_upload_signed(current_user):
|
if not contract.can_upload_signed(current_user):
|
||||||
flash(_('Only the player can upload their signed contract.'), 'danger')
|
flash(_('Only the player can upload their signed contract.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
if document_uploads_locked():
|
if document_uploads_locked():
|
||||||
flash(_('Document uploads are currently disabled.'), 'danger')
|
flash(_('Document uploads are currently disabled.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
file = request.files.get('signed_file')
|
file = request.files.get('signed_file')
|
||||||
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
||||||
if error:
|
if error:
|
||||||
flash(error, 'danger')
|
flash(error, 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
signed_filename = f"signed_{contract.stored_filename}"
|
signed_filename = f"signed_{contract.stored_filename}"
|
||||||
# Legacy local records keep their signed copy beside the original. Drive
|
# Legacy local records keep their signed copy beside the original. Drive
|
||||||
@@ -209,7 +208,7 @@ def upload_signed_contract(contract_id):
|
|||||||
stored_signed_path = store_uploaded_document(file, signed_path)
|
stored_signed_path = store_uploaded_document(file, signed_path)
|
||||||
except GoogleDriveStorageError:
|
except GoogleDriveStorageError:
|
||||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
contract.signed_filename = signed_filename
|
contract.signed_filename = signed_filename
|
||||||
contract.signed_file_path = stored_signed_path
|
contract.signed_file_path = stored_signed_path
|
||||||
@@ -217,7 +216,7 @@ def upload_signed_contract(contract_id):
|
|||||||
contract.signed_at = utc_now_naive()
|
contract.signed_at = utc_now_naive()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Signed contract uploaded successfully!'), 'success')
|
flash(_('Signed contract uploaded successfully!'), 'success')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/contracts/<int:contract_id>/download')
|
@users_bp.route('/contracts/<int:contract_id>/download')
|
||||||
@@ -227,12 +226,12 @@ def download_contract(contract_id):
|
|||||||
contract = db.get_or_404(Contract, contract_id)
|
contract = db.get_or_404(Contract, contract_id)
|
||||||
if not contract.can_view(current_user):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
try:
|
try:
|
||||||
document = open_document(contract.file_path)
|
document = open_document(contract.file_path)
|
||||||
except GoogleDriveStorageError:
|
except GoogleDriveStorageError:
|
||||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
return send_file(document, as_attachment=True, download_name=contract.original_filename)
|
return send_file(document, as_attachment=True, download_name=contract.original_filename)
|
||||||
|
|
||||||
|
|
||||||
@@ -243,13 +242,13 @@ def download_signed_contract(contract_id):
|
|||||||
contract = db.get_or_404(Contract, contract_id)
|
contract = db.get_or_404(Contract, contract_id)
|
||||||
if not contract.can_view(current_user):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
if not contract.signed_file_path:
|
if not contract.signed_file_path:
|
||||||
flash(_('No signed contract available.'), 'danger')
|
flash(_('No signed contract available.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
try:
|
try:
|
||||||
document = open_document(contract.signed_file_path)
|
document = open_document(contract.signed_file_path)
|
||||||
except GoogleDriveStorageError:
|
except GoogleDriveStorageError:
|
||||||
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||||
return respond_after_change(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
||||||
|
|||||||
+19
-20
@@ -10,7 +10,6 @@ from flask_login import current_user, login_required
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Coach,
|
Coach,
|
||||||
@@ -177,14 +176,14 @@ def manage_team_notes():
|
|||||||
org_teams = coach_org_teams(current_user)
|
org_teams = coach_org_teams(current_user)
|
||||||
if not org_teams:
|
if not org_teams:
|
||||||
flash(_('You are not assigned to a team.'), 'danger')
|
flash(_('You are not assigned to a team.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
org_team = org_teams[0]
|
org_team = org_teams[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = TeamNote(
|
note = TeamNote(
|
||||||
org_team_id=org_team.id,
|
org_team_id=org_team.id,
|
||||||
@@ -195,7 +194,7 @@ def manage_team_notes():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team notes saved successfully!'), 'success')
|
flash(_('Team notes saved successfully!'), 'success')
|
||||||
|
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -215,17 +214,17 @@ def manage_personal_notes():
|
|||||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
player_id = data['player_id']
|
player_id = data['player_id']
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
if not isinstance(player, Player):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
if not coach_can_access_player(current_user, player_id):
|
||||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = PersonalNote(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -235,7 +234,7 @@ def manage_personal_notes():
|
|||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -255,44 +254,44 @@ def add_personal_note():
|
|||||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
player_id = data['player_id']
|
player_id = data['player_id']
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
player = db.get_or_404(User, player_id)
|
||||||
if not isinstance(player, Player):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
if not coach_can_access_player(current_user, player_id):
|
||||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if data['match_id']:
|
if data['match_id']:
|
||||||
match = db.get_or_404(Match, data['match_id'])
|
match = db.get_or_404(Match, data['match_id'])
|
||||||
if not current_user.can_manage_this_tryout(match.tryout):
|
if not current_user.can_manage_this_tryout(match.tryout):
|
||||||
flash(_('You cannot use that match as note context.'), 'danger')
|
flash(_('You cannot use that match as note context.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
|
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')
|
flash(_('That player did not participate in the selected match.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if data['tryout_id']:
|
if data['tryout_id']:
|
||||||
tryout = db.get_or_404(Tryout, data['tryout_id'])
|
tryout = db.get_or_404(Tryout, data['tryout_id'])
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You cannot use that tryout as note context.'), 'danger')
|
flash(_('You cannot use that tryout as note context.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
|
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')
|
flash(_('That player is not registered for the selected tryout.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if data['team_id']:
|
if data['team_id']:
|
||||||
team = db.get_or_404(Team, data['team_id'])
|
team = db.get_or_404(Team, data['team_id'])
|
||||||
if not current_user.can_manage_this_tryout(team.tryout):
|
if not current_user.can_manage_this_tryout(team.tryout):
|
||||||
flash(_('You cannot use that team as note context.'), 'danger')
|
flash(_('You cannot use that team as note context.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
|
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')
|
flash(_('That player is not on the selected team.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = PersonalNote(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -305,7 +304,7 @@ def add_personal_note():
|
|||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
flash(_('Note added for %(username)s.', username=player.username), 'success')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -324,7 +323,7 @@ def add_note_from_tryout(tryout_id):
|
|||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
|
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
preselected_player_id = request.args.get('player_id', type=int)
|
preselected_player_id = request.args.get('player_id', type=int)
|
||||||
|
|
||||||
# Get registrations as players for the select list
|
# Get registrations as players for the select list
|
||||||
@@ -388,7 +387,7 @@ def add_note_from_match(match_id):
|
|||||||
match_obj = db.get_or_404(Match, match_id)
|
match_obj = db.get_or_404(Match, match_id)
|
||||||
if not current_user.can_manage_this_tryout(match_obj.tryout):
|
if not current_user.can_manage_this_tryout(match_obj.tryout):
|
||||||
flash(_('You do not have permission to add notes for this match.'), 'danger')
|
flash(_('You do not have permission to add notes for this match.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
# Get participants as players for the select list
|
# Get participants as players for the select list
|
||||||
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
|
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from flask_login import current_user, login_required
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||||
from app.routes.users.blueprint import users_bp
|
from app.routes.users.blueprint import users_bp
|
||||||
@@ -65,13 +64,13 @@ def one_on_one():
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not coach:
|
if not coach:
|
||||||
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
|
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
|
||||||
return respond_after_change(url_for('users.one_on_one'))
|
return redirect(url_for('users.one_on_one'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = OneOnOneRequestSchema().load(form_payload())
|
data = OneOnOneRequestSchema().load(form_payload())
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('users.one_on_one'))
|
return redirect(url_for('users.one_on_one'))
|
||||||
|
|
||||||
date_obj = data['date']
|
date_obj = data['date']
|
||||||
start_time = data['start_time']
|
start_time = data['start_time']
|
||||||
@@ -91,7 +90,7 @@ def one_on_one():
|
|||||||
|
|
||||||
if not is_available:
|
if not is_available:
|
||||||
flash(_("The requested time is not within the coach's availability."), 'danger')
|
flash(_("The requested time is not within the coach's availability."), 'danger')
|
||||||
return respond_after_change(url_for('users.one_on_one'))
|
return redirect(url_for('users.one_on_one'))
|
||||||
|
|
||||||
request_obj = OneOnOneRequest(
|
request_obj = OneOnOneRequest(
|
||||||
player_id=current_user.id,
|
player_id=current_user.id,
|
||||||
@@ -119,7 +118,7 @@ def one_on_one():
|
|||||||
)
|
)
|
||||||
|
|
||||||
flash(_('Your One on One request has been submitted!'), 'success')
|
flash(_('Your One on One request has been submitted!'), 'success')
|
||||||
return respond_after_change(url_for('users.one_on_one'))
|
return redirect(url_for('users.one_on_one'))
|
||||||
|
|
||||||
# Build list of upcoming dates that have coach availability
|
# Build list of upcoming dates that have coach availability
|
||||||
from datetime import date as date_cls
|
from datetime import date as date_cls
|
||||||
@@ -170,11 +169,11 @@ def accept_one_on_one(request_id):
|
|||||||
|
|
||||||
if request_obj.coach_id != current_user.id:
|
if request_obj.coach_id != current_user.id:
|
||||||
flash(_('This request is not for you.'), 'danger')
|
flash(_('This request is not for you.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if request_obj.status != 'pending':
|
if request_obj.status != 'pending':
|
||||||
flash(_('This request has already been processed.'), 'info')
|
flash(_('This request has already been processed.'), 'info')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
player = request_obj.player
|
player = request_obj.player
|
||||||
request_obj.status = 'approved'
|
request_obj.status = 'approved'
|
||||||
@@ -205,7 +204,7 @@ def accept_one_on_one(request_id):
|
|||||||
),
|
),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
|
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
|
||||||
@@ -220,17 +219,17 @@ def reject_one_on_one(request_id):
|
|||||||
|
|
||||||
if request_obj.coach_id != current_user.id:
|
if request_obj.coach_id != current_user.id:
|
||||||
flash(_('This request is not for you.'), 'danger')
|
flash(_('This request is not for you.'), 'danger')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if request_obj.status != 'pending':
|
if request_obj.status != 'pending':
|
||||||
flash(_('This request has already been processed.'), 'info')
|
flash(_('This request has already been processed.'), 'info')
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
|
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
rejection_reason = data['rejection_reason']
|
rejection_reason = data['rejection_reason']
|
||||||
player = request_obj.player
|
player = request_obj.player
|
||||||
|
|
||||||
@@ -265,4 +264,4 @@ def reject_one_on_one(request_id):
|
|||||||
),
|
),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return respond_after_change(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from flask_login import current_user, login_required
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db, hash_password
|
from app.extensions import db, hash_password
|
||||||
from app.responders import respond_after_change
|
|
||||||
from app.logging_config import log_auth_event
|
from app.logging_config import log_auth_event
|
||||||
from app.models import (
|
from app.models import (
|
||||||
ESPORT_GAMES,
|
ESPORT_GAMES,
|
||||||
@@ -130,7 +129,7 @@ def edit_profile():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Profile updated successfully!'), 'success')
|
flash(_('Profile updated successfully!'), 'success')
|
||||||
return respond_after_change(url_for('users.profile'))
|
return redirect(url_for('users.profile'))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/edit_profile.html',
|
'pages/edit_profile.html',
|
||||||
|
|||||||
@@ -1788,6 +1788,296 @@ a:hover { color: var(--primary-dark); }
|
|||||||
box-shadow: var(--shadow-md);
|
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 {
|
.card-header .drag-handle {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
|||||||
+16
-193
@@ -420,9 +420,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
initDraggableBlocks();
|
initDraggableBlocks();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
|
* Auto-dismiss flash alerts after 5 seconds.
|
||||||
*/
|
*/
|
||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
Declarative behaviours — replacing inline event handlers
|
Declarative behaviours — replacing inline event handlers
|
||||||
@@ -580,7 +593,7 @@ document.addEventListener('input', function (event) {
|
|||||||
document.addEventListener('change', function (event) {
|
document.addEventListener('change', function (event) {
|
||||||
const control = event.target.closest('[data-submit-on-change]');
|
const control = event.target.closest('[data-submit-on-change]');
|
||||||
if (control && control.form) {
|
if (control && control.form) {
|
||||||
submitForm(control.form);
|
control.form.submit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -600,193 +613,3 @@ document.addEventListener('change', function (event) {
|
|||||||
window.location.href = select.getAttribute('data-navigate')
|
window.location.href = select.getAttribute('data-navigate')
|
||||||
.replace('{value}', encodeURIComponent(select.value));
|
.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 = '×';
|
|
||||||
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;
|
|
||||||
@@ -118,4 +118,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
@@ -68,6 +68,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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' %}
|
{% if current_user.role == 'player' %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
|
||||||
@@ -173,7 +179,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="content" id="page-content">
|
<div class="content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="
|
<form method="POST" action="
|
||||||
{% if context_type == 'tryout' %}
|
{% if context_type == 'tryout' %}
|
||||||
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}
|
||||||
{% elif context_type == 'match' %}
|
{% elif context_type == 'match' %}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
Only admins can create or modify tryouts. Coaches and managers are locked out.
|
Only admins can create or modify tryouts. Coaches and managers are locked out.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" data-ajax action="{{ url_for('admin.toggle_tryouts') }}">
|
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
|
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
|
||||||
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% 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.
|
Only admins can upload or sign contracts. Other roles are blocked.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_uploads') }}">
|
<form method="POST" action="{{ url_for('admin.toggle_document_uploads') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-{% if settings.document_uploads_enabled %}warning{% else %}success{% endif %}">
|
<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 %}
|
{% if settings.document_uploads_enabled %}Disable Uploads{% else %}Enable Uploads{% endif %}
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
|
|
||||||
{% if not settings.season_active %}
|
{% if not settings.season_active %}
|
||||||
<hr style="margin: 12px 0;">
|
<hr style="margin: 12px 0;">
|
||||||
<form method="POST" data-ajax action="{{ url_for('admin.start_season') }}">
|
<form method="POST" action="{{ url_for('admin.start_season') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Season Name</label>
|
<label>Season Name</label>
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<hr style="margin: 12px 0;">
|
<hr style="margin: 12px 0;">
|
||||||
<form method="POST" data-ajax action="{{ url_for('admin.end_season') }}">
|
<form method="POST" action="{{ url_for('admin.end_season') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>End Date</label>
|
<label>End Date</label>
|
||||||
|
|||||||
@@ -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 breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="POST" data-ajax action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
{% for entry in players %}
|
{% for entry in players %}
|
||||||
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
|
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
|
||||||
|
|||||||
@@ -346,14 +346,6 @@ function showEventModal(event) {
|
|||||||
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
|
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) {
|
if (props.description) {
|
||||||
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST" data-ajax action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
|
<form method="POST" 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() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<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">
|
<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">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
{% if is_practice %}
|
{% if is_practice %}
|
||||||
<input type="hidden" name="title" value="Practice">
|
<input type="hidden" name="title" value="Practice">
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ function togglePresence(btn) {
|
|||||||
btn.classList.add('btn-outline');
|
btn.classList.add('btn-outline');
|
||||||
btn.innerHTML = 'Confirm';
|
btn.innerHTML = 'Confirm';
|
||||||
}
|
}
|
||||||
window.TT.refreshPage(window.location.pathname);
|
location.reload();
|
||||||
})
|
})
|
||||||
.catch(function(error) {
|
.catch(function(error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<span class="badge badge-esport">{{ org_team.name }}</span>
|
<span class="badge badge-esport">{{ org_team.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
|
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
|
<form method="POST" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if req.status == 'pending' %}
|
{% if req.status == 'pending' %}
|
||||||
<form method="POST" data-ajax action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
|
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}">
|
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}">
|
||||||
<i class="fas fa-check"></i> {{ _('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>
|
<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">×</button>
|
<button type="button" class="modal-close" data-action="hide-reject-modal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="rejectForm" method="POST" data-ajax action="">
|
<form id="rejectForm" method="POST" action="">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% 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();">×</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 %}
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if coach %}
|
{% if coach %}
|
||||||
<form method="POST" data-ajax action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
|
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="{{ url_for('users.manage_personal_notes') }}" class="form">
|
<form method="POST" action="{{ url_for('users.manage_personal_notes') }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
|
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<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">
|
<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">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -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') }}">
|
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" data-ajax action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
|
<form method="POST" 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() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
|
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
@@ -170,8 +170,8 @@ function togglePresence(btn) {
|
|||||||
btn.classList.add('btn-outline');
|
btn.classList.add('btn-outline');
|
||||||
btn.innerHTML = 'Confirm';
|
btn.innerHTML = 'Confirm';
|
||||||
}
|
}
|
||||||
// Update in place to preserve state
|
// Reload to update presence bar
|
||||||
window.TT.refreshPage(window.location.pathname);
|
location.reload();
|
||||||
})
|
})
|
||||||
.catch(function(error) {
|
.catch(function(error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form">
|
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<h3>{{ _('Create New Team') }}</h3>
|
<h3>{{ _('Create New Team') }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="{{ url_for('teams.create_team') }}" class="form">
|
<form method="POST" action="{{ url_for('teams.create_team') }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group col-4">
|
<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 '' }}">
|
<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') }}
|
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||||
</button>
|
</button>
|
||||||
<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) }}">
|
<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) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
<i class="fas fa-trash"></i> {{ _('Delete') }}
|
<i class="fas fa-trash"></i> {{ _('Delete') }}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<span class="staff-tag">
|
<span class="staff-tag">
|
||||||
{{ c.username }}
|
{{ c.username }}
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<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) }}">
|
<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) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<input type="hidden" name="coach_id" value="{{ c.id }}"/>
|
<input type="hidden" name="coach_id" value="{{ c.id }}"/>
|
||||||
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">×</button>
|
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Coach') }}">×</button>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
<span class="staff-tag manager-tag">
|
<span class="staff-tag manager-tag">
|
||||||
{{ m.username }}
|
{{ m.username }}
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<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) }}">
|
<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) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<input type="hidden" name="manager_id" value="{{ m.id }}"/>
|
<input type="hidden" name="manager_id" value="{{ m.id }}"/>
|
||||||
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">×</button>
|
<button type="submit" class="btn-icon-sm" title="{{ _('Remove Manager') }}">×</button>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<td>{{ entry.player.phone or '-' }}</td>
|
<td>{{ entry.player.phone or '-' }}</td>
|
||||||
{% if can_manage_team %}
|
{% if can_manage_team %}
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
<form method="POST" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
|
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<div class="add-player-section">
|
<div class="add-player-section">
|
||||||
<h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5>
|
<h5 class="mb-2"><i class="fas fa-user-plus"></i> Add Player to {{ team.name }}</h5>
|
||||||
<form method="POST" data-ajax action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
|
<form method="POST" action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<select name="player_id" class="form-select" required>
|
<select name="player_id" class="form-select" required>
|
||||||
<option value="">{{ _('-- Select a player --') }}</option>
|
<option value="">{{ _('-- Select a player --') }}</option>
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<button class="modal-close" data-action="hide-edit-form">×</button>
|
<button class="modal-close" data-action="hide-edit-form">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="editTeamForm" method="POST" data-ajax action="" class="form">
|
<form id="editTeamForm" method="POST" action="" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<input type="hidden" name="sync_staff" value="1"/>
|
<input type="hidden" name="sync_staff" value="1"/>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="POST" data-ajax action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
|
<form method="POST" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<select name="status" data-submit-on-change class="form-select">
|
<select name="status" data-submit-on-change class="form-select">
|
||||||
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>{{ _('Upcoming') }}</option>
|
<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'] %}
|
{% if current_user.role == 'player' and not is_registered and tryout.status in ['upcoming', 'in_progress'] %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<form method="POST" data-ajax action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
|
<form method="POST" action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-primary btn-lg">
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
<i class="fas fa-user-plus"></i> {{ _('Register for this Tryout') }}
|
<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>
|
<h3><i class="fas fa-user-plus"></i> {{ _('Add Player to Tryout') }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" data-ajax action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
|
<form method="POST" action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<select name="player_id" class="form-select" required>
|
<select name="player_id" class="form-select" required>
|
||||||
<option value="">{{ _('-- Select a player --') }}</option>
|
<option value="">{{ _('-- Select a player --') }}</option>
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="POST" data-ajax action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
|
<form method="POST" 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() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<select name="status" data-submit-on-change class="form-select form-select-sm">
|
<select name="status" data-submit-on-change class="form-select form-select-sm">
|
||||||
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option>
|
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>{{ _('Registered') }}</option>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not tryout.is_ended %}
|
{% if not tryout.is_ended %}
|
||||||
<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) }}">
|
<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) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}">
|
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Remove player from tryout') }}">
|
||||||
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
|
<i class="fas fa-user-minus"></i> {{ _('Remove') }}
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if can_edit and not tryout.is_ended %}
|
{% if can_edit and not tryout.is_ended %}
|
||||||
<div id="createTeamForm" class="hidden mb-3">
|
<div id="createTeamForm" class="hidden mb-3">
|
||||||
<form method="POST" data-ajax action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
|
<form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<input type="text" name="team_name" placeholder="{{ _('Team name') }}" required class="form-input mr-2">
|
<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>
|
<button type="submit" class="btn btn-sm btn-success">{{ _('Create') }}</button>
|
||||||
@@ -249,14 +249,7 @@
|
|||||||
|
|
||||||
{% for team in team_data %}
|
{% for team in team_data %}
|
||||||
<div class="team-card mb-3">
|
<div class="team-card mb-3">
|
||||||
<div class="team-card-header">
|
<h4 class="team-name">{{ team.team.name }}</h4>
|
||||||
<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">
|
<ul class="team-members">
|
||||||
{% for member in team.members %}
|
{% for member in team.members %}
|
||||||
<li>
|
<li>
|
||||||
@@ -270,96 +263,32 @@
|
|||||||
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
{% if can_edit and not tryout.is_ended and registered_players %}
|
||||||
|
{% set positions = game_positions.get(tryout.game, []) %}
|
||||||
{% if can_edit and not tryout.is_ended %}
|
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
|
||||||
{% set positions = game_positions.get(tryout.game, []) %}
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<!-- Edit Team Modal -->
|
<select name="player_id" class="form-select" required>
|
||||||
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
|
<option value="">{{ _('Select player...') }}</option>
|
||||||
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
|
{% for p in registered_players %}
|
||||||
<div class="modal-content">
|
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
||||||
<div class="modal-header">
|
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||||
<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 }}">×</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 %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
<hr class="section-divider" style="border-color: var(--danger);">
|
</select>
|
||||||
<div class="text-center">
|
{% if positions %}
|
||||||
<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.') }}">
|
<select name="position" class="form-select mx-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<option value="">{{ _('-- Select Position --') }}</option>
|
||||||
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
|
{% for pos in positions %}
|
||||||
</form>
|
<option value="{{ pos }}">{{ pos }}</option>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
|
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -529,12 +458,6 @@
|
|||||||
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
||||||
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||||
</a>
|
</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 %}
|
{% 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') }}">
|
<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') }}
|
<i class="fas fa-sticky-note"></i> {{ _('Note') }}
|
||||||
@@ -645,56 +568,8 @@
|
|||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
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>
|
</style>
|
||||||
<script nonce="{{ csp_nonce }}">
|
<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 showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
|
||||||
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
||||||
|
|
||||||
@@ -717,58 +592,41 @@ function toggleTryoutPresence(matchId, participantId, btn) {
|
|||||||
btn.classList.add('presence-pending-btn');
|
btn.classList.add('presence-pending-btn');
|
||||||
btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
|
btn.innerHTML = btn.innerHTML.replace('✅', '⏳');
|
||||||
}
|
}
|
||||||
window.TT.refreshPage(window.location.pathname);
|
location.reload();
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
console.error('Error toggling tryout presence:', err);
|
console.error('Error toggling tryout presence:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini calendar for matches. Re-initialised on fragment:updated so the
|
// Mini calendar for matches
|
||||||
// calendar survives AJAX edits that swap the page content in place.
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var miniCalendar = null;
|
|
||||||
|
|
||||||
function initMiniCalendar() {
|
|
||||||
var miniCalendarEl = document.getElementById('mini-calendar');
|
var miniCalendarEl = document.getElementById('mini-calendar');
|
||||||
if (!miniCalendarEl) {
|
if (miniCalendarEl) {
|
||||||
return;
|
var miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
|
||||||
}
|
initialView: 'dayGridMonth',
|
||||||
if (miniCalendar) {
|
headerToolbar: {
|
||||||
miniCalendar.destroy();
|
left: 'prev,next',
|
||||||
miniCalendar = null;
|
center: 'title',
|
||||||
}
|
right: 'dayGridMonth,timeGridWeek'
|
||||||
miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
|
},
|
||||||
initialView: 'dayGridMonth',
|
events: '/matches/api/events/{{ tryout.id }}',
|
||||||
headerToolbar: {
|
height: '300px',
|
||||||
left: 'prev,next',
|
eventClick: function(info) {
|
||||||
center: 'title',
|
if (info.event.extendedProps.match_id) {
|
||||||
right: 'dayGridMonth,timeGridWeek'
|
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
|
||||||
},
|
}
|
||||||
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();
|
||||||
miniCalendar.render();
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initMiniCalendar);
|
|
||||||
document.addEventListener('fragment:updated', initMiniCalendar);
|
|
||||||
|
|
||||||
// Behaviours declared in the markup, dispatched by the delegated listener
|
// Behaviours declared in the markup, dispatched by the delegated listener
|
||||||
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
|
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
|
||||||
registerActions({
|
registerActions({
|
||||||
'show-create-team': showCreateTeam,
|
'show-create-team': showCreateTeam,
|
||||||
'hide-create-team': hideCreateTeam,
|
'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) {
|
'toggle-tryout-presence': function (element) {
|
||||||
toggleTryoutPresence(
|
toggleTryoutPresence(
|
||||||
element.getAttribute('data-match-id'),
|
element.getAttribute('data-match-id'),
|
||||||
|
|||||||
+143
-13
@@ -23,7 +23,15 @@ from marshmallow import (
|
|||||||
validates_schema,
|
validates_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
from app.models import (
|
||||||
|
ESPORT_GAMES,
|
||||||
|
GAME_PLATFORMS,
|
||||||
|
OBJECTIVE_CHECK_FREQUENCIES,
|
||||||
|
OBJECTIVE_PRIORITIES,
|
||||||
|
OBJECTIVE_STATUSES,
|
||||||
|
OBJECTIVE_TYPES,
|
||||||
|
USER_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Custom Validators
|
# Custom Validators
|
||||||
@@ -605,18 +613,6 @@ 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):
|
class NoteContentSchema(StripMixin):
|
||||||
"""Bounded text stored as a team or personal coaching note."""
|
"""Bounded text stored as a team or personal coaching note."""
|
||||||
|
|
||||||
@@ -1061,3 +1057,137 @@ class EvaluationSchema(StripMixin):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
load_default=None,
|
load_default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Player Evolution — Objective / Plan Schema
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.')},
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
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).'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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
@@ -0,0 +1,66 @@
|
|||||||
|
"""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.
|
After Width: | Height: | Size: 536 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
@@ -1,36 +0,0 @@
|
|||||||
"""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.')
|
|
||||||
@@ -72,6 +72,10 @@ if __name__ == '__main__':
|
|||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
print('[INFO] Seeded 4 default users (password for all: password).')
|
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'
|
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
app.logger.warning(
|
app.logger.warning(
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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();">×</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')
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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')
|
||||||
@@ -59,4 +59,4 @@ class TestAppSettingsBool:
|
|||||||
AppSettings.set_bool('bool_key', True)
|
AppSettings.set_bool('bool_key', True)
|
||||||
assert AppSettings.get('bool_key') == 'true'
|
assert AppSettings.get('bool_key') == 'true'
|
||||||
AppSettings.set_bool('bool_key', False)
|
AppSettings.set_bool('bool_key', False)
|
||||||
assert AppSettings.get('bool_key') == 'false'
|
assert AppSettings.get('bool_key') == 'false'
|
||||||
@@ -58,4 +58,4 @@ class TestAuditLogRecord:
|
|||||||
|
|
||||||
entry = AuditLog.query.filter_by(action='before_delete').first()
|
entry = AuditLog.query.filter_by(action='before_delete').first()
|
||||||
assert entry is not None
|
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
|
||||||
@@ -41,9 +41,7 @@ class TestBackupCreate:
|
|||||||
|
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/admin/backup/create',
|
'/admin/backup/create', data={'notes': 'test backup'}, follow_redirects=False,
|
||||||
data={'notes': 'test backup'},
|
|
||||||
follow_redirects=False,
|
|
||||||
)
|
)
|
||||||
assert _redirected(response)
|
assert _redirected(response)
|
||||||
|
|
||||||
@@ -63,9 +61,7 @@ class TestBackupCreate:
|
|||||||
|
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/admin/backup/create',
|
'/admin/backup/create', data={}, follow_redirects=True,
|
||||||
data={},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'Backup failed' in html
|
assert 'Backup failed' in html
|
||||||
@@ -139,8 +135,7 @@ class TestBackupDownload:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
f'/admin/backup/{record_id}/download',
|
f'/admin/backup/{record_id}/download', follow_redirects=True,
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'missing from disk' in html.lower()
|
assert 'missing from disk' in html.lower()
|
||||||
@@ -167,8 +162,7 @@ class TestBackupDelete:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/admin/backup/{record_id}/delete',
|
f'/admin/backup/{record_id}/delete', follow_redirects=False,
|
||||||
follow_redirects=False,
|
|
||||||
)
|
)
|
||||||
assert _redirected(response)
|
assert _redirected(response)
|
||||||
|
|
||||||
@@ -220,13 +214,14 @@ class TestBackupRestore:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/admin/backup/{record_id}/restore',
|
f'/admin/backup/{record_id}/restore', follow_redirects=True,
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'missing from disk' in html.lower()
|
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
|
import app.routes.admin as admin_module
|
||||||
|
|
||||||
# The restore route parses DATABASE_URL outside its try block.
|
# The restore route parses DATABASE_URL outside its try block.
|
||||||
@@ -287,4 +282,4 @@ class TestBackupRestore:
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first()
|
safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first()
|
||||||
assert safety is not None
|
assert safety is not None
|
||||||
assert safety.backup_type == 'pre_restore'
|
assert safety.backup_type == 'pre_restore'
|
||||||
@@ -53,4 +53,4 @@ class TestAdminCsrfProtection:
|
|||||||
# Flask-WTF returns 400 on missing CSRF token
|
# Flask-WTF returns 400 on missing CSRF token
|
||||||
assert response.status_code in (400, 302), (
|
assert response.status_code in (400, 302), (
|
||||||
f'{route} returned {response.status_code} without CSRF token'
|
f'{route} returned {response.status_code} without CSRF token'
|
||||||
)
|
)
|
||||||
@@ -147,4 +147,4 @@ class TestAdminTemplateIntegrity:
|
|||||||
def test_static_js_loads(self, client):
|
def test_static_js_loads(self, client):
|
||||||
response = client.get('/static/js/main.js')
|
response = client.get('/static/js/main.js')
|
||||||
assert response.status_code == 200
|
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
|
||||||
@@ -47,4 +47,4 @@ class TestAdminRouteRegistration:
|
|||||||
return
|
return
|
||||||
assert _redirected(response), (
|
assert _redirected(response), (
|
||||||
f'{route} answered {response.status_code} to an anonymous caller'
|
f'{route} answered {response.status_code} to an anonymous caller'
|
||||||
)
|
)
|
||||||
@@ -154,9 +154,7 @@ class TestSeasonGateEnforcement:
|
|||||||
|
|
||||||
as_role(role)
|
as_role(role)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/team-matches/{match_id}/delete',
|
f'/team-matches/{match_id}/delete', data={}, follow_redirects=True,
|
||||||
data={},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'season is not active' in html.lower() or 'begin a season' in html.lower()
|
assert 'season is not active' in html.lower() or 'begin a season' in html.lower()
|
||||||
@@ -169,4 +167,4 @@ class TestSeasonGateEnforcement:
|
|||||||
data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'},
|
data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'},
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert _redirected(response) # success, not blocked
|
assert _redirected(response) # success, not blocked
|
||||||
@@ -84,4 +84,4 @@ class TestAdminImages:
|
|||||||
def test_esports_logo_image_loads(self, client):
|
def test_esports_logo_image_loads(self, client):
|
||||||
# The original logo may still be referenced
|
# The original logo may still be referenced
|
||||||
response = client.get('/static/images/UdeS_logo.png')
|
response = client.get('/static/images/UdeS_logo.png')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -76,7 +76,9 @@ class TestTryoutGateEnforcement:
|
|||||||
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
|
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
|
||||||
|
|
||||||
@pytest.mark.parametrize('role', NON_ADMIN_ROLES)
|
@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
|
# Create a tryout as admin first
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
client.post(
|
client.post(
|
||||||
@@ -117,9 +119,7 @@ class TestTryoutGateEnforcement:
|
|||||||
|
|
||||||
as_role(role)
|
as_role(role)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/tryouts/{tryout_id}/delete',
|
f'/tryouts/{tryout_id}/delete', data={}, follow_redirects=True,
|
||||||
data={},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'closed' in html.lower() or 'open tryouts' in html.lower()
|
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'},
|
data={'title': 'Admin Edited', 'game': 'Valorant', 'date': '2026-12-01'},
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert _redirected(response) # success, not blocked
|
assert _redirected(response) # success, not blocked
|
||||||
@@ -9,12 +9,7 @@ import pytest
|
|||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AuditLog,
|
AuditLog, BackupRecord, OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||||
BackupRecord,
|
|
||||||
OrgTeam,
|
|
||||||
TeamMatch,
|
|
||||||
TeamMatchParticipant,
|
|
||||||
TeamPlayer,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -26,9 +21,7 @@ class TestWipeConfirmation:
|
|||||||
def test_wipe_without_confirmation_fails(self, app, client, as_role):
|
def test_wipe_without_confirmation_fails(self, app, client, as_role):
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/admin/teams/wipe',
|
'/admin/teams/wipe', data={}, follow_redirects=True,
|
||||||
data={},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'WIPE' in html
|
assert 'WIPE' in html
|
||||||
@@ -36,9 +29,7 @@ class TestWipeConfirmation:
|
|||||||
def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role):
|
def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role):
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/admin/teams/wipe',
|
'/admin/teams/wipe', data={'confirm': 'yes'}, follow_redirects=True,
|
||||||
data={'confirm': 'yes'},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'WIPE' in html
|
assert 'WIPE' in html
|
||||||
@@ -125,4 +116,4 @@ class TestWipeDataRemoval:
|
|||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
entry = AuditLog.query.filter_by(action='teams_wiped').first()
|
entry = AuditLog.query.filter_by(action='teams_wiped').first()
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
+3
-103
@@ -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'
|
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
|
||||||
).get_json()
|
).get_json()
|
||||||
|
|
||||||
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
assert _match_event(payload)['date'] == '2030-05-02'
|
||||||
|
|
||||||
def test_a_match_outside_the_window_is_not(
|
def test_a_match_outside_the_window_is_not(
|
||||||
self, app, client, as_role, make_user, match_factory
|
self, app, client, as_role, make_user, match_factory
|
||||||
@@ -192,7 +192,7 @@ class TestTheWindow:
|
|||||||
|
|
||||||
payload = client.get('/matches/api/events').get_json()
|
payload = client.get('/matches/api/events').get_json()
|
||||||
|
|
||||||
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
assert _match_event(payload)['date'] == '2030-05-02'
|
||||||
|
|
||||||
def test_unparseable_bounds_are_ignored_rather_than_rejected(
|
def test_unparseable_bounds_are_ignored_rather_than_rejected(
|
||||||
self, app, client, as_role, make_user, match_factory
|
self, app, client, as_role, make_user, match_factory
|
||||||
@@ -206,7 +206,7 @@ class TestTheWindow:
|
|||||||
response = client.get('/matches/api/events?start=whenever&end=')
|
response = client.get('/matches/api/events?start=whenever&end=')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
|
assert _match_event(response.get_json())['date'] == '2030-05-02'
|
||||||
|
|
||||||
def test_the_viewers_own_participation_still_comes_back(
|
def test_the_viewers_own_participation_still_comes_back(
|
||||||
self, app, client, as_role, make_user, match_factory
|
self, app, client, as_role, make_user, match_factory
|
||||||
@@ -258,103 +258,3 @@ class TestTheWindowParser:
|
|||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
assert calendar_window(request.args) == (None, None)
|
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'
|
|
||||||
|
|||||||
Reference in New Issue
Block a user