Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5249331661 | ||
|
|
abf2f06642 | ||
|
|
bd9ed4bdc7 | ||
|
|
a9b33b30f5 | ||
|
|
32c112ceea | ||
|
|
30069d782f | ||
|
|
d994d88c12 | ||
|
|
b9791d1547 | ||
|
|
4a2e2f053f | ||
|
|
e2e2b1b07e | ||
|
|
e4a271cfd8 | ||
|
|
742995d3cd | ||
|
|
cf551867c9 |
@@ -1,40 +0,0 @@
|
|||||||
"""Add Drive signed upload test."""
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
p = pathlib.Path('tests/test_contract_uploads.py')
|
|
||||||
t = p.read_bytes()
|
|
||||||
|
|
||||||
drive_test = (
|
|
||||||
b"\n def test_signed_upload_with_google_drive(\n"
|
|
||||||
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
|
|
||||||
b" ):\n"
|
|
||||||
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
|
|
||||||
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
|
|
||||||
b" monkeypatch.setattr(\n"
|
|
||||||
b" 'app.storage.upload_google_drive_file',\n"
|
|
||||||
b" lambda **_kwargs: 'drive-signed-456',\n"
|
|
||||||
b" )\n"
|
|
||||||
b" player_id = as_role('player')\n"
|
|
||||||
b" admin_id = make_user('admin')\n"
|
|
||||||
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
|
|
||||||
b" client.post(\n"
|
|
||||||
b" f'/users/contracts/{contract_id}/upload_signed',\n"
|
|
||||||
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
|
|
||||||
b" content_type='multipart/form-data',\n"
|
|
||||||
b" follow_redirects=True,\n"
|
|
||||||
b" )\n\n"
|
|
||||||
b" with app.app_context():\n"
|
|
||||||
b" contract = db.session.get(Contract, contract_id)\n"
|
|
||||||
b" assert contract.status == 'signed'\n"
|
|
||||||
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add before 'class TestTheHelper:'
|
|
||||||
marker = b'\nclass TestTheHelper:'
|
|
||||||
pos = t.find(marker)
|
|
||||||
if pos >= 0:
|
|
||||||
t = t[:pos] + drive_test + t[pos:]
|
|
||||||
p.write_bytes(t)
|
|
||||||
print('Added Drive signed upload test')
|
|
||||||
else:
|
|
||||||
print('Could not find marker')
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Add new tests to test_contract_uploads.py."""
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
p = pathlib.Path('tests/test_contract_uploads.py')
|
|
||||||
t = p.read_bytes()
|
|
||||||
|
|
||||||
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
|
|
||||||
marker = b'\n\nclass TestTheHelper:'
|
|
||||||
test = (
|
|
||||||
b"\n def test_a_header_only_pdf_is_refused(\n"
|
|
||||||
b" self, app, client, as_role, make_user, contract_for\n"
|
|
||||||
b" ):\n"
|
|
||||||
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
|
|
||||||
b" player_id = as_role('player')\n"
|
|
||||||
b" admin_id = make_user('admin')\n"
|
|
||||||
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
|
|
||||||
b" client.post(\n"
|
|
||||||
b" f'/users/contracts/{contract_id}/upload_signed',\n"
|
|
||||||
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
|
|
||||||
b" content_type='multipart/form-data',\n"
|
|
||||||
b" follow_redirects=True,\n"
|
|
||||||
b" )\n\n"
|
|
||||||
b" with app.app_context():\n"
|
|
||||||
b" contract = db.session.get(Contract, contract_id)\n"
|
|
||||||
b" assert contract.status != 'signed'\n"
|
|
||||||
b" assert contract.signed_file_path is None\n\n"
|
|
||||||
)
|
|
||||||
pos = t.find(marker)
|
|
||||||
if pos >= 0:
|
|
||||||
t = t[:pos] + test + t[pos:]
|
|
||||||
|
|
||||||
# 2. Header-only helper test at end of TestTheHelper
|
|
||||||
trailing = t.rstrip(b'\n')
|
|
||||||
idx = t.find(trailing) + len(trailing)
|
|
||||||
helper = (
|
|
||||||
b"\n def test_a_header_only_file_is_refused(self, app):\n"
|
|
||||||
b' """A stream starting with %PDF- but holding non-PDF data is\n'
|
|
||||||
b" rejected (the header check is not enough).\"\"\"\n"
|
|
||||||
b" from werkzeug.datastructures import FileStorage\n\n"
|
|
||||||
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
|
|
||||||
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
|
|
||||||
b" with app.test_request_context('/'):\n"
|
|
||||||
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
|
|
||||||
)
|
|
||||||
t = t[:idx] + helper + t[idx:]
|
|
||||||
|
|
||||||
p.write_bytes(t)
|
|
||||||
print('Added new tests')
|
|
||||||
+52
-2
@@ -26,8 +26,10 @@ 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()
|
||||||
|
|
||||||
@@ -347,6 +349,42 @@ 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.
|
||||||
@@ -403,6 +441,7 @@ 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
|
||||||
@@ -411,7 +450,6 @@ 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
|
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(tryouts_bp)
|
app.register_blueprint(tryouts_bp)
|
||||||
@@ -531,6 +569,18 @@ 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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -725,4 +775,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).
|
||||||
|
|||||||
+82
-14
@@ -492,6 +492,19 @@ 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)
|
||||||
|
|
||||||
@@ -673,6 +686,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
event_date: str,
|
event_date: str,
|
||||||
event_time: str,
|
event_time: str,
|
||||||
reference_id: int,
|
reference_id: int,
|
||||||
|
change_kind: str = 'scheduled',
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Send a schedule addition notification to a player.
|
"""Send a schedule addition notification to a player.
|
||||||
|
|
||||||
@@ -683,6 +697,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
event_date: Date string.
|
event_date: Date string.
|
||||||
event_time: Time string.
|
event_time: Time string.
|
||||||
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
||||||
|
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
|
||||||
"""
|
"""
|
||||||
# Look up the DB user to get their Discord user ID
|
# Look up the DB user to get their Discord user ID
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -699,16 +714,29 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
event_name = "Match" if event_type == 'match' else "Tryout"
|
event_name = "Match" if event_type == 'match' else "Tryout"
|
||||||
|
|
||||||
message = (
|
if change_kind == 'modified':
|
||||||
f"📅 **{event_name} Scheduled**\n\n"
|
message = (
|
||||||
f"You have been added to the following {event_type}:\n"
|
f"📅 **{event_name} Modified**\n\n"
|
||||||
f"**{event_title}**\n"
|
f"The {event_type} date and/or time has been modified for "
|
||||||
f"**Date:** {event_date}\n"
|
f"**{event_title}**.\n"
|
||||||
f"**Time:** {event_time}\n\n"
|
f"The new date and/or time is:\n"
|
||||||
"Please confirm your attendance:\n"
|
f"**Date:** {event_date}\n"
|
||||||
f"{CHECK_EMOJI} - Confirm attendance\n"
|
f"**Time:** {event_time}\n\n"
|
||||||
f"{CROSS_EMOJI} - Decline"
|
"Please confirm your attendance:\n"
|
||||||
)
|
f"{CHECK_EMOJI} - Confirm attendance\n"
|
||||||
|
f"{CROSS_EMOJI} - Decline"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"📅 **{event_name} Scheduled**\n\n"
|
||||||
|
f"You have been added to the following {event_type}:\n"
|
||||||
|
f"**{event_title}**\n"
|
||||||
|
f"**Date:** {event_date}\n"
|
||||||
|
f"**Time:** {event_time}\n\n"
|
||||||
|
"Please confirm your attendance:\n"
|
||||||
|
f"{CHECK_EMOJI} - Confirm attendance\n"
|
||||||
|
f"{CROSS_EMOJI} - Decline"
|
||||||
|
)
|
||||||
|
|
||||||
msg = await self._send_dm(
|
msg = await self._send_dm(
|
||||||
db_user.discord_user_id,
|
db_user.discord_user_id,
|
||||||
@@ -992,6 +1020,12 @@ 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(
|
||||||
@@ -1040,6 +1074,11 @@ 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(
|
||||||
@@ -1222,10 +1261,11 @@ 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:** {match.date.strftime('%A, %B %d, %Y')}\n"
|
f"**Date:** {date_str}\n"
|
||||||
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
|
f"**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"
|
||||||
@@ -1241,10 +1281,11 @@ 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:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
|
f"**Date:** {date_str}\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."
|
||||||
)
|
)
|
||||||
@@ -1314,10 +1355,11 @@ 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:** {session.date.strftime('%A, %B %d, %Y')}\n"
|
f"**Date:** {date_str}\n"
|
||||||
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
|
f"**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!"
|
||||||
@@ -1417,8 +1459,13 @@ def send_schedule_notification(
|
|||||||
event_date: str,
|
event_date: str,
|
||||||
event_time: str,
|
event_time: str,
|
||||||
reference_id: int,
|
reference_id: int,
|
||||||
|
change_kind: str = 'scheduled',
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Queue a schedule addition notification to be sent by the bot."""
|
"""Queue a schedule addition notification to be sent by the bot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
change_kind: 'scheduled' (new match / added player) or 'modified' (date/time changed).
|
||||||
|
"""
|
||||||
return _enqueue(
|
return _enqueue(
|
||||||
'schedule_addition',
|
'schedule_addition',
|
||||||
{
|
{
|
||||||
@@ -1428,11 +1475,32 @@ def send_schedule_notification(
|
|||||||
'event_date': event_date,
|
'event_date': event_date,
|
||||||
'event_time': event_time,
|
'event_time': event_time,
|
||||||
'reference_id': reference_id,
|
'reference_id': reference_id,
|
||||||
|
'change_kind': change_kind,
|
||||||
},
|
},
|
||||||
description=f'the {event_type} notification for user {user_id}',
|
description=f'the {event_type} notification for user {user_id}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_pending_for_participants(participant_ids):
|
||||||
|
"""Queue a purge of pending Discord entries for stale participant rows.
|
||||||
|
|
||||||
|
Called after a match edit deletes participants. The bot thread removes
|
||||||
|
matching ``schedule_addition`` pending entries so that a reaction on an
|
||||||
|
old message cannot produce a false confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant_ids: list[int] of MatchParticipant primary keys that no
|
||||||
|
longer exist.
|
||||||
|
"""
|
||||||
|
if not participant_ids:
|
||||||
|
return None
|
||||||
|
return _enqueue(
|
||||||
|
'purge_pending',
|
||||||
|
{'participant_ids': participant_ids},
|
||||||
|
description=f'purge of {len(participant_ids)} stale participant records',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_one_on_one_response(
|
def send_one_on_one_response(
|
||||||
player_discord_id: str,
|
player_discord_id: str,
|
||||||
player_full_name: str,
|
player_full_name: str,
|
||||||
|
|||||||
+68
-3
@@ -28,6 +28,59 @@ def _setting(name):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def root_folder_id():
|
||||||
|
"""The configured Drive folder that holds every application document.
|
||||||
|
|
||||||
|
This is the folder the owner set up (the ``signés`` folder); new
|
||||||
|
documents land directly in it and signed copies are filed in nested
|
||||||
|
folders below it.
|
||||||
|
"""
|
||||||
|
return _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_folder(name, parent_id):
|
||||||
|
"""Return the Drive folder named *name* under *parent_id*, creating it.
|
||||||
|
|
||||||
|
Drive has no ``mkdir -p``; creating a folder is a write, so a signed
|
||||||
|
copy filed under ``<team>/<role>/<username>`` walks the chain once per
|
||||||
|
upload. A folder already present is reused rather than duplicated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Folder name to find or create.
|
||||||
|
parent_id: Drive id of the parent folder.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Drive id of the folder.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = _drive_service()
|
||||||
|
query = (
|
||||||
|
f"name='{name}' and mimeType='application/vnd.google-apps.folder' "
|
||||||
|
f"and '{parent_id}' in parents and trashed=false"
|
||||||
|
)
|
||||||
|
response = service.files().list(q=query, fields='files(id, name)', pageSize=10).execute()
|
||||||
|
existing = response.get('files', [])
|
||||||
|
if existing:
|
||||||
|
return existing[0]['id']
|
||||||
|
folder = (
|
||||||
|
service.files()
|
||||||
|
.create(
|
||||||
|
body={
|
||||||
|
'name': name,
|
||||||
|
'mimeType': 'application/vnd.google-apps.folder',
|
||||||
|
'parents': [parent_id],
|
||||||
|
},
|
||||||
|
fields='id',
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return folder['id']
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # Google client exceptions share no stable base class.
|
||||||
|
raise GoogleDriveStorageError('Google Drive could not create the document folder.') from exc
|
||||||
|
|
||||||
|
|
||||||
def _drive_service():
|
def _drive_service():
|
||||||
"""Build an authorized Drive client from the owner's refresh token."""
|
"""Build an authorized Drive client from the owner's refresh token."""
|
||||||
try:
|
try:
|
||||||
@@ -86,8 +139,17 @@ def list_folder_files():
|
|||||||
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
|
||||||
|
|
||||||
|
|
||||||
def upload_file(*, stream, filename, mimetype):
|
def upload_file(*, stream, filename, mimetype, parents=None):
|
||||||
"""Upload a PDF to the configured owner folder and return its Drive ID."""
|
"""Upload a PDF to the configured owner folder and return its Drive ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream: The binary stream to upload.
|
||||||
|
filename: Name used for the uploaded file.
|
||||||
|
mimetype: MIME type of the file.
|
||||||
|
parents: Drive ids of the parent folders. Defaults to the root
|
||||||
|
folder when omitted, which is how the legacy contract flow
|
||||||
|
still works.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from googleapiclient.http import MediaIoBaseUpload
|
from googleapiclient.http import MediaIoBaseUpload
|
||||||
|
|
||||||
@@ -97,7 +159,10 @@ def upload_file(*, stream, filename, mimetype):
|
|||||||
_drive_service()
|
_drive_service()
|
||||||
.files()
|
.files()
|
||||||
.create(
|
.create(
|
||||||
body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
|
body={
|
||||||
|
'name': filename,
|
||||||
|
'parents': parents or [root_folder_id()],
|
||||||
|
},
|
||||||
media_body=media,
|
media_body=media,
|
||||||
fields='id',
|
fields='id',
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-4
@@ -324,8 +324,9 @@ def _create_database():
|
|||||||
def _stop(*_args):
|
def _stop(*_args):
|
||||||
"""Stop the PostgreSQL server with a fast shutdown.
|
"""Stop the PostgreSQL server with a fast shutdown.
|
||||||
|
|
||||||
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
Registered as an ``atexit`` handler so it fires on normal exit. Signal
|
||||||
normal exit, and terminal close.
|
handling goes through ``_handle_stop_signal``, which also stops the
|
||||||
|
server but then re-raises so the process actually exits.
|
||||||
"""
|
"""
|
||||||
global _started
|
global _started
|
||||||
global _stopped_system_service_name
|
global _stopped_system_service_name
|
||||||
@@ -349,6 +350,19 @@ def _stop(*_args):
|
|||||||
_start_system_service(srv)
|
_start_system_service(srv)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_stop_signal(signum, frame):
|
||||||
|
"""Stop PostgreSQL, then let the interpreter shut down normally.
|
||||||
|
|
||||||
|
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
|
||||||
|
system service, then returns without exiting, leaving the dev server
|
||||||
|
running against a stopped database and the terminal frozen. Re-raising
|
||||||
|
``KeyboardInterrupt`` hands control back to the framework, which exits;
|
||||||
|
the ``atexit`` registration then runs ``_stop`` again (idempotent).
|
||||||
|
"""
|
||||||
|
_stop()
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -420,9 +434,9 @@ def start() -> str:
|
|||||||
_started = True
|
_started = True
|
||||||
atexit.register(_stop)
|
atexit.register(_stop)
|
||||||
with contextlib.suppress(ValueError, OSError):
|
with contextlib.suppress(ValueError, OSError):
|
||||||
signal.signal(signal.SIGINT, _stop)
|
signal.signal(signal.SIGINT, _handle_stop_signal)
|
||||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||||
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
|
||||||
|
|
||||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||||
return DATABASE_URL
|
return DATABASE_URL
|
||||||
|
|||||||
+15
-1
@@ -96,4 +96,18 @@ from app.models.personal_note import PersonalNote
|
|||||||
from app.models.one_on_one_request import OneOnOneRequest
|
from app.models.one_on_one_request import OneOnOneRequest
|
||||||
from app.models.admin_settings import AppSettings
|
from app.models.admin_settings import AppSettings
|
||||||
from app.models.backup_record import BackupRecord
|
from app.models.backup_record import BackupRecord
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Layer 12: documents (canonical document + versions + assignments)
|
||||||
|
# =========================================================================
|
||||||
|
from app.models.document import (
|
||||||
|
ACKNOWLEDGMENT_KINDS,
|
||||||
|
ASSIGNMENT_STATUSES,
|
||||||
|
DOCUMENT_KIND_ROLES,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
SIGNATURE_KINDS,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
DocumentVersion,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Documents, versions, and per-user assignments.
|
||||||
|
|
||||||
|
The old contract flow stored one uploaded file per player even when the
|
||||||
|
file was identical for everyone, and Drive received no team/role structure
|
||||||
|
at all. This module replaces that with three two-level concepts:
|
||||||
|
|
||||||
|
- ``Document`` the canonical document (e.g. ``Contrat joueur``).
|
||||||
|
One row per kind; the file is stored once, never
|
||||||
|
once per person.
|
||||||
|
- ``DocumentVersion`` an immutable published revision. Uploading a new
|
||||||
|
PDF is publishing a new version, which is the twice
|
||||||
|
a year update cycle.
|
||||||
|
- ``DocumentAssignment`` a per-person obligation. ``UNIQUE(document_id,
|
||||||
|
user_id)`` is what guarantees a player signs a
|
||||||
|
document exactly once, whatever the number of teams
|
||||||
|
they sit on.
|
||||||
|
|
||||||
|
Signing obligations are role-scoped, not team-scoped: the team folder in
|
||||||
|
Drive is a *filing* concern for the signed copy; the obligation itself is
|
||||||
|
created once per eligible person.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
#: Every document kind this application can hold.
|
||||||
|
DOCUMENT_KINDS = (
|
||||||
|
'player',
|
||||||
|
'coach',
|
||||||
|
'manager',
|
||||||
|
'team',
|
||||||
|
'code_of_conduct',
|
||||||
|
'ethics_code',
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Kinds whose completion is an uploaded signed PDF.
|
||||||
|
SIGNATURE_KINDS = ('player', 'coach', 'manager', 'team')
|
||||||
|
|
||||||
|
#: Kinds whose completion is read-and-acknowledge only (no file).
|
||||||
|
ACKNOWLEDGMENT_KINDS = ('code_of_conduct', 'ethics_code')
|
||||||
|
|
||||||
|
#: Kind -> set of roles eligible to complete it. ``team`` and the two
|
||||||
|
#: codes span roles; the three role contracts are single-role by nature.
|
||||||
|
DOCUMENT_KIND_ROLES = {
|
||||||
|
'player': ('player',),
|
||||||
|
'coach': ('coach',),
|
||||||
|
'manager': ('manager',),
|
||||||
|
'team': ('player', 'coach', 'manager'),
|
||||||
|
'code_of_conduct': ('player', 'coach', 'manager'),
|
||||||
|
'ethics_code': ('player', 'coach', 'manager'),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Possible values for DocumentAssignment.status.
|
||||||
|
ASSIGNMENT_STATUSES = ('pending', 'signed', 'acknowledged')
|
||||||
|
|
||||||
|
|
||||||
|
class Document(db.Model):
|
||||||
|
"""A canonical document, stored once, completed by eligible people."""
|
||||||
|
|
||||||
|
__tablename__ = 'documents'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(255), nullable=False)
|
||||||
|
kind = db.Column(db.String(30), nullable=False, default='player')
|
||||||
|
#: Admins choose when the document is downloadable by team members. The
|
||||||
|
#: old ``document_uploads_enabled`` global toggle is replaced by this
|
||||||
|
#: per-document switch for the new flow.
|
||||||
|
is_published = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
current_version_id = db.Column(db.Integer, db.ForeignKey('document_versions.id'), nullable=True)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
versions = db.relationship(
|
||||||
|
'DocumentVersion',
|
||||||
|
backref='document',
|
||||||
|
lazy='dynamic',
|
||||||
|
order_by='DocumentVersion.version',
|
||||||
|
foreign_keys='DocumentVersion.document_id',
|
||||||
|
)
|
||||||
|
current_version = db.relationship(
|
||||||
|
'DocumentVersion',
|
||||||
|
foreign_keys=[current_version_id],
|
||||||
|
uselist=False,
|
||||||
|
)
|
||||||
|
assignments = db.relationship(
|
||||||
|
'DocumentAssignment',
|
||||||
|
back_populates='document',
|
||||||
|
lazy='dynamic',
|
||||||
|
order_by='DocumentAssignment.created_at',
|
||||||
|
foreign_keys='DocumentAssignment.document_id',
|
||||||
|
)
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_signature(self):
|
||||||
|
"""Whether completion means an uploaded signed PDF."""
|
||||||
|
return self.kind in SIGNATURE_KINDS
|
||||||
|
|
||||||
|
def completion_counts(self):
|
||||||
|
"""(completed, pending) over every assignment of this document.
|
||||||
|
|
||||||
|
``completed`` covers both signed and acknowledged assignments.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
db.session.query(DocumentAssignment.status, db.func.count())
|
||||||
|
.filter(DocumentAssignment.document_id == self.id)
|
||||||
|
.group_by(DocumentAssignment.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
totals = dict(rows)
|
||||||
|
completed = totals.get('signed', 0) + totals.get('acknowledged', 0)
|
||||||
|
return completed, totals.get('pending', 0)
|
||||||
|
|
||||||
|
def next_version_number(self):
|
||||||
|
"""The version number a freshly published revision should carry."""
|
||||||
|
latest = self.versions.order_by(DocumentVersion.version.desc()).first()
|
||||||
|
return (latest.version + 1) if latest else 1
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentVersion(db.Model):
|
||||||
|
"""One immutable published revision of a document."""
|
||||||
|
|
||||||
|
__tablename__ = 'document_versions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
|
||||||
|
version = db.Column(db.Integer, nullable=False)
|
||||||
|
original_filename = db.Column(db.String(255), nullable=False)
|
||||||
|
stored_filename = db.Column(db.String(255), nullable=False)
|
||||||
|
#: ``gdrive://<file-id>`` for Drive storage, or a path relative to the
|
||||||
|
#: document root for the local backend.
|
||||||
|
file_path = db.Column(db.String(500), nullable=False)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
published_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
published_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
publisher = db.relationship('User', foreign_keys=[published_by])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('document_id', 'version', name='unique_document_version'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAssignment(db.Model):
|
||||||
|
"""One person's obligation to sign or acknowledge a document."""
|
||||||
|
|
||||||
|
__tablename__ = 'document_assignments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
document_id = db.Column(db.Integer, db.ForeignKey('documents.id'), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
completed_version_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('document_versions.id'), nullable=True
|
||||||
|
)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default='pending')
|
||||||
|
signed_filename = db.Column(db.String(255), nullable=True)
|
||||||
|
signed_file_path = db.Column(db.String(500), nullable=True)
|
||||||
|
completed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
#: The team whose Drive folder files the signed copy. A member on
|
||||||
|
#: several teams still files under one canonical team only.
|
||||||
|
filed_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
document = db.relationship('Document', back_populates='assignments', foreign_keys=[document_id])
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id], backref='document_assignments')
|
||||||
|
completed_version = db.relationship('DocumentVersion', foreign_keys=[completed_version_id])
|
||||||
|
filed_team = db.relationship('OrgTeam', foreign_keys=[filed_team_id])
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint('document_id', 'user_id', name='unique_document_user'),)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completed(self):
|
||||||
|
return self.status in ('signed', 'acknowledged')
|
||||||
@@ -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)
|
date = db.Column(db.Date, nullable=False, index=True)
|
||||||
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)
|
||||||
|
|||||||
@@ -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)
|
date = db.Column(db.Date, nullable=False, index=True)
|
||||||
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,21 +13,25 @@ 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)
|
date = db.Column(db.Date, nullable=False, index=True)
|
||||||
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(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
manager_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||||
|
) # deprecated, kept for migration
|
||||||
coach_id = db.Column(
|
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('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
|
manager = db.relationship(
|
||||||
|
'User', foreign_keys=[manager_id], backref='managed_tryouts'
|
||||||
|
) # deprecated
|
||||||
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
|
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,84 @@
|
|||||||
|
"""Helpers for mutating routes that should not force a full page reload.
|
||||||
|
|
||||||
|
The site is server-rendered, and every mutating route used to end with
|
||||||
|
``return redirect(url_for(...))``, so each change reloaded the whole page:
|
||||||
|
scroll reset, open dialogs closed, and chaining several edits meant waiting
|
||||||
|
for a full render between each one.
|
||||||
|
|
||||||
|
These helpers let a route answer an AJAX submit (the ``data-ajax`` forms in
|
||||||
|
main.js) with a small JSON payload instead of a redirect, while the plain
|
||||||
|
HTML form path keeps behaving exactly as before. The client then fetches the
|
||||||
|
page again and swaps only the content region, so the page never reloads.
|
||||||
|
|
||||||
|
The counterpart that performs the swap lives in app/static/js/main.js.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import get_flashed_messages, jsonify, redirect, request, session
|
||||||
|
|
||||||
|
from app.models import AppSettings
|
||||||
|
|
||||||
|
#: Key holding a coarse, monotonically increasing stamp for the whole dataset.
|
||||||
|
#: It advances after every committed mutation so a stale client can be told
|
||||||
|
#: (with a 409) that someone else has changed what it is looking at.
|
||||||
|
REVISION_KEY = 'data_revision'
|
||||||
|
|
||||||
|
|
||||||
|
def is_ajax_submit():
|
||||||
|
"""Whether this POST came from the fetch-based form handler in main.js."""
|
||||||
|
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
|
|
||||||
|
|
||||||
|
def current_revision():
|
||||||
|
"""The current revision stamp, as an int (0 when nothing has been bumped)."""
|
||||||
|
try:
|
||||||
|
return int(AppSettings.get(REVISION_KEY, '0') or '0')
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def bump_revision():
|
||||||
|
"""Advance the revision stamp after a committed change.
|
||||||
|
|
||||||
|
Stored through AppSettings so it survives restarts and is shared by every
|
||||||
|
worker, unlike a module-level counter.
|
||||||
|
"""
|
||||||
|
AppSettings.set(REVISION_KEY, str(current_revision() + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _flashed_success():
|
||||||
|
"""Whether a success flash is pending, without consuming it.
|
||||||
|
|
||||||
|
The HTML path still redirects and must leave the flash for the next page
|
||||||
|
to render; peeking at the session is the only way to know without clearing
|
||||||
|
it. Only a successful commit advances the revision -- a rejected edit must
|
||||||
|
not make other users reload for nothing.
|
||||||
|
"""
|
||||||
|
flashes = session.get('_flashes', [])
|
||||||
|
return any(category == 'success' for category, _message in flashes)
|
||||||
|
|
||||||
|
|
||||||
|
def respond_after_change(redirect_url):
|
||||||
|
"""Answer a committed (or rejected) mutation.
|
||||||
|
|
||||||
|
For a normal HTML form submit this is exactly the ``redirect(...)`` the
|
||||||
|
routes used to return. For an AJAX submit it returns JSON telling the
|
||||||
|
client where to fetch the refreshed fragment and which flash messages to
|
||||||
|
show, so the page can update in place instead of reloading.
|
||||||
|
"""
|
||||||
|
if _flashed_success():
|
||||||
|
bump_revision()
|
||||||
|
|
||||||
|
if is_ajax_submit():
|
||||||
|
messages = [
|
||||||
|
{'category': category, 'text': message}
|
||||||
|
for category, message in get_flashed_messages(with_categories=True)
|
||||||
|
]
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
'ok': True,
|
||||||
|
'redirect': redirect_url,
|
||||||
|
'messages': messages,
|
||||||
|
'revision': current_revision(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return redirect(redirect_url)
|
||||||
+255
-38
@@ -6,23 +6,53 @@ to administrators.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, render_template, redirect, url_for, flash, request,
|
Blueprint,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
send_file,
|
send_file,
|
||||||
|
url_for,
|
||||||
)
|
)
|
||||||
from flask_login import login_required, current_user
|
from flask_babel import gettext as _
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
|
DOCUMENT_KIND_ROLES,
|
||||||
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
|
DOCUMENT_KINDS,
|
||||||
|
Admin,
|
||||||
|
AppSettings,
|
||||||
|
AuditLog,
|
||||||
|
BackupRecord,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
DocumentVersion,
|
||||||
|
OrgTeam,
|
||||||
|
TeamMatch,
|
||||||
|
TeamMatchParticipant,
|
||||||
|
TeamPlayer,
|
||||||
|
Tryout,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
|
from app.responders import respond_after_change
|
||||||
|
from app.routes.users._shared import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
|
||||||
|
from app.services.documents import backfill_assignments_for_document
|
||||||
|
from app.storage import GoogleDriveStorageError, open_document, store_master_document
|
||||||
from app.supporting_scripts.backup import (
|
from app.supporting_scripts.backup import (
|
||||||
BACKUP_DIR, BackupError, backup_database, backup_documents,
|
BackupError,
|
||||||
create_backup_dir, parse_database_url, verify_backup,
|
backup_database,
|
||||||
|
create_backup_dir,
|
||||||
|
parse_database_url,
|
||||||
)
|
)
|
||||||
|
from app.validators import DocumentCreateSchema
|
||||||
|
|
||||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@@ -56,6 +86,7 @@ def _log(action, details=None):
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('')
|
@admin_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -96,6 +127,7 @@ 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()
|
||||||
@@ -130,11 +162,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/backup/<int:backup_id>/download')
|
@admin_bp.route('/backup/<int:backup_id>/download')
|
||||||
@@ -147,7 +179,7 @@ def download_backup(backup_id):
|
|||||||
record = BackupRecord.query.get_or_404(backup_id)
|
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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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)
|
||||||
@@ -166,13 +198,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
@admin_bp.route('/backup/<int:backup_id>/restore', methods=['POST'])
|
||||||
@@ -189,32 +221,41 @@ def restore_backup(backup_id):
|
|||||||
record = BackupRecord.query.get_or_404(backup_id)
|
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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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:
|
||||||
safety = _create_backup_record(
|
_create_backup_record(
|
||||||
backup_type='pre_restore',
|
backup_type='pre_restore',
|
||||||
notes='Automatic safety backup before restoring ' + record.filename,
|
notes='Automatic safety backup before restoring ' + record.filename,
|
||||||
)
|
)
|
||||||
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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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, dump_environment, parse_database_url,
|
PG_RESTORE,
|
||||||
|
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', conn['host'],
|
'--host',
|
||||||
'--port', conn['port'],
|
conn['host'],
|
||||||
'--username', conn['user'],
|
'--port',
|
||||||
'--dbname', conn['dbname'],
|
conn['port'],
|
||||||
'--clean', '--if-exists', '--no-owner',
|
'--username',
|
||||||
|
conn['user'],
|
||||||
|
'--dbname',
|
||||||
|
conn['dbname'],
|
||||||
|
'--clean',
|
||||||
|
'--if-exists',
|
||||||
|
'--no-owner',
|
||||||
record.file_path,
|
record.file_path,
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
@@ -230,20 +271,21 @@ 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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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():
|
||||||
@@ -258,13 +300,14 @@ 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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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():
|
||||||
@@ -274,19 +317,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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)
|
||||||
@@ -295,7 +338,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/season/end', methods=['POST'])
|
@admin_bp.route('/season/end', methods=['POST'])
|
||||||
@@ -307,18 +350,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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)
|
||||||
@@ -326,7 +369,9 @@ 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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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():
|
||||||
@@ -341,16 +386,14 @@ 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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(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():
|
||||||
@@ -366,7 +409,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(url_for('admin.dashboard'))
|
||||||
|
|
||||||
# Safety backup before destructive operation
|
# Safety backup before destructive operation
|
||||||
try:
|
try:
|
||||||
@@ -376,7 +419,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 redirect(url_for('admin.dashboard'))
|
return respond_after_change(url_for('admin.dashboard'))
|
||||||
|
|
||||||
roster_count = TeamPlayer.query.count()
|
roster_count = TeamPlayer.query.count()
|
||||||
match_count = TeamMatch.query.count()
|
match_count = TeamMatch.query.count()
|
||||||
@@ -393,4 +436,178 @@ def wipe_teams():
|
|||||||
f'and {match_count} regular-season matches.',
|
f'and {match_count} regular-season matches.',
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return redirect(url_for('admin.dashboard'))
|
return respond_after_change(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Documents (canonical document store)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/documents')
|
||||||
|
@login_required
|
||||||
|
def documents():
|
||||||
|
"""List every document with its published flag and completion counts."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
documents = Document.query.order_by(Document.kind, Document.title).all()
|
||||||
|
return render_template(
|
||||||
|
'pages/admin_documents.html',
|
||||||
|
documents=documents,
|
||||||
|
kinds=DOCUMENT_KINDS,
|
||||||
|
kind_roles=DOCUMENT_KIND_ROLES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/documents/create', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_document():
|
||||||
|
"""Create a new canonical document (no file yet; publish one next)."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = DocumentCreateSchema().load(request.form)
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return respond_after_change(url_for('admin.documents'))
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
title=data['title'],
|
||||||
|
kind=data['kind'],
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(document)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_log('document_created', f'{data["kind"]}: {data["title"]}')
|
||||||
|
flash(_('Document "%(title)s" created.', title=data['title']), 'success')
|
||||||
|
return respond_after_change(url_for('admin.documents'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/documents/<int:doc_id>/publish-version', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def publish_document_version(doc_id):
|
||||||
|
"""Store a new PDF once and record it as the current version."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
document = db.get_or_404(Document, doc_id)
|
||||||
|
file = request.files.get('version_file')
|
||||||
|
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
|
||||||
|
if error:
|
||||||
|
flash(error, 'danger')
|
||||||
|
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
|
||||||
|
|
||||||
|
original_filename = secure_filename(file.filename)
|
||||||
|
stored_filename = f'{uuid.uuid4()}.pdf'
|
||||||
|
|
||||||
|
try:
|
||||||
|
stored_path = store_master_document(file, stored_filename)
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
flash(
|
||||||
|
_('Document storage is temporarily unavailable. Please try again later.'),
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
|
||||||
|
|
||||||
|
version = DocumentVersion(
|
||||||
|
document_id=document.id,
|
||||||
|
version=document.next_version_number(),
|
||||||
|
original_filename=original_filename,
|
||||||
|
stored_filename=stored_filename,
|
||||||
|
file_path=stored_path,
|
||||||
|
notes=request.form.get('notes', '').strip() or None,
|
||||||
|
published_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(version)
|
||||||
|
db.session.flush()
|
||||||
|
document.current_version_id = version.id
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
backfill_assignments_for_document(document)
|
||||||
|
|
||||||
|
_log('document_version_published', f'{document.title} v{version.version}')
|
||||||
|
flash(
|
||||||
|
_(
|
||||||
|
'Version %(number)s of "%(title)s" published.',
|
||||||
|
number=version.version,
|
||||||
|
title=document.title,
|
||||||
|
),
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('admin.document_detail', doc_id=document.id))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/documents/<int:doc_id>/toggle-published', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def toggle_document_published(doc_id):
|
||||||
|
"""Flip whether team members can see and download the document."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
document = db.get_or_404(Document, doc_id)
|
||||||
|
document.is_published = not document.is_published
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if document.is_published:
|
||||||
|
# Enabling a document must also backfill anyone who is nowadays
|
||||||
|
# eligible but was not when versions were published earlier.
|
||||||
|
created = backfill_assignments_for_document(document)
|
||||||
|
_log('document_published', f'{document.title} (assignments: {created})')
|
||||||
|
flash(
|
||||||
|
_('"%(title)s" is now downloadable by team members.', title=document.title),
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_log('document_unpublished', document.title)
|
||||||
|
flash(
|
||||||
|
_('"%(title)s" is no longer downloadable.', title=document.title),
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('admin.documents'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/documents/version/<int:version_id>/download')
|
||||||
|
@login_required
|
||||||
|
def download_document_version(version_id):
|
||||||
|
"""Admin download of a stored document revision."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
version = db.get_or_404(DocumentVersion, version_id)
|
||||||
|
try:
|
||||||
|
document = open_document(version.file_path)
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
flash(
|
||||||
|
_('Document storage is temporarily unavailable. Please try again later.'),
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('admin.documents'))
|
||||||
|
return send_file(document, as_attachment=True, download_name=version.original_filename)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/documents/<int:doc_id>')
|
||||||
|
@login_required
|
||||||
|
def document_detail(doc_id):
|
||||||
|
"""Revisions and per-person completion for one document."""
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
document = db.get_or_404(Document, doc_id)
|
||||||
|
versions = document.versions.order_by(DocumentVersion.version).all()
|
||||||
|
assignments = (
|
||||||
|
db.session.query(DocumentAssignment, User)
|
||||||
|
.join(User, User.id == DocumentAssignment.user_id)
|
||||||
|
.filter(DocumentAssignment.document_id == document.id)
|
||||||
|
.order_by(User.username)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
'pages/admin_document_detail.html',
|
||||||
|
document=document,
|
||||||
|
versions=versions,
|
||||||
|
assignments=assignments,
|
||||||
|
kinds=DOCUMENT_KINDS,
|
||||||
|
)
|
||||||
|
|||||||
+326
-112
@@ -3,33 +3,33 @@
|
|||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
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 sqlalchemy import false, func, select
|
||||||
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.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,
|
||||||
|
Coach,
|
||||||
Evaluation,
|
Evaluation,
|
||||||
|
Manager,
|
||||||
Player,
|
Player,
|
||||||
|
Scout,
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE
|
||||||
|
from app.responders import respond_after_change
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import EvaluationSchema
|
from app.validators import EvaluationSchema
|
||||||
|
|
||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||||
@@ -43,95 +43,286 @@ def _users_by_id(user_ids):
|
|||||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_score(score_value):
|
||||||
|
"""Turn a raw form value into a criterion score (1..10), or None.
|
||||||
|
|
||||||
|
A blank value means "not assessed". Anything else outside 1..10 is a
|
||||||
|
validation error, matching the single-player form's ``EvaluationSchema``
|
||||||
|
-- a coach must not be able to quietly turn 11 or 0 into "no score"
|
||||||
|
(QUA-003).
|
||||||
|
"""
|
||||||
|
if score_value is None or str(score_value).strip() == '':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
score = int(score_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValidationError('A score must be a whole number from 1 to 10.') from exc
|
||||||
|
if not 1 <= score <= 10:
|
||||||
|
raise ValidationError('Scores run from 1 to 10.')
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_evaluation(evaluation, scores, comments, position):
|
||||||
|
"""Write validated scores, comments and position onto an Evaluation."""
|
||||||
|
evaluation.apply_scores(scores)
|
||||||
|
evaluation.comments = comments
|
||||||
|
evaluation.position_recommendation = position
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_evaluations_query(user):
|
||||||
|
"""Evaluations the acting user is allowed to see, as a query.
|
||||||
|
|
||||||
|
Admins and scouts see everything. Managers and coaches see the
|
||||||
|
evaluations authored by managers and coaches inside the tryouts they
|
||||||
|
manage; players never reach the evaluation pages.
|
||||||
|
"""
|
||||||
|
if isinstance(user, (Admin, Scout)):
|
||||||
|
return Evaluation.query
|
||||||
|
tryout_ids = [t.id for t in user.get_visible_tryouts()]
|
||||||
|
if not tryout_ids:
|
||||||
|
return Evaluation.query.filter(false())
|
||||||
|
evaluator_alias = aliased(User, name='visible_evaluator')
|
||||||
|
return (
|
||||||
|
Evaluation.query
|
||||||
|
.join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||||
|
.filter(evaluator_alias.role.in_(['manager', 'coach']))
|
||||||
|
.filter(Evaluation.tryout_id.in_(tryout_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_evaluation_ids(user):
|
||||||
|
"""A ``select`` of the ids of evaluations the acting user may see."""
|
||||||
|
ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery()
|
||||||
|
return select(ids.c.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _average(values):
|
||||||
|
"""Mean of the non-None values, rounded to one decimal (or None)."""
|
||||||
|
values = [value for value in values if value is not None]
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
return round(sum(values) / len(values), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate(evaluations):
|
||||||
|
"""Per-criterion averages plus a combined overall average."""
|
||||||
|
criterion_averages = [
|
||||||
|
(label, _average([getattr(e, field) for e in evaluations]))
|
||||||
|
for field, label in EVALUATION_CRITERIA
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
'criterion_averages': criterion_averages,
|
||||||
|
'combined_average': _average([e.overall_score for e in evaluations]),
|
||||||
|
'count': len(evaluations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluation_view(evaluation, evaluator, tryout):
|
||||||
|
"""Shape an Evaluation for the templates (scores in criterion order)."""
|
||||||
|
return {
|
||||||
|
'evaluation': evaluation,
|
||||||
|
'evaluator': evaluator,
|
||||||
|
'tryout': tryout,
|
||||||
|
'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA],
|
||||||
|
'overall': evaluation.overall_score,
|
||||||
|
'position': evaluation.position_recommendation,
|
||||||
|
'comment': evaluation.comments,
|
||||||
|
'date': evaluation.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _players_by_average(user):
|
||||||
|
"""Players with visible evaluations, best combined average first."""
|
||||||
|
visible_ids = _visible_evaluation_ids(user)
|
||||||
|
rows = (
|
||||||
|
db.session.query(
|
||||||
|
Evaluation.player_id,
|
||||||
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
|
)
|
||||||
|
.filter(Evaluation.id.in_(visible_ids))
|
||||||
|
.group_by(Evaluation.player_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
players_by_id = _users_by_id(row.player_id for row in rows)
|
||||||
|
ordered = [
|
||||||
|
(players_by_id[row.player_id], row.avg_score)
|
||||||
|
for row in rows
|
||||||
|
if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player)
|
||||||
|
]
|
||||||
|
ordered.sort(
|
||||||
|
key=lambda pair: (
|
||||||
|
pair[1] is None,
|
||||||
|
-(pair[1] if pair[1] is not None else 0),
|
||||||
|
pair[0].username.lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
@evaluations_bp.route('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
"""List all evaluations accessible to the current user."""
|
"""List player evaluations grouped by player, with a per-player view."""
|
||||||
user = current_user
|
user = current_user
|
||||||
|
|
||||||
if isinstance(user, Player):
|
if isinstance(user, Player):
|
||||||
flash(_('You do not have permission to view evaluations.'), 'danger')
|
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
sort_column = request.args.get('sort', 'created_at')
|
sort_column = request.args.get('sort', 'overall_score')
|
||||||
sort_order = request.args.get('order', 'desc')
|
sort_order = request.args.get('order', 'desc')
|
||||||
if sort_order not in ('asc', 'desc'):
|
if sort_order not in ('asc', 'desc'):
|
||||||
sort_order = 'desc'
|
sort_order = 'desc'
|
||||||
|
|
||||||
player_alias = aliased(User, name='eval_player')
|
visible_ids = _visible_evaluation_ids(user)
|
||||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
|
||||||
|
|
||||||
sort_map = {
|
aggregate = (
|
||||||
'tryout': Tryout.title,
|
db.session.query(
|
||||||
'player': player_alias.username,
|
Evaluation.player_id,
|
||||||
'evaluator': evaluator_alias.username,
|
func.count(Evaluation.id).label('eval_count'),
|
||||||
'mecanics_score': Evaluation.mecanics_score,
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
'cohesion_score': Evaluation.cohesion_score,
|
func.max(Evaluation.created_at).label('last_eval'),
|
||||||
'communication_score': Evaluation.communication_score,
|
|
||||||
'gamesense_score': Evaluation.gamesense_score,
|
|
||||||
'versatility_score': Evaluation.versatility_score,
|
|
||||||
'discipline_score': Evaluation.discipline_score,
|
|
||||||
'analysis_score': Evaluation.analysis_score,
|
|
||||||
'sport_ethics_score': Evaluation.sport_ethics_score,
|
|
||||||
'mental_score': Evaluation.mental_score,
|
|
||||||
'overall_score': Evaluation.overall_score,
|
|
||||||
'position_recommendation': Evaluation.position_recommendation,
|
|
||||||
'created_at': Evaluation.created_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
|
|
||||||
if sort_order == 'asc':
|
|
||||||
sort_expr = sort_expr.asc()
|
|
||||||
else:
|
|
||||||
sort_expr = sort_expr.desc()
|
|
||||||
|
|
||||||
if isinstance(user, Admin):
|
|
||||||
evaluations_page = paginate(
|
|
||||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
|
||||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
|
||||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
|
||||||
.order_by(sort_expr, Evaluation.id)
|
|
||||||
)
|
)
|
||||||
avg_scores = (
|
.filter(Evaluation.id.in_(visible_ids))
|
||||||
db.session.query(
|
.group_by(Evaluation.player_id)
|
||||||
Evaluation.player_id,
|
.all()
|
||||||
func.count(Evaluation.id).label('eval_count'),
|
)
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
|
||||||
)
|
players_by_id = _users_by_id(row.player_id for row in aggregate)
|
||||||
.group_by(Evaluation.player_id)
|
grouped = []
|
||||||
|
for row in aggregate:
|
||||||
|
player = players_by_id.get(row.player_id)
|
||||||
|
if not player or not isinstance(player, Player):
|
||||||
|
continue
|
||||||
|
grouped.append(
|
||||||
|
{
|
||||||
|
'player': player,
|
||||||
|
'avg': round(row.avg_score, 1) if row.avg_score is not None else None,
|
||||||
|
'count': row.eval_count,
|
||||||
|
'last_eval': row.last_eval,
|
||||||
|
'evaluations': [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def sort_value(entry):
|
||||||
|
if sort_column == 'player':
|
||||||
|
return entry['player'].username.lower()
|
||||||
|
if sort_column == 'count':
|
||||||
|
return entry['count']
|
||||||
|
if sort_column == 'created_at':
|
||||||
|
return entry['last_eval'] or utc_now_naive()
|
||||||
|
return entry['avg'] if entry['avg'] is not None else float('-inf')
|
||||||
|
|
||||||
|
grouped.sort(key=sort_value, reverse=(sort_order == 'desc'))
|
||||||
|
|
||||||
|
page = request.args.get('page', 1, type=int) or 1
|
||||||
|
page = max(1, page)
|
||||||
|
per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE
|
||||||
|
per_page = max(1, min(per_page, MAX_PER_PAGE))
|
||||||
|
|
||||||
|
total = len(grouped)
|
||||||
|
pages = math.ceil(total / per_page) if total else 1
|
||||||
|
page = min(page, pages)
|
||||||
|
start = (page - 1) * per_page
|
||||||
|
page_items = grouped[start:start + per_page]
|
||||||
|
|
||||||
|
players_on_page = {entry['player'].id: entry for entry in page_items}
|
||||||
|
if players_on_page:
|
||||||
|
evaluations = (
|
||||||
|
Evaluation.query
|
||||||
|
.filter(Evaluation.player_id.in_(players_on_page))
|
||||||
|
.filter(Evaluation.id.in_(visible_ids))
|
||||||
|
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
|
||||||
player_scores = {}
|
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
|
||||||
for row in avg_scores:
|
tryouts_by_id = (
|
||||||
p = players_by_id.get(row.player_id)
|
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
|
||||||
if p:
|
if tryout_ids
|
||||||
player_scores[p.id] = {
|
else {}
|
||||||
'player': p,
|
|
||||||
'count': row.eval_count,
|
|
||||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Everyone still here evaluates: players were redirected above, and
|
|
||||||
# can_evaluate() is true for the four remaining roles. The former
|
|
||||||
# `else` branch listed evaluations *received* — a player's view,
|
|
||||||
# unreachable from this point (ARCH-007).
|
|
||||||
evaluations_page = paginate(
|
|
||||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
|
||||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
|
||||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
|
||||||
.filter(Evaluation.evaluator_id == user.id)
|
|
||||||
.order_by(sort_expr, Evaluation.id)
|
|
||||||
)
|
)
|
||||||
player_scores = {}
|
for evaluation in evaluations:
|
||||||
|
players_on_page[evaluation.player_id]['evaluations'].append(
|
||||||
|
_evaluation_view(
|
||||||
|
evaluation,
|
||||||
|
evaluators_by_id.get(evaluation.evaluator_id),
|
||||||
|
tryouts_by_id.get(evaluation.tryout_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination = {
|
||||||
|
'page': page,
|
||||||
|
'pages': pages,
|
||||||
|
'total': total,
|
||||||
|
'has_prev': page > 1,
|
||||||
|
'has_next': page < pages,
|
||||||
|
'prev_num': page - 1,
|
||||||
|
'next_num': page + 1,
|
||||||
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/evaluations.html',
|
'pages/evaluations.html',
|
||||||
evaluations=evaluations_page.items,
|
players=page_items,
|
||||||
pagination=evaluations_page,
|
pagination=pagination,
|
||||||
player_scores=player_scores,
|
|
||||||
sort_column=sort_column,
|
sort_column=sort_column,
|
||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
first_player_id=grouped[0]['player'].id if grouped else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@evaluations_bp.route('/player/<int:player_id>')
|
||||||
|
@login_required
|
||||||
|
def player_evaluations(player_id):
|
||||||
|
"""One player's evaluations, with prev/next navigation between players."""
|
||||||
|
user = current_user
|
||||||
|
if not user.can_evaluate():
|
||||||
|
flash(_('You do not have permission to view evaluations.'), 'danger')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
player = db.get_or_404(User, player_id)
|
||||||
|
if not isinstance(player, Player):
|
||||||
|
flash(_('Can only view evaluations for players.'), 'danger')
|
||||||
|
return redirect(url_for('evaluations.list_evaluations'))
|
||||||
|
|
||||||
|
ordered = _players_by_average(user)
|
||||||
|
index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None)
|
||||||
|
prev_player = ordered[index - 1][0] if index is not None and index > 0 else None
|
||||||
|
next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None
|
||||||
|
|
||||||
|
visible_ids = _visible_evaluation_ids(user)
|
||||||
|
evaluations = (
|
||||||
|
Evaluation.query
|
||||||
|
.filter(Evaluation.player_id == player.id)
|
||||||
|
.filter(Evaluation.id.in_(visible_ids))
|
||||||
|
.order_by(Evaluation.created_at.desc(), Evaluation.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations)
|
||||||
|
tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id]
|
||||||
|
tryouts_by_id = (
|
||||||
|
{t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()}
|
||||||
|
if tryout_ids
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
evaluation_views = [
|
||||||
|
_evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id))
|
||||||
|
for e in evaluations
|
||||||
|
]
|
||||||
|
|
||||||
|
aggregates = _aggregate(evaluations)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'pages/player_evaluations.html',
|
||||||
|
player=player,
|
||||||
|
evaluations=evaluation_views,
|
||||||
|
prev_player=prev_player,
|
||||||
|
next_player=next_player,
|
||||||
|
criterion_averages=aggregates['criterion_averages'],
|
||||||
|
combined_average=aggregates['combined_average'],
|
||||||
|
evaluation_count=aggregates['count'],
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -157,12 +348,12 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
)
|
)
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
player = db.get_or_404(User, player_id)
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing_eval = Evaluation.query.filter_by(
|
existing_eval = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
@@ -216,7 +407,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_evaluation_form()
|
return render_evaluation_form()
|
||||||
|
|
||||||
@@ -291,23 +482,31 @@ 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 = TryoutRegistration.query.filter_by(
|
is_registered = (
|
||||||
tryout_id=tryout_id, player_id=pid,
|
TryoutRegistration.query.filter_by(
|
||||||
).first() is not None
|
tryout_id=tryout_id,
|
||||||
|
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, player_id=pid, evaluator_id=current_user.id,
|
tryout_id=tryout_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,
|
{
|
||||||
'existing': existing,
|
'player': player,
|
||||||
'existing_scores': existing_scores,
|
'existing': existing,
|
||||||
})
|
'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')
|
||||||
@@ -315,32 +514,47 @@ def batch_evaluate(tryout_id):
|
|||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
saved = 0
|
saved = 0
|
||||||
for entry in players:
|
try:
|
||||||
pid = entry['player'].id
|
for entry in players:
|
||||||
scores = {
|
pid = entry['player'].id
|
||||||
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
scores = {
|
||||||
for field_name, _ in EVALUATION_CRITERIA
|
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
|
||||||
}
|
for field_name, _ in EVALUATION_CRITERIA
|
||||||
comments = request.form.get(f'comments_{pid}')
|
}
|
||||||
position = request.form.get(f'position_recommendation_{pid}')
|
comments = request.form.get(f'comments_{pid}')
|
||||||
|
position = request.form.get(f'position_recommendation_{pid}')
|
||||||
|
|
||||||
existing = entry['existing']
|
existing = entry['existing']
|
||||||
if existing:
|
if existing:
|
||||||
_apply_evaluation(existing, scores, comments, position)
|
_apply_evaluation(existing, scores, comments, position)
|
||||||
else:
|
else:
|
||||||
evaluation = Evaluation(
|
evaluation = Evaluation(
|
||||||
tryout_id=tryout_id, player_id=pid,
|
tryout_id=tryout_id,
|
||||||
evaluator_id=current_user.id,
|
player_id=pid,
|
||||||
)
|
evaluator_id=current_user.id,
|
||||||
_apply_evaluation(evaluation, scores, comments, position)
|
)
|
||||||
db.session.add(evaluation)
|
_apply_evaluation(evaluation, scores, comments, position)
|
||||||
saved += 1
|
db.session.add(evaluation)
|
||||||
|
saved += 1
|
||||||
|
except ValidationError:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('Scores must be whole numbers from 1 to 10.'), 'danger')
|
||||||
|
return render_template(
|
||||||
|
'pages/batch_evaluate.html',
|
||||||
|
tryout=tryout,
|
||||||
|
players=players,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
game_positions=GAME_POSITIONS,
|
||||||
|
)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
flash(f'Saved evaluations for {saved} player(s).', 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_template('pages/batch_evaluate.html',
|
return render_template(
|
||||||
tryout=tryout, players=players,
|
'pages/batch_evaluate.html',
|
||||||
evaluation_criteria=EVALUATION_CRITERIA,
|
tryout=tryout,
|
||||||
game_positions=GAME_POSITIONS)
|
players=players,
|
||||||
|
evaluation_criteria=EVALUATION_CRITERIA,
|
||||||
|
game_positions=GAME_POSITIONS,
|
||||||
|
)
|
||||||
|
|||||||
+229
-97
@@ -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, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, jsonify, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_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,11 +26,14 @@ 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
|
||||||
|
|
||||||
@@ -74,6 +77,25 @@ 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.
|
||||||
|
|
||||||
@@ -165,13 +187,17 @@ 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)
|
||||||
@@ -198,7 +224,6 @@ def api_events():
|
|||||||
|
|
||||||
for match in matches:
|
for match in matches:
|
||||||
tryout = tryouts_by_id[match.tryout_id]
|
tryout = tryouts_by_id[match.tryout_id]
|
||||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
|
||||||
# 'description' used to be participants_str + '<br>' + description.
|
# 'description' used to be participants_str + '<br>' + description.
|
||||||
# Building presentation markup inside a JSON field is what carried
|
# Building presentation markup inside a JSON field is what carried
|
||||||
# the stored XSS: the browser dropped it straight into innerHTML,
|
# the stored XSS: the browser dropped it straight into innerHTML,
|
||||||
@@ -225,30 +250,69 @@ def api_events():
|
|||||||
|
|
||||||
user_participant = mine_by_match.get(match.id)
|
user_participant = mine_by_match.get(match.id)
|
||||||
|
|
||||||
events.append(
|
event = {
|
||||||
{
|
'id': f'match_{match.id}',
|
||||||
'id': f'match_{match.id}',
|
'title': match.title,
|
||||||
'title': match.title,
|
'type': 'match',
|
||||||
'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',
|
||||||
|
'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):
|
||||||
@@ -263,23 +327,21 @@ def api_events():
|
|||||||
one_on_ones = []
|
one_on_ones = []
|
||||||
|
|
||||||
for ooo in one_on_ones:
|
for ooo in one_on_ones:
|
||||||
events.append(
|
event = {
|
||||||
{
|
'id': f'one_on_one_{ooo.id}',
|
||||||
'id': f'one_on_one_{ooo.id}',
|
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
'type': 'one_on_one',
|
||||||
'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)
|
||||||
|
|
||||||
@@ -351,25 +413,24 @@ def api_events_for_tryout(tryout_id):
|
|||||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
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
|
||||||
|
|
||||||
events.append(
|
event = {
|
||||||
{
|
'id': f'match_{match.id}',
|
||||||
'id': f'match_{match.id}',
|
'title': match.title,
|
||||||
'title': match.title,
|
'type': 'match',
|
||||||
'date': match.date.strftime('%Y-%m-%d'),
|
'color': match_color,
|
||||||
'type': 'match',
|
'extendedProps': {
|
||||||
'color': match_color,
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'extendedProps': {
|
'status': match.status,
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'match_type': match.match_type,
|
||||||
'status': match.status,
|
'tryout_id': tryout.id,
|
||||||
'match_type': match.match_type,
|
'match_id': match.id,
|
||||||
'tryout_id': tryout.id,
|
'participants': participants_str,
|
||||||
'match_id': match.id,
|
'start_time': start_time_str,
|
||||||
'participants': participants_str,
|
'end_time': end_time_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)
|
||||||
|
|
||||||
@@ -381,11 +442,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if tryout.is_ended:
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||||
all_players = registered_players(tryout_id)
|
all_players = registered_players(tryout_id)
|
||||||
@@ -443,7 +504,7 @@ def create_match(tryout_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
flash(_('Match scheduled successfully!'), 'success')
|
flash(_('Match scheduled successfully!'), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return rerender()
|
return rerender()
|
||||||
|
|
||||||
@@ -457,11 +518,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 redirect(url_for('matches.calendar'))
|
return respond_after_change(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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||||
all_players = registered_players(tryout.id)
|
all_players = registered_players(tryout.id)
|
||||||
@@ -505,6 +566,12 @@ def edit_match(match_id):
|
|||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return rerender()
|
return rerender()
|
||||||
|
|
||||||
|
# Snapshot the date/time before we overwrite them, so we can detect
|
||||||
|
# whether only these fields changed and choose the right Discord message.
|
||||||
|
old_date = match.date
|
||||||
|
old_start = match.start_time
|
||||||
|
old_end = match.end_time
|
||||||
|
|
||||||
# Assigned only once the whole form has been accepted. Assigning as
|
# Assigned only once the whole form has been accepted. Assigning as
|
||||||
# each field was read meant a form rejected halfway had already
|
# each field was read meant a form rejected halfway had already
|
||||||
# changed the record in the session.
|
# changed the record in the session.
|
||||||
@@ -516,41 +583,106 @@ def edit_match(match_id):
|
|||||||
match.location = data['location']
|
match.location = data['location']
|
||||||
match.status = data['status']
|
match.status = data['status']
|
||||||
|
|
||||||
|
time_changed = (
|
||||||
|
match.date != old_date or match.start_time != old_start or match.end_time != old_end
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the desired participant set and reconcile it against the
|
||||||
|
# existing participants, rather than deleting and recreating everyone.
|
||||||
|
# This preserves attendance_confirmed for players who stay.
|
||||||
|
desired = set()
|
||||||
|
if match.match_type == 'team_vs_team':
|
||||||
|
match.team1_id = data['team1_id']
|
||||||
|
match.team2_id = data['team2_id']
|
||||||
|
for team_id in (data['team1_id'], data['team2_id']):
|
||||||
|
if team_id:
|
||||||
|
for member in TeamMember.query.filter_by(team_id=team_id).all():
|
||||||
|
desired.add(member.player_id)
|
||||||
|
elif match.match_type == 'player_vs_player':
|
||||||
|
for pid in data['team1_player_ids']:
|
||||||
|
desired.add(pid)
|
||||||
|
for pid in data['team2_player_ids']:
|
||||||
|
desired.add(pid)
|
||||||
|
else:
|
||||||
|
for pid in data['player_ids']:
|
||||||
|
desired.add(pid)
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
p.player_id: p for p in MatchParticipant.query.filter_by(match_id=match.id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
removed_ids = []
|
||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
|
kept_ids = set()
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
for player_id, participant in list(existing.items()):
|
||||||
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
if player_id in desired:
|
||||||
if teams_changed:
|
kept_ids.add(player_id)
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
notified_player_ids.append(player_id)
|
||||||
match.team1_id = data['team1_id']
|
notified_participant_ids.append(participant.id)
|
||||||
match.team2_id = data['team2_id']
|
|
||||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
|
||||||
else:
|
else:
|
||||||
# Same teams: the roster stands, but everyone is told again,
|
removed_ids.append(participant.id)
|
||||||
# because the date or the time may have moved.
|
db.session.delete(participant)
|
||||||
for team_id in (match.team1_id, match.team2_id):
|
|
||||||
if team_id:
|
# New players get new participant rows.
|
||||||
notified_player_ids.extend(
|
for player_id in desired - set(existing.keys()):
|
||||||
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
participant = MatchParticipant(match_id=match.id, player_id=player_id)
|
||||||
)
|
db.session.add(participant)
|
||||||
else:
|
db.session.flush()
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
notified_player_ids.append(player_id)
|
||||||
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
notified_participant_ids.append(participant.id)
|
||||||
|
kept_ids.add(player_id)
|
||||||
|
|
||||||
|
# When the date or time changed, reset attendance on everyone who
|
||||||
|
# stayed. They'll be asked to re-confirm via the modified message.
|
||||||
|
if time_changed:
|
||||||
|
for participant in MatchParticipant.query.filter_by(match_id=match.id).all():
|
||||||
|
if participant.player_id in kept_ids:
|
||||||
|
participant.attendance_confirmed = False
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
notify_participants(
|
# Purge Discord pending entries for participants that no longer exist.
|
||||||
title=match.title,
|
if removed_ids:
|
||||||
date=match.date,
|
from app.discord_bot import purge_pending_for_participants
|
||||||
start_time=match.start_time,
|
|
||||||
end_time=match.end_time,
|
purge_pending_for_participants(removed_ids)
|
||||||
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
|
||||||
fallback_id=match.id,
|
# Decide whether and what to notify.
|
||||||
)
|
if time_changed:
|
||||||
|
# All current participants get the "modified" message.
|
||||||
|
all_current = list(zip(notified_player_ids, notified_participant_ids))
|
||||||
|
notify_participants(
|
||||||
|
title=match.title,
|
||||||
|
date=match.date,
|
||||||
|
start_time=match.start_time,
|
||||||
|
end_time=match.end_time,
|
||||||
|
participants=all_current,
|
||||||
|
fallback_id=match.id,
|
||||||
|
change_kind='modified',
|
||||||
|
)
|
||||||
|
elif desired - set(existing.keys()):
|
||||||
|
# Only the newly added players are told about the match.
|
||||||
|
new_player_ids = desired - set(existing.keys())
|
||||||
|
new_pairs = [
|
||||||
|
(pid, nid)
|
||||||
|
for pid, nid in zip(notified_player_ids, notified_participant_ids)
|
||||||
|
if pid in new_player_ids
|
||||||
|
]
|
||||||
|
notify_participants(
|
||||||
|
title=match.title,
|
||||||
|
date=match.date,
|
||||||
|
start_time=match.start_time,
|
||||||
|
end_time=match.end_time,
|
||||||
|
participants=new_pairs,
|
||||||
|
fallback_id=match.id,
|
||||||
|
change_kind='scheduled',
|
||||||
|
)
|
||||||
|
# else: nothing changed that needs a notification.
|
||||||
|
|
||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
return rerender()
|
return rerender()
|
||||||
|
|
||||||
@@ -586,10 +718,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 redirect(url_for('matches.calendar'))
|
return respond_after_change(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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
# Notes outlive the match they were taken during: a coach's observation
|
# 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.
|
||||||
@@ -602,7 +734,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
|
|
||||||
def get_players_available_at_time(date_str, time_str):
|
def get_players_available_at_time(date_str, time_str):
|
||||||
|
|||||||
+17
-12
@@ -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, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, jsonify, render_template, request, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_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,6 +13,7 @@ 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,
|
||||||
@@ -20,10 +21,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
|
||||||
@@ -121,12 +122,14 @@ 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('The regular season is not active. Begin a season before scheduling matches.', 'warning')
|
flash(
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
'The regular season is not active. Begin a season before scheduling matches.', 'warning'
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
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 redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
||||||
prefill_date = request.args.get('date', '')
|
prefill_date = request.args.get('date', '')
|
||||||
@@ -219,7 +222,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 redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
||||||
|
|
||||||
@@ -233,7 +236,7 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
if not can_manage_team_match(team):
|
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 redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(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,
|
||||||
@@ -263,7 +266,7 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Match updated successfully!'), 'success')
|
flash(_('Match updated successfully!'), 'success')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
return render_template(
|
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=[]
|
||||||
@@ -277,17 +280,19 @@ 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('The regular season is not active. Begin a season before deleting matches.', 'warning')
|
flash(
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
'The regular season is not active. Begin a season before deleting matches.', 'warning'
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
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 redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(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 redirect(url_for('team_matches.list_matches'))
|
return respond_after_change(url_for('team_matches.list_matches'))
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/api/manageable-teams')
|
@team_matches_bp.route('/api/manageable-teams')
|
||||||
|
|||||||
+56
-46
@@ -27,6 +27,8 @@ 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.services.documents import backfill_assignments_for_user
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||||
|
|
||||||
@@ -76,7 +78,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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
from app.models import TeamMatch, TeamMatchParticipant
|
from app.models import TeamMatch, TeamMatchParticipant
|
||||||
|
|
||||||
@@ -183,18 +185,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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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)
|
||||||
@@ -214,8 +216,11 @@ def create_team():
|
|||||||
team.managers.append(manager)
|
team.managers.append(manager)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
for staff_user in (coach, manager):
|
||||||
|
if staff_user:
|
||||||
|
backfill_assignments_for_user(staff_user)
|
||||||
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
||||||
@@ -225,18 +230,18 @@ def edit_team(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
team.name = name
|
team.name = name
|
||||||
|
|
||||||
@@ -268,8 +273,10 @@ def edit_team(team_id):
|
|||||||
team.managers.append(manager)
|
team.managers.append(manager)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
for staff_user in team.get_coaches() + team.get_managers():
|
||||||
|
backfill_assignments_for_user(staff_user)
|
||||||
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
||||||
@@ -297,7 +304,7 @@ def delete_team(team_id):
|
|||||||
|
|
||||||
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
name = team.name
|
name = team.name
|
||||||
|
|
||||||
@@ -328,7 +335,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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
||||||
@@ -338,19 +345,19 @@ def add_coach(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
if not data['coach_id']:
|
if not data['coach_id']:
|
||||||
flash(_('Please select a coach.'), 'danger')
|
flash(_('Please select a coach.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
coach = db.session.get(User, data['coach_id'])
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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(
|
||||||
@@ -361,17 +368,18 @@ def add_coach(team_id):
|
|||||||
),
|
),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
team.coaches.append(coach)
|
team.coaches.append(coach)
|
||||||
if not team.coach_id:
|
if not team.coach_id:
|
||||||
team.coach_id = coach.id
|
team.coach_id = coach.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
backfill_assignments_for_user(coach)
|
||||||
flash(
|
flash(
|
||||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
||||||
@@ -381,19 +389,19 @@ def add_manager(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
if not data['manager_id']:
|
if not data['manager_id']:
|
||||||
flash(_('Please select a manager.'), 'danger')
|
flash(_('Please select a manager.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
manager = db.session.get(User, data['manager_id'])
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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(
|
||||||
@@ -404,17 +412,18 @@ def add_manager(team_id):
|
|||||||
),
|
),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
team.managers.append(manager)
|
team.managers.append(manager)
|
||||||
if not team.manager_id:
|
if not team.manager_id:
|
||||||
team.manager_id = manager.id
|
team.manager_id = manager.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
backfill_assignments_for_user(manager)
|
||||||
flash(
|
flash(
|
||||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
||||||
@@ -424,11 +433,11 @@ def remove_coach(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(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'])
|
||||||
@@ -442,7 +451,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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
||||||
@@ -452,11 +461,11 @@ def remove_manager(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamStaffSchema())
|
data = _posted(TeamStaffSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(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'])
|
||||||
@@ -470,7 +479,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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
||||||
@@ -480,20 +489,20 @@ def add_player(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
data = _posted(TeamPlayerSchema())
|
data = _posted(TeamPlayerSchema())
|
||||||
if data is None:
|
if data is None:
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
if not data['player_id']:
|
if not data['player_id']:
|
||||||
flash(_('Please select a player.'), 'danger')
|
flash(_('Please select a player.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
status = data['status']
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -501,13 +510,14 @@ 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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||||
db.session.add(tp)
|
db.session.add(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
backfill_assignments_for_user(player)
|
||||||
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||||
@@ -517,7 +527,7 @@ def remove_player(team_id, player_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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()
|
||||||
@@ -526,7 +536,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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
db.session.delete(tp)
|
db.session.delete(tp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -534,7 +544,7 @@ def remove_player(team_id, player_id):
|
|||||||
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||||
@@ -569,19 +579,19 @@ def add_team_note(team_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
||||||
@@ -591,12 +601,12 @@ def add_player_note(team_id, player_id):
|
|||||||
team = db.get_or_404(OrgTeam, team_id)
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
if not tp:
|
if not tp:
|
||||||
@@ -604,16 +614,16 @@ def add_player_note(team_id, player_id):
|
|||||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
return redirect(url_for('teams.list_teams'))
|
return respond_after_change(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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|
||||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
|
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 redirect(url_for('teams.list_teams'))
|
return respond_after_change(url_for('teams.list_teams'))
|
||||||
|
|||||||
+224
-46
@@ -16,6 +16,7 @@ from app.models import (
|
|||||||
ESPORT_GAMES,
|
ESPORT_GAMES,
|
||||||
GAME_POSITIONS,
|
GAME_POSITIONS,
|
||||||
Admin,
|
Admin,
|
||||||
|
AppSettings,
|
||||||
Coach,
|
Coach,
|
||||||
Evaluation,
|
Evaluation,
|
||||||
Manager,
|
Manager,
|
||||||
@@ -30,14 +31,15 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -140,7 +142,10 @@ 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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
if not can_manage():
|
if not can_manage():
|
||||||
@@ -204,7 +209,10 @@ 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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
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):
|
||||||
@@ -273,9 +281,7 @@ 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):
|
elif isinstance(current_user, (Manager, Coach)):
|
||||||
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 = (
|
||||||
@@ -341,7 +347,12 @@ def view_tryout(tryout_id):
|
|||||||
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
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]
|
||||||
|
|
||||||
@@ -467,31 +478,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
tryout = locked_tryout_or_404(tryout_id)
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing = TryoutRegistration.query.filter_by(
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if tryout.max_players:
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
|
||||||
@@ -500,8 +511,11 @@ 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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
@@ -510,12 +524,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
tryout.status = data['status']
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
|
||||||
@@ -524,8 +538,11 @@ def update_registration_status(tryout_id, player_id):
|
|||||||
"""Update a registration's attendance status."""
|
"""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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
@@ -538,12 +555,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
registration.status = data['status']
|
registration.status = data['status']
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Registration status updated.'), 'success')
|
flash(_('Registration status updated.'), 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
|
||||||
@@ -552,21 +569,24 @@ def register_player(tryout_id):
|
|||||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
"""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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
try:
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if not data['player_id']:
|
if not data['player_id']:
|
||||||
flash(_('Please select a player.'), 'danger')
|
flash(_('Please select a player.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
# Same two checks as the team roster (SEC-16): the right role, and an
|
# 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
|
||||||
@@ -574,7 +594,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -582,19 +602,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if tryout.max_players:
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
|
||||||
@@ -603,8 +623,11 @@ def remove_player(tryout_id, player_id):
|
|||||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
"""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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
@@ -634,7 +657,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
|
||||||
@@ -643,24 +666,27 @@ 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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
try:
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
|
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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
|
||||||
@@ -670,12 +696,15 @@ def add_to_team(tryout_id, team_id):
|
|||||||
team = db.get_or_404(Team, team_id)
|
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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
# The two ids arrive independently in the URL. Without this check, being
|
# 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
|
||||||
@@ -687,7 +716,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
player_id = data['player_id']
|
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.
|
||||||
@@ -697,7 +726,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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -707,7 +736,153 @@ 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 redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_team(tryout_id, team_id):
|
||||||
|
"""Rename a tryout-specific team."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
team.name = data['team_name']
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_team(tryout_id, team_id):
|
||||||
|
"""Delete a tryout-specific team and its memberships."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
name = team.name
|
||||||
|
|
||||||
|
# Personal note references outlive the team.
|
||||||
|
PersonalNote.query.filter_by(team_id=team_id).update(
|
||||||
|
{'team_id': None}, synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Matches referencing this team: null the FK so the match survives.
|
||||||
|
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
|
||||||
|
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
|
||||||
|
|
||||||
|
# Clean participants for those matches (they are no longer in that team).
|
||||||
|
for match in Match.query.filter(
|
||||||
|
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
|
||||||
|
).all():
|
||||||
|
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
|
||||||
|
db.session.delete(team)
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Team "%(name)s" deleted.', name=name), 'success')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def remove_from_team(tryout_id, team_id, player_id):
|
||||||
|
"""Remove a player from a tryout team."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
# Also remove the player from any match participants that were tied to this team.
|
||||||
|
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
|
||||||
|
if match_ids:
|
||||||
|
MatchParticipant.query.filter(
|
||||||
|
MatchParticipant.match_id.in_(match_ids),
|
||||||
|
MatchParticipant.player_id == player_id,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Player removed from team.'), 'success')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
@tryouts_bp.route(
|
||||||
|
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
def update_member_position(tryout_id, team_id, member_id):
|
||||||
|
"""Set or clear a team member's in-game position (role)."""
|
||||||
|
team = db.get_or_404(Team, team_id)
|
||||||
|
tryout = db.get_or_404(Tryout, tryout_id)
|
||||||
|
member = db.get_or_404(TeamMember, member_id)
|
||||||
|
if tryouts_locked():
|
||||||
|
flash(
|
||||||
|
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
|
flash(_('Permission denied.'), 'danger')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
if team.tryout_id != tryout_id or member.team_id != team_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
member.position = data['position'] or None
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Position updated.'), 'success')
|
||||||
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
||||||
@@ -716,8 +891,11 @@ def delete_tryout(tryout_id):
|
|||||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
"""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('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
flash(
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
'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):
|
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')
|
||||||
@@ -757,4 +935,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'))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.routes.users import (
|
|||||||
accounts, # noqa: F401,E402
|
accounts, # noqa: F401,E402
|
||||||
availability, # noqa: F401,E402
|
availability, # noqa: F401,E402
|
||||||
contracts, # noqa: F401,E402
|
contracts, # noqa: F401,E402
|
||||||
|
documents, # noqa: F401,E402
|
||||||
notes, # noqa: F401,E402
|
notes, # noqa: F401,E402
|
||||||
one_on_one, # noqa: F401,E402
|
one_on_one, # noqa: F401,E402
|
||||||
profile, # noqa: F401,E402
|
profile, # noqa: F401,E402
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
@@ -102,11 +103,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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(url_for('users.list_contracts'))
|
||||||
|
|
||||||
players = manageable_players()
|
players = manageable_players()
|
||||||
|
|
||||||
@@ -125,13 +126,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 redirect(url_for('users.upload_contract'))
|
return respond_after_change(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 redirect(url_for('users.upload_contract'))
|
return respond_after_change(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()
|
||||||
@@ -154,7 +155,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 redirect(url_for('users.upload_contract'))
|
return respond_after_change(url_for('users.upload_contract'))
|
||||||
|
|
||||||
contract = Contract(
|
contract = Contract(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -171,7 +172,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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(url_for('users.list_contracts'))
|
||||||
|
|
||||||
return render_template('pages/upload_contract.html', players=players)
|
return render_template('pages/upload_contract.html', players=players)
|
||||||
|
|
||||||
@@ -183,17 +184,17 @@ def upload_signed_contract(contract_id):
|
|||||||
contract = db.get_or_404(Contract, contract_id)
|
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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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
|
||||||
@@ -208,7 +209,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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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
|
||||||
@@ -216,7 +217,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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(url_for('users.list_contracts'))
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/contracts/<int:contract_id>/download')
|
@users_bp.route('/contracts/<int:contract_id>/download')
|
||||||
@@ -226,12 +227,12 @@ def download_contract(contract_id):
|
|||||||
contract = db.get_or_404(Contract, contract_id)
|
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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(url_for('users.list_contracts'))
|
||||||
return send_file(document, as_attachment=True, download_name=contract.original_filename)
|
return send_file(document, as_attachment=True, download_name=contract.original_filename)
|
||||||
|
|
||||||
|
|
||||||
@@ -242,13 +243,13 @@ def download_signed_contract(contract_id):
|
|||||||
contract = db.get_or_404(Contract, contract_id)
|
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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(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 redirect(url_for('users.list_contracts'))
|
return respond_after_change(url_for('users.list_contracts'))
|
||||||
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
return send_file(document, as_attachment=True, download_name=contract.signed_filename)
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Member-facing document centre.
|
||||||
|
|
||||||
|
Players, coaches and managers see the published documents they must sign
|
||||||
|
or acknowledge, complete them, and download the file drawn from the single
|
||||||
|
canonical copy in storage. Coaches and managers additionally see progress
|
||||||
|
for the members of the teams they are attached to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import (
|
||||||
|
DOCUMENT_KIND_ROLES,
|
||||||
|
Admin,
|
||||||
|
Coach,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
DocumentVersion,
|
||||||
|
Manager,
|
||||||
|
)
|
||||||
|
from app.permissions import visible_org_teams
|
||||||
|
from app.responders import respond_after_change
|
||||||
|
from app.routes.users._shared import ALLOWED_SIGNED_EXTENSIONS, pdf_upload_error
|
||||||
|
from app.routes.users.blueprint import users_bp
|
||||||
|
from app.services.documents import (
|
||||||
|
backfill_assignments_for_user,
|
||||||
|
primary_team_for_user,
|
||||||
|
user_eligible_for_kind,
|
||||||
|
user_has_any_team,
|
||||||
|
)
|
||||||
|
from app.storage import GoogleDriveStorageError, open_document, store_signed_document
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
|
def _team_members(team):
|
||||||
|
"""Active players, coaches and managers of one team, de-duplicated."""
|
||||||
|
members = {}
|
||||||
|
for player in team.players:
|
||||||
|
if player.is_active_account:
|
||||||
|
members[player.id] = player
|
||||||
|
for staff in list(team.get_coaches()) + list(team.get_managers()):
|
||||||
|
if staff.is_active_account:
|
||||||
|
members[staff.id] = staff
|
||||||
|
return sorted(members.values(), key=lambda user: user.username.lower())
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/documents')
|
||||||
|
@login_required
|
||||||
|
def list_my_documents():
|
||||||
|
"""The current member's obligations, plus team progress for staff."""
|
||||||
|
backfill_assignments_for_user(current_user)
|
||||||
|
|
||||||
|
if isinstance(current_user, Admin):
|
||||||
|
return redirect(url_for('admin.documents'))
|
||||||
|
|
||||||
|
if not user_has_any_team(current_user):
|
||||||
|
return render_template('pages/my_documents.html', documents=[], teams_progress=None)
|
||||||
|
|
||||||
|
published = (
|
||||||
|
Document.query.filter(Document.is_published.is_(True))
|
||||||
|
.order_by(Document.kind, Document.title)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for doc in published:
|
||||||
|
if current_user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
|
||||||
|
continue
|
||||||
|
assignment = DocumentAssignment.query.filter_by(
|
||||||
|
document_id=doc.id, user_id=current_user.id
|
||||||
|
).first()
|
||||||
|
documents.append({'document': doc, 'assignment': assignment})
|
||||||
|
|
||||||
|
teams_progress = None
|
||||||
|
if isinstance(current_user, (Coach, Manager)):
|
||||||
|
teams_progress = []
|
||||||
|
for team in visible_org_teams(current_user):
|
||||||
|
rows = []
|
||||||
|
for member in _team_members(team):
|
||||||
|
statuses = {}
|
||||||
|
for doc in published:
|
||||||
|
if member.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
|
||||||
|
continue
|
||||||
|
assignment = DocumentAssignment.query.filter_by(
|
||||||
|
document_id=doc.id, user_id=member.id
|
||||||
|
).first()
|
||||||
|
statuses[doc.id] = assignment.status if assignment else None
|
||||||
|
rows.append({'user': member, 'statuses': statuses})
|
||||||
|
teams_progress.append({'team': team, 'members': rows, 'documents': published})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'pages/my_documents.html',
|
||||||
|
documents=documents,
|
||||||
|
teams_progress=teams_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/documents/assignments/<int:assignment_id>/sign', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def sign_document(assignment_id):
|
||||||
|
"""Upload the signed copy of a pending signature document."""
|
||||||
|
assignment = db.get_or_404(DocumentAssignment, assignment_id)
|
||||||
|
if assignment.user_id != current_user.id:
|
||||||
|
flash(_('You do not have permission to sign this document.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
if assignment.completed:
|
||||||
|
flash(_('This document has already been completed.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
if not assignment.document.requires_signature:
|
||||||
|
flash(_('This document is acknowledged, not signed.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
if not assignment.document.current_version:
|
||||||
|
flash(_('This document has no version published yet.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
file = request.files.get('signed_file')
|
||||||
|
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
|
||||||
|
if error:
|
||||||
|
flash(error, 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
team = primary_team_for_user(current_user)
|
||||||
|
if team is None:
|
||||||
|
flash(_('You must be part of a team before signing documents.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
version = assignment.document.current_version
|
||||||
|
# The kind prefix keeps separate documents from colliding: a player signs
|
||||||
|
# both the player contract and the team agreement, each at version 1.
|
||||||
|
signed_filename = f'{assignment.document.kind}-v{version.version}.pdf'
|
||||||
|
try:
|
||||||
|
stored_path = store_signed_document(
|
||||||
|
file,
|
||||||
|
team_name=team.name,
|
||||||
|
role=current_user.role,
|
||||||
|
username=current_user.username,
|
||||||
|
filename=signed_filename,
|
||||||
|
)
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
flash(
|
||||||
|
_('Document storage is temporarily unavailable. Please try again later.'),
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
assignment.status = 'signed'
|
||||||
|
assignment.signed_filename = signed_filename
|
||||||
|
assignment.signed_file_path = stored_path
|
||||||
|
assignment.completed_version_id = version.id
|
||||||
|
assignment.completed_at = utc_now_naive()
|
||||||
|
assignment.filed_team_id = team.id
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Document signed successfully!'), 'success')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/documents/assignments/<int:assignment_id>/acknowledge', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def acknowledge_document(assignment_id):
|
||||||
|
"""Mark a read-and-acknowledge document (code of conduct, ethics)."""
|
||||||
|
assignment = db.get_or_404(DocumentAssignment, assignment_id)
|
||||||
|
if assignment.user_id != current_user.id:
|
||||||
|
flash(_('You do not have permission to acknowledge this document.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
if assignment.completed:
|
||||||
|
flash(_('This document has already been completed.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
if assignment.document.requires_signature:
|
||||||
|
flash(_('This document must be signed, not acknowledged.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
if not assignment.document.current_version:
|
||||||
|
flash(_('This document has no version published yet.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
assignment.status = 'acknowledged'
|
||||||
|
assignment.completed_version_id = assignment.document.current_version.id
|
||||||
|
assignment.completed_at = utc_now_naive()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Document acknowledged.'), 'success')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/documents/version/<int:version_id>/download')
|
||||||
|
@login_required
|
||||||
|
def download_document(version_id):
|
||||||
|
"""Download the canonical file, gated on the admin's published flag."""
|
||||||
|
version = db.get_or_404(DocumentVersion, version_id)
|
||||||
|
allowed = isinstance(current_user, Admin) or (
|
||||||
|
version.document.is_published
|
||||||
|
and user_eligible_for_kind(current_user, version.document.kind)
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
flash(_('This document is not available to you.'), 'danger')
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_object = open_document(version.file_path)
|
||||||
|
except GoogleDriveStorageError:
|
||||||
|
flash(
|
||||||
|
_('Document storage is temporarily unavailable. Please try again later.'),
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return respond_after_change(url_for('users.list_my_documents'))
|
||||||
|
return send_file(file_object, as_attachment=True, download_name=version.original_filename)
|
||||||
+20
-19
@@ -10,6 +10,7 @@ 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,
|
||||||
@@ -176,14 +177,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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = TeamNote(
|
note = TeamNote(
|
||||||
org_team_id=org_team.id,
|
org_team_id=org_team.id,
|
||||||
@@ -194,7 +195,7 @@ def manage_team_notes():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Team notes saved successfully!'), 'success')
|
flash(_('Team notes saved successfully!'), 'success')
|
||||||
|
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -214,17 +215,17 @@ def manage_personal_notes():
|
|||||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
player_id = data['player_id']
|
player_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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = PersonalNote(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -234,7 +235,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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -254,44 +255,44 @@ def add_personal_note():
|
|||||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
flash_validation_errors(err)
|
flash_validation_errors(err)
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
player_id = data['player_id']
|
player_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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
note = PersonalNote(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -304,7 +305,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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -323,7 +324,7 @@ def add_note_from_tryout(tryout_id):
|
|||||||
tryout = db.get_or_404(Tryout, tryout_id)
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
preselected_player_id = request.args.get('player_id', type=int)
|
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
|
||||||
@@ -387,7 +388,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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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,6 +6,7 @@ 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
|
||||||
@@ -64,13 +65,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 redirect(url_for('users.one_on_one'))
|
return respond_after_change(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 redirect(url_for('users.one_on_one'))
|
return respond_after_change(url_for('users.one_on_one'))
|
||||||
|
|
||||||
date_obj = data['date']
|
date_obj = data['date']
|
||||||
start_time = data['start_time']
|
start_time = data['start_time']
|
||||||
@@ -90,7 +91,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 redirect(url_for('users.one_on_one'))
|
return respond_after_change(url_for('users.one_on_one'))
|
||||||
|
|
||||||
request_obj = OneOnOneRequest(
|
request_obj = OneOnOneRequest(
|
||||||
player_id=current_user.id,
|
player_id=current_user.id,
|
||||||
@@ -118,7 +119,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 redirect(url_for('users.one_on_one'))
|
return respond_after_change(url_for('users.one_on_one'))
|
||||||
|
|
||||||
# Build list of upcoming dates that have coach availability
|
# Build list of upcoming dates that have coach availability
|
||||||
from datetime import date as date_cls
|
from datetime import date as date_cls
|
||||||
@@ -169,11 +170,11 @@ def accept_one_on_one(request_id):
|
|||||||
|
|
||||||
if request_obj.coach_id != current_user.id:
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
player = request_obj.player
|
player = request_obj.player
|
||||||
request_obj.status = 'approved'
|
request_obj.status = 'approved'
|
||||||
@@ -204,7 +205,7 @@ def accept_one_on_one(request_id):
|
|||||||
),
|
),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
|
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
|
||||||
@@ -219,17 +220,17 @@ def reject_one_on_one(request_id):
|
|||||||
|
|
||||||
if request_obj.coach_id != current_user.id:
|
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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(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 redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
rejection_reason = data['rejection_reason']
|
rejection_reason = data['rejection_reason']
|
||||||
player = request_obj.player
|
player = request_obj.player
|
||||||
|
|
||||||
@@ -264,4 +265,4 @@ def reject_one_on_one(request_id):
|
|||||||
),
|
),
|
||||||
'info',
|
'info',
|
||||||
)
|
)
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return respond_after_change(url_for('users.notes_dashboard'))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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,
|
||||||
@@ -129,7 +130,7 @@ def edit_profile():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Profile updated successfully!'), 'success')
|
flash(_('Profile updated successfully!'), 'success')
|
||||||
return redirect(url_for('users.profile'))
|
return respond_after_change(url_for('users.profile'))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/edit_profile.html',
|
'pages/edit_profile.html',
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Document lifecycle logic: eligibility, assignment backfill, primary team.
|
||||||
|
|
||||||
|
The rules this encodes are the club's:
|
||||||
|
|
||||||
|
- Only *active* members who belong to at least one team receive an
|
||||||
|
obligation. A player not placed on a team signs nothing.
|
||||||
|
- Obligations are role-scoped. A player on two teams signs the player
|
||||||
|
contract once; ``UNIQUE(document_id, user_id)`` is the guarantee, and the
|
||||||
|
team folder below is only where the signed copy is filed.
|
||||||
|
- Publishing a version (or enabling a document) backfills pending
|
||||||
|
assignments for everyone currently eligible. Joining a team backfills
|
||||||
|
that one person across every published document matching their role.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import (
|
||||||
|
DOCUMENT_KIND_ROLES,
|
||||||
|
Coach,
|
||||||
|
Document,
|
||||||
|
DocumentAssignment,
|
||||||
|
Manager,
|
||||||
|
OrgTeam,
|
||||||
|
Player,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_org_team_ids(user):
|
||||||
|
"""Ids of the organisation teams the user is a member of.
|
||||||
|
|
||||||
|
Role-appropriate: placements for a player, the coach/managers
|
||||||
|
relationships (many-to-many *and* legacy single column) for staff. An
|
||||||
|
admin or a scout belongs to none, regardless of how many teams exist.
|
||||||
|
"""
|
||||||
|
from app.permissions import coach_org_teams, manager_org_teams
|
||||||
|
|
||||||
|
if isinstance(user, Player):
|
||||||
|
return [tp.org_team_id for tp in user.team_placements]
|
||||||
|
if isinstance(user, Coach):
|
||||||
|
return [team.id for team in coach_org_teams(user)]
|
||||||
|
if isinstance(user, Manager):
|
||||||
|
return [team.id for team in manager_org_teams(user)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_any_team(user):
|
||||||
|
"""Whether the user belongs to at least one organisation team."""
|
||||||
|
return bool(user_org_team_ids(user))
|
||||||
|
|
||||||
|
|
||||||
|
def primary_team_for_user(user):
|
||||||
|
"""The canonical team used for filing a signed copy, or None.
|
||||||
|
|
||||||
|
A member can sit on several teams but every signed file is filed under
|
||||||
|
one team only. The choice is deterministic: the team with the lowest
|
||||||
|
id among the user's memberships.
|
||||||
|
"""
|
||||||
|
team_ids = user_org_team_ids(user)
|
||||||
|
if not team_ids:
|
||||||
|
return None
|
||||||
|
return db.session.get(OrgTeam, min(team_ids))
|
||||||
|
|
||||||
|
|
||||||
|
def user_eligible_for_kind(user, kind):
|
||||||
|
"""Whether *user* must complete documents of *kind*."""
|
||||||
|
if kind not in DOCUMENT_KIND_ROLES:
|
||||||
|
return False
|
||||||
|
if not user.is_active_account:
|
||||||
|
return False
|
||||||
|
if user.role not in DOCUMENT_KIND_ROLES[kind]:
|
||||||
|
return False
|
||||||
|
return user_has_any_team(user)
|
||||||
|
|
||||||
|
|
||||||
|
def eligible_user_ids(kind):
|
||||||
|
"""Ids of every user currently eligible for *kind*, ordered by id."""
|
||||||
|
roles = DOCUMENT_KIND_ROLES[kind]
|
||||||
|
users = (
|
||||||
|
User.query.filter(User.role.in_(roles), User.is_active_account.is_(True))
|
||||||
|
.order_by(User.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [u.id for u in users if user_has_any_team(u)]
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_assignments_for_document(document):
|
||||||
|
"""Create a pending assignment for every currently eligible user.
|
||||||
|
|
||||||
|
Idempotent: anyone who already has an assignment for this document is
|
||||||
|
left alone. Returns how many new assignments were created.
|
||||||
|
"""
|
||||||
|
existing = {a.user_id for a in document.assignments.all()}
|
||||||
|
created = 0
|
||||||
|
for user_id in eligible_user_ids(document.kind):
|
||||||
|
if user_id in existing:
|
||||||
|
continue
|
||||||
|
db.session.add(
|
||||||
|
DocumentAssignment(
|
||||||
|
document_id=document.id,
|
||||||
|
user_id=user_id,
|
||||||
|
status='pending',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_assignments_for_user(user):
|
||||||
|
"""Create pending assignments for *user* across published documents.
|
||||||
|
|
||||||
|
Called when a member joins (or is reactivated into) a team. Only
|
||||||
|
published documents are backfilled: an essentially invisible draft
|
||||||
|
creates no obligations. Returns how many new assignments were created.
|
||||||
|
"""
|
||||||
|
if not user.is_active_account or not user_has_any_team(user):
|
||||||
|
return 0
|
||||||
|
documents = Document.query.filter(Document.is_published.is_(True)).order_by(Document.id).all()
|
||||||
|
created = 0
|
||||||
|
for doc in documents:
|
||||||
|
if user.role not in DOCUMENT_KIND_ROLES.get(doc.kind, ()):
|
||||||
|
continue
|
||||||
|
exists = DocumentAssignment.query.filter_by(document_id=doc.id, user_id=user.id).first()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
db.session.add(
|
||||||
|
DocumentAssignment(
|
||||||
|
document_id=doc.id,
|
||||||
|
user_id=user.id,
|
||||||
|
status='pending',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
return created
|
||||||
@@ -60,7 +60,9 @@ def zip_participants(player_ids, participant_ids):
|
|||||||
yield player_id, participant_ids[index] if index < len(participant_ids) else None
|
yield player_id, participant_ids[index] if index < len(participant_ids) else None
|
||||||
|
|
||||||
|
|
||||||
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
|
def notify_participants(
|
||||||
|
*, title, date, start_time, end_time, participants, fallback_id, change_kind='scheduled'
|
||||||
|
):
|
||||||
"""Tell each participant that a match has been scheduled or changed.
|
"""Tell each participant that a match has been scheduled or changed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -73,6 +75,8 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
|
|||||||
attendance lands on the right row.
|
attendance lands on the right row.
|
||||||
fallback_id: Reference to use when a participant row has no id —
|
fallback_id: Reference to use when a participant row has no id —
|
||||||
the match's own, which the bot can still act on.
|
the match's own, which the bot can still act on.
|
||||||
|
change_kind: 'scheduled' (new match / new player added) or
|
||||||
|
'modified' (date/time changed for existing players).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: How many notifications were handed to the bot.
|
int: How many notifications were handed to the bot.
|
||||||
@@ -91,6 +95,7 @@ def notify_participants(*, title, date, start_time, end_time, participants, fall
|
|||||||
event_date=event_date,
|
event_date=event_date,
|
||||||
event_time=event_time,
|
event_time=event_time,
|
||||||
reference_id=participant_id or fallback_id,
|
reference_id=participant_id or fallback_id,
|
||||||
|
change_kind=change_kind,
|
||||||
)
|
)
|
||||||
sent += 1
|
sent += 1
|
||||||
return sent
|
return sent
|
||||||
|
|||||||
+189
-15
@@ -1043,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
|
|||||||
|
|
||||||
.fc {
|
.fc {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--fc-border-color: var(--gray-200);
|
||||||
|
--fc-page-bg-color: transparent;
|
||||||
|
--fc-neutral-bg-color: var(--gray-50);
|
||||||
|
--fc-today-bg-color: var(--info-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-toolbar {
|
.fc-toolbar {
|
||||||
@@ -1055,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
|
|||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-button {
|
.fc .fc-button {
|
||||||
background: var(--primary) !important;
|
background: var(--gray-50) !important;
|
||||||
border: none !important;
|
border: 1px solid var(--gray-200) !important;
|
||||||
|
color: var(--gray-700) !important;
|
||||||
border-radius: var(--radius-sm) !important;
|
border-radius: var(--radius-sm) !important;
|
||||||
padding: 6px 12px !important;
|
padding: 6px 12px !important;
|
||||||
font-size: 0.85rem !important;
|
font-size: 0.85rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-button:hover {
|
.fc .fc-button:hover {
|
||||||
background: var(--primary-dark) !important;
|
background: var(--gray-100) !important;
|
||||||
|
color: var(--gray-900) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-button-primary:not(:disabled).fc-button-active,
|
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||||
.fc-button-primary:not(:disabled):active {
|
.fc .fc-button-primary:not(:disabled):active {
|
||||||
background: var(--primary-dark) !important;
|
background: var(--primary) !important;
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-daygrid-day {
|
.fc-daygrid-day,
|
||||||
|
.fc-timegrid-slot {
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
|
|||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-event {
|
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
|
||||||
border-radius: var(--radius-sm);
|
its inline event colors) */
|
||||||
padding: 2px 4px;
|
#calendar .fc-event {
|
||||||
font-size: 0.8rem;
|
border: none !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
|
padding: 3px 6px !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
background-color: var(--fc-event-bg, var(--primary)) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-event-title {
|
#calendar .fc-event-body {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#calendar .fc-event-body i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#calendar .fc-event-text {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#calendar .fc-event-time {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#calendar .fc-event-title {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event type colors (semantic, light theme) */
|
||||||
|
#calendar .fc-event--team { --fc-event-bg: #10b981; }
|
||||||
|
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
|
||||||
|
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
|
||||||
|
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
|
||||||
|
|
||||||
|
#calendar .fc-now-indicator {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme: the calendar chrome reacts to the light/dark switch */
|
||||||
|
[data-theme="dark"] .fc {
|
||||||
|
--fc-border-color: var(--border-color);
|
||||||
|
--fc-neutral-bg-color: var(--bg-secondary);
|
||||||
|
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .fc-toolbar-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .fc .fc-button {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .fc .fc-button:hover {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||||
|
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
|
||||||
|
background: var(--primary) !important;
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .fc-daygrid-day:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .fc-daygrid-day-number,
|
||||||
|
[data-theme="dark"] .fc-col-header-cell-cushion,
|
||||||
|
[data-theme="dark"] .fc-timegrid-axis-cushion,
|
||||||
|
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pastel event chips with dark text keep contrast on a dark background. */
|
||||||
|
[data-theme="dark"] #calendar .fc-event {
|
||||||
|
color: var(--gray-900) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
|
||||||
|
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
|
||||||
|
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
|
||||||
|
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -2122,3 +2226,73 @@ a:hover { color: var(--primary-dark); }
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Evaluation comments - separate grid so long notes stay readable */
|
||||||
|
.eval-comments {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comments-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment-text {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comment-author strong {
|
||||||
|
color: var(--gray-700);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment-text {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comment-author strong {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-comments-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .eval-comments-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|||||||
+193
-16
@@ -420,22 +420,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
initDraggableBlocks();
|
initDraggableBlocks();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-dismiss flash alerts after 5 seconds.
|
* Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss).
|
||||||
*/
|
*/
|
||||||
const alerts = document.querySelectorAll('.alert-dismissible');
|
armAlertAutoDismiss();
|
||||||
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
|
||||||
@@ -593,7 +580,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) {
|
||||||
control.form.submit();
|
submitForm(control.form);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -613,3 +600,193 @@ 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;
|
||||||
@@ -41,6 +41,12 @@ from app.google_drive import (
|
|||||||
from app.google_drive import (
|
from app.google_drive import (
|
||||||
download_file as download_google_drive_file,
|
download_file as download_google_drive_file,
|
||||||
)
|
)
|
||||||
|
from app.google_drive import (
|
||||||
|
get_or_create_folder as get_or_create_drive_folder,
|
||||||
|
)
|
||||||
|
from app.google_drive import (
|
||||||
|
root_folder_id as google_drive_root_folder_id,
|
||||||
|
)
|
||||||
from app.google_drive import (
|
from app.google_drive import (
|
||||||
upload_file as upload_google_drive_file,
|
upload_file as upload_google_drive_file,
|
||||||
)
|
)
|
||||||
@@ -159,6 +165,73 @@ def store_uploaded_document(file_storage, relative_path):
|
|||||||
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def _save_locally(file_storage, relative_path):
|
||||||
|
"""Persist *relative_path* (from ``documents_root()``) and return it."""
|
||||||
|
absolute_path = document_path(relative_path)
|
||||||
|
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||||
|
file_storage.save(absolute_path)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_folder_for(parts):
|
||||||
|
"""Resolve (and create as needed) a nested chain of Drive folders.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parts: Folder names under the root folder, root first.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Drive id of the deepest folder.
|
||||||
|
"""
|
||||||
|
parent = google_drive_root_folder_id()
|
||||||
|
for part in parts:
|
||||||
|
parent = get_or_create_drive_folder(part, parent)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def store_master_document(file_storage, filename, mimetype=None):
|
||||||
|
"""Store the unsigned template of a document (the canonical copy).
|
||||||
|
|
||||||
|
The point is to store the file exactly once: every assignment for that
|
||||||
|
document points at this one reference.
|
||||||
|
"""
|
||||||
|
if document_storage_backend() == 'local':
|
||||||
|
return _save_locally(file_storage, os.path.join(CONTRACTS_DIR, filename))
|
||||||
|
|
||||||
|
file_id = upload_google_drive_file(
|
||||||
|
stream=file_storage.stream,
|
||||||
|
filename=filename,
|
||||||
|
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
|
||||||
|
)
|
||||||
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def store_signed_document(file_storage, *, team_name, role, username, filename, mimetype=None):
|
||||||
|
"""File a signed copy under ``signés/<team>/<role>/<username>/<filename>``.
|
||||||
|
|
||||||
|
The root folder is already the signed-contracts folder, so no extra
|
||||||
|
``signed`` segment is added (the layout the club requested).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_storage: The uploaded FileStorage.
|
||||||
|
team_name: Name of the signing member's (primary) team.
|
||||||
|
role: The signing member's own role (player/coach/manager).
|
||||||
|
username: The signing member's username.
|
||||||
|
filename: Name of the signed file, e.g. ``player-v1.pdf``.
|
||||||
|
"""
|
||||||
|
if document_storage_backend() == 'local':
|
||||||
|
relative = os.path.join(CONTRACTS_DIR, team_name, role, username, filename)
|
||||||
|
return _save_locally(file_storage, relative)
|
||||||
|
|
||||||
|
parent = _drive_folder_for([team_name, role, username])
|
||||||
|
file_id = upload_google_drive_file(
|
||||||
|
stream=file_storage.stream,
|
||||||
|
filename=filename,
|
||||||
|
mimetype=mimetype or file_storage.mimetype or 'application/pdf',
|
||||||
|
parents=[parent],
|
||||||
|
)
|
||||||
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
def open_document(stored_path):
|
def open_document(stored_path):
|
||||||
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||||
if is_google_drive_path(stored_path):
|
if is_google_drive_path(stored_path):
|
||||||
|
|||||||
@@ -118,4 +118,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -113,6 +113,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('users.list_my_documents') }}" class="{% if request.endpoint and 'document' in request.endpoint %}active{% endif %}">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<span>{{ _('Documents') }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
|
||||||
<i class="fas fa-file-contract"></i>
|
<i class="fas fa-file-contract"></i>
|
||||||
@@ -173,7 +179,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content" id="page-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" action="
|
<form method="POST" data-ajax 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" action="{{ url_for('admin.toggle_tryouts') }}">
|
<form method="POST" data-ajax action="{{ url_for('admin.toggle_tryouts') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('admin.toggle_document_uploads') }}">
|
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_uploads') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('admin.start_season') }}">
|
<form method="POST" data-ajax action="{{ url_for('admin.start_season') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('admin.end_season') }}">
|
<form method="POST" data-ajax action="{{ url_for('admin.end_season') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ document.title }} - Admin{% endblock %}
|
||||||
|
{% block page_title %}{{ document.title }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / <a href="{{ url_for('admin.documents') }}">{{ _('Documents') }}</a> / {{ document.title }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set kind_labels = {
|
||||||
|
'player': _('Player contract'),
|
||||||
|
'coach': _('Coach contract'),
|
||||||
|
'manager': _('Manager contract'),
|
||||||
|
'team': _('Team agreement'),
|
||||||
|
'code_of_conduct': _('Code of conduct'),
|
||||||
|
'ethics_code': _('Ethics code'),
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-file-upload"></i> {{ _('Publish a new version') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{{ _('Type') }}: <strong>{{ kind_labels.get(document.kind, document.kind) }}</strong>
|
||||||
|
{% if document.requires_signature %} · {{ _('signed by the member') }}
|
||||||
|
{% else %} · {{ _('acknowledged by the member') }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin.publish_document_version', doc_id=document.id) }}" enctype="multipart/form-data" class="form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="version_file">{{ _('New version file (PDF)') }}</label>
|
||||||
|
<input type="file" id="version_file" name="version_file" accept=".pdf" required>
|
||||||
|
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">{{ _('Version notes (optional)') }}</label>
|
||||||
|
<textarea id="notes" name="notes" rows="2" placeholder="{{ _('What changed in this version?') }}"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('admin.documents') }}" class="btn btn-secondary">{{ _('Back') }}</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upload"></i> {{ _('Publish version') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-history"></i> {{ _('Versions') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Version') }}</th>
|
||||||
|
<th>{{ _('File') }}</th>
|
||||||
|
<th>{{ _('Notes') }}</th>
|
||||||
|
<th>{{ _('Published') }}</th>
|
||||||
|
<th>{{ _('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for version in versions %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ _('v%(number)s', number=version.version) }}
|
||||||
|
{% if document.current_version_id == version.id %}
|
||||||
|
<span class="badge badge-success">{{ _('Current') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ version.original_filename }}</td>
|
||||||
|
<td>{{ version.notes or '—' }}</td>
|
||||||
|
<td>{{ version.published_at.strftime('%Y-%m-%d %H:%M') if version.published_at else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin.download_document_version', version_id=version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-muted">{{ _('No versions published yet.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-list-check"></i> {{ _('Completion') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Member') }}</th>
|
||||||
|
<th>{{ _('Role') }}</th>
|
||||||
|
<th>{{ _('Status') }}</th>
|
||||||
|
<th>{{ _('Completed at') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for assignment, user in assignments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.role | capitalize }}</td>
|
||||||
|
<td>
|
||||||
|
{% if assignment.status == 'signed' %}
|
||||||
|
<span class="badge badge-success">{{ _('Signed') }}</span>
|
||||||
|
{% elif assignment.status == 'acknowledged' %}
|
||||||
|
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">{{ _('Pending') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ assignment.completed_at.strftime('%Y-%m-%d %H:%M') if assignment.completed_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-muted">
|
||||||
|
{{ _('No one is assigned yet. Publish a version first.') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ _('Documents') }} - Admin{% endblock %}
|
||||||
|
{% block page_title %}{{ _('Document management') }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('admin.dashboard') }}">Admin</a> / {{ _('Documents') }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set kind_labels = {
|
||||||
|
'player': _('Player contract'),
|
||||||
|
'coach': _('Coach contract'),
|
||||||
|
'manager': _('Manager contract'),
|
||||||
|
'team': _('Team agreement'),
|
||||||
|
'code_of_conduct': _('Code of conduct'),
|
||||||
|
'ethics_code': _('Ethics code'),
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-plus-circle"></i> {{ _('Create a document') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('admin.create_document') }}" class="form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">{{ _('Title') }}</label>
|
||||||
|
<input type="text" id="title" name="title" class="form-control" required
|
||||||
|
placeholder="{{ _('e.g. Player contract 2026-2027') }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="kind">{{ _('Type') }}</label>
|
||||||
|
<select id="kind" name="kind" class="form-select" required>
|
||||||
|
<option value="">{{ _('-- Select a type --') }}</option>
|
||||||
|
{% for kind in kinds %}
|
||||||
|
<option value="{{ kind }}">{{ kind_labels.get(kind, kind) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{{ _('The type decides who must complete the document and how.') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus"></i> {{ _('Create document') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-file-contract"></i> {{ _('Existing documents') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Title') }}</th>
|
||||||
|
<th>{{ _('Type') }}</th>
|
||||||
|
<th>{{ _('Version') }}</th>
|
||||||
|
<th>{{ _('Availability') }}</th>
|
||||||
|
<th>{{ _('Progress') }}</th>
|
||||||
|
<th>{{ _('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.document_detail', doc_id=document.id) }}">{{ document.title }}</a></td>
|
||||||
|
<td>{{ kind_labels.get(document.kind, document.kind) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if document.current_version %}
|
||||||
|
{{ _('v%(number)s', number=document.current_version.version) }}
|
||||||
|
{% else %}{{ _('None') }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if document.is_published %}
|
||||||
|
<span class="badge badge-success">{{ _('Downloadable') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">{{ _('Hidden') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set completed, pending = document.completion_counts() %}
|
||||||
|
<span class="badge badge-info">{{ _('%(completed)s done', completed=completed) }}</span>
|
||||||
|
<span class="badge badge-warning">{{ _('%(pending)s pending', pending=pending) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('admin.document_detail', doc_id=document.id) }}" class="btn btn-sm btn-primary" title="{{ _('Manage') }}">
|
||||||
|
<i class="fas fa-cog"></i> {{ _('Manage') }}
|
||||||
|
</a>
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('admin.toggle_document_published', doc_id=document.id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
{% if document.is_published %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary" title="{{ _('Hide') }}">
|
||||||
|
<i class="fas fa-eye-slash"></i> {{ _('Hide') }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Make downloadable') }}">
|
||||||
|
<i class="fas fa-eye"></i> {{ _('Publish') }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-muted">{{ _('No documents yet. Create one above.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
<form method="POST" data-ajax action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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 }}"/>
|
||||||
|
|||||||
@@ -137,12 +137,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
right: ''
|
||||||
},
|
},
|
||||||
events: '/matches/api/events',
|
events: '/matches/api/events',
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
showEventModal(info.event);
|
showEventModal(info.event);
|
||||||
},
|
},
|
||||||
|
eventClassNames: function(arg) {
|
||||||
|
return [eventClassName(arg.event)];
|
||||||
|
},
|
||||||
|
eventContent: function(arg) {
|
||||||
|
return renderEventContent(arg);
|
||||||
|
},
|
||||||
dateClick: function(info) {
|
dateClick: function(info) {
|
||||||
if (canScheduleMatches) {
|
if (canScheduleMatches) {
|
||||||
var dateStr = info.dateStr;
|
var dateStr = info.dateStr;
|
||||||
@@ -158,7 +164,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
slotMinTime: '12:00:00',
|
slotMinTime: '12:00:00',
|
||||||
slotMaxTime: '24:00:00'
|
slotMaxTime: '24:00:00',
|
||||||
|
slotEventOverlap: true,
|
||||||
|
dayMaxEvents: false,
|
||||||
|
nowIndicator: true,
|
||||||
|
height: 'auto'
|
||||||
});
|
});
|
||||||
calendar.render();
|
calendar.render();
|
||||||
|
|
||||||
@@ -177,6 +187,41 @@ function changeView(viewName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventClassName(event) {
|
||||||
|
var props = event.extendedProps || {};
|
||||||
|
if (props.type === 'team_match') { return 'fc-event--team-match'; }
|
||||||
|
if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; }
|
||||||
|
return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim';
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIcon(type, matchType) {
|
||||||
|
if (type === 'team_match') { return 'fa-trophy'; }
|
||||||
|
if (type === 'one_on_one') { return 'fa-user-friends'; }
|
||||||
|
if (matchType === 'team_vs_team') { return 'fa-shield-alt'; }
|
||||||
|
if (matchType === 'player_vs_player') { return 'fa-users'; }
|
||||||
|
return 'fa-gamepad';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEventContent(arg) {
|
||||||
|
// Built with DOM nodes + textContent: the title comes from a JSON endpoint
|
||||||
|
// and has never been HTML-escaped, so it must not reach innerHTML.
|
||||||
|
var props = arg.event.extendedProps || {};
|
||||||
|
var wrap = makeEl('div', 'fc-event-body');
|
||||||
|
|
||||||
|
var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type));
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
wrap.appendChild(icon);
|
||||||
|
|
||||||
|
var text = makeEl('span', 'fc-event-text');
|
||||||
|
if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) {
|
||||||
|
text.appendChild(makeEl('span', 'fc-event-time', arg.timeText));
|
||||||
|
}
|
||||||
|
text.appendChild(makeEl('span', 'fc-event-title', arg.event.title));
|
||||||
|
wrap.appendChild(text);
|
||||||
|
|
||||||
|
return { domNodes: [wrap] };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Create Event Modal ---
|
// --- Create Event Modal ---
|
||||||
function showCreateEventModal(dateStr) {
|
function showCreateEventModal(dateStr) {
|
||||||
document.getElementById('createEventDate').textContent = dateStr;
|
document.getElementById('createEventDate').textContent = dateStr;
|
||||||
@@ -346,6 +391,14 @@ 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" action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
|
<form method="POST" data-ajax action="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=player.id) }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set sort_column = sort_column | default('created_at') %}
|
{% set sort_column = sort_column | default('overall_score') %}
|
||||||
{% set sort_order = sort_order | default('desc') %}
|
{% set sort_order = sort_order | default('desc') %}
|
||||||
|
|
||||||
{% macro sort_link(column, label) %}
|
{% macro sort_link(column, label) %}
|
||||||
@@ -22,76 +22,111 @@
|
|||||||
</th>
|
</th>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% if current_user.role == 'admin' and player_scores %}
|
<div class="view-toggle mb-3">
|
||||||
<div class="stats-grid mb-4">
|
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-primary">
|
||||||
{% for pid, data in player_scores.items() %}
|
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||||
<div class="stat-card stat-card-sm">
|
</a>
|
||||||
<div class="stat-icon bg-info">
|
{% if first_player_id %}
|
||||||
<i class="fas fa-user"></i>
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=first_player_id) }}" class="btn btn-sm btn-outline">
|
||||||
</div>
|
<i class="fas fa-user"></i> {{ _('By player') }}
|
||||||
<div class="stat-info">
|
</a>
|
||||||
<h3>{{ data.player.username[:12] }}</h3>
|
{% endif %}
|
||||||
<p>Avg: <strong>{{ data.avg }}</strong> / {{ data.count }} evals</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-clipboard-list"></i>
|
<h3><i class="fas fa-clipboard-list"></i> {{ _('Player Evaluations') }}</h3>
|
||||||
{% if current_user.role == 'player' %}
|
|
||||||
My Evaluations
|
|
||||||
{% else %}
|
|
||||||
All Evaluations
|
|
||||||
{% endif %}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{{ sort_link('tryout', 'Tryout') }}
|
<th></th>
|
||||||
{{ sort_link('player', 'Player') }}
|
{{ sort_link('player', 'Player') }}
|
||||||
{{ sort_link('evaluator', 'Evaluator') }}
|
{{ sort_link('overall_score', 'Average') }}
|
||||||
{{ sort_link('mecanics_score', 'Mecanics') }}
|
{{ sort_link('count', 'Evaluations') }}
|
||||||
{{ sort_link('cohesion_score', 'Cohesion') }}
|
<th>{{ _('Open') }}</th>
|
||||||
{{ sort_link('communication_score', 'Communication') }}
|
|
||||||
{{ sort_link('gamesense_score', 'Gamesense') }}
|
|
||||||
{{ sort_link('versatility_score', 'Versatility') }}
|
|
||||||
{{ sort_link('discipline_score', 'Discipline') }}
|
|
||||||
{{ sort_link('analysis_score', 'Analysis') }}
|
|
||||||
{{ sort_link('sport_ethics_score', 'Sport Ethics') }}
|
|
||||||
{{ sort_link('mental_score', 'Mental') }}
|
|
||||||
{{ sort_link('overall_score', 'Overall') }}
|
|
||||||
{{ sort_link('position_recommendation', 'Position') }}
|
|
||||||
{{ sort_link('created_at', 'Date') }}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for eval in evaluations %}
|
{% for entry in players %}
|
||||||
<tr>
|
<tr class="eval-row" data-action="toggle-player-evals" data-target="evals-{{ entry.player.id }}">
|
||||||
<td>{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}</td>
|
<td><i class="fas fa-chevron-right eval-chevron"></i></td>
|
||||||
<td>{{ eval.player.username if eval.player else 'Deleted Player' }}</td>
|
<td>
|
||||||
<td>{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}</td>
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="player-link">
|
||||||
<td>{{ eval.mecanics_score or '-' }}</td>
|
{{ entry.player.username }}
|
||||||
<td>{{ eval.cohesion_score or '-' }}</td>
|
</a>
|
||||||
<td>{{ eval.communication_score or '-' }}</td>
|
</td>
|
||||||
<td>{{ eval.gamesense_score or '-' }}</td>
|
<td><span class="score">{{ entry.avg | round(1) if entry.avg is not none else '-' }}</span></td>
|
||||||
<td>{{ eval.versatility_score or '-' }}</td>
|
<td>{{ entry.count }}</td>
|
||||||
<td>{{ eval.discipline_score or '-' }}</td>
|
<td>
|
||||||
<td>{{ eval.analysis_score or '-' }}</td>
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
|
||||||
<td>{{ eval.sport_ethics_score or '-' }}</td>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
<td>{{ eval.mental_score or '-' }}</td>
|
</a>
|
||||||
<td><span class="score">{{ eval.overall_score or '-' }}</span></td>
|
</td>
|
||||||
<td><span class="badge badge-info">{{ eval.position_recommendation or 'N/A' }}</span></td>
|
</tr>
|
||||||
<td>{{ eval.created_at.strftime('%m/%d/%Y') }}</td>
|
<tr class="eval-detail hidden" id="evals-{{ entry.player.id }}">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="eval-detail-inner">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Evaluator') }}</th>
|
||||||
|
<th>{{ _('Tryout') }}</th>
|
||||||
|
{% for field, label in evaluation_criteria %}
|
||||||
|
<th>{{ label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>{{ _('Overall') }}</th>
|
||||||
|
<th>{{ _('Position') }}</th>
|
||||||
|
<th>{{ _('Date') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ev in entry.evaluations %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
||||||
|
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
||||||
|
{% for score in ev.scores %}
|
||||||
|
<td>{{ score if score is not none else '-' }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
|
||||||
|
<td>{{ ev.position or '-' }}</td>
|
||||||
|
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">{{ _('No evaluations for this player.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if entry.evaluations %}
|
||||||
|
{% set comment_evals = entry.evaluations | selectattr('comment') | list %}
|
||||||
|
<div class="eval-comments">
|
||||||
|
<h4 class="eval-comments-title"><i class="fas fa-comments"></i> {{ _('Comments') }}</h4>
|
||||||
|
{% if comment_evals %}
|
||||||
|
{% for ev in comment_evals %}
|
||||||
|
<article class="eval-comment">
|
||||||
|
<p class="eval-comment-text">{{ ev.comment }}</p>
|
||||||
|
<footer class="eval-comment-meta">
|
||||||
|
<span class="eval-comment-author">{{ _('Evaluator') }} : <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
|
||||||
|
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} · {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="eval-comments-empty">{{ _('No comments') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="15" class="text-center">
|
<td colspan="5" class="text-center">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-clipboard"></i>
|
<i class="fas fa-clipboard"></i>
|
||||||
<h3>{{ _('No evaluations yet') }}</h3>
|
<h3>{{ _('No evaluations yet') }}</h3>
|
||||||
@@ -106,3 +141,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<style>
|
||||||
|
.view-toggle { display: flex; gap: 8px; }
|
||||||
|
.eval-row { cursor: pointer; }
|
||||||
|
.eval-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||||
|
.eval-detail-inner { padding: 8px 4px; }
|
||||||
|
.eval-detail-inner .table { margin-bottom: 0; }
|
||||||
|
.player-nav { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.is-disabled { opacity: 0.5; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
function togglePlayerEvals(element, event) {
|
||||||
|
if (event.target.closest('a')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var detail = document.getElementById(element.getAttribute('data-target'));
|
||||||
|
if (!detail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var chevron = element.querySelector('.eval-chevron');
|
||||||
|
var isHidden = detail.classList.contains('hidden');
|
||||||
|
if (isHidden) {
|
||||||
|
detail.classList.remove('hidden');
|
||||||
|
if (chevron) chevron.classList.add('fa-rotate-90');
|
||||||
|
} else {
|
||||||
|
detail.classList.add('hidden');
|
||||||
|
if (chevron) chevron.classList.remove('fa-rotate-90');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerActions({ 'toggle-player-evals': togglePlayerEvals });
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
|
<form method="POST" data-ajax action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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">
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ _('My Documents') }} - UdeS team manager{% endblock %}
|
||||||
|
{% block page_title %}{{ _('My Documents') }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / {{ _('My Documents') }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set kind_labels = {
|
||||||
|
'player': _('Player contract'),
|
||||||
|
'coach': _('Coach contract'),
|
||||||
|
'manager': _('Manager contract'),
|
||||||
|
'team': _('Team agreement'),
|
||||||
|
'code_of_conduct': _('Code of conduct'),
|
||||||
|
'ethics_code': _('Ethics code'),
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-file-contract"></i> {{ _('Documents to sign or acknowledge') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if documents %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Document') }}</th>
|
||||||
|
<th>{{ _('Type') }}</th>
|
||||||
|
<th>{{ _('Version') }}</th>
|
||||||
|
<th>{{ _('Status') }}</th>
|
||||||
|
<th>{{ _('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.document.title }}</td>
|
||||||
|
<td>{{ kind_labels.get(entry.document.kind, entry.document.kind) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.document.current_version %}
|
||||||
|
{{ _('Version %(number)s', number=entry.document.current_version.version) }}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set assignment = entry.assignment %}
|
||||||
|
{% if assignment is not none and assignment.status == 'signed' %}
|
||||||
|
<span class="badge badge-success">{{ _('Signed') }}</span>
|
||||||
|
{% elif assignment is not none and assignment.status == 'acknowledged' %}
|
||||||
|
<span class="badge badge-success">{{ _('Acknowledged') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">{{ _('Pending') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
{% if entry.document.current_version %}
|
||||||
|
<a href="{{ url_for('users.download_document', version_id=entry.document.current_version.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
|
||||||
|
<i class="fas fa-download"></i> {{ _('Download') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if assignment is not none and assignment.status == 'pending' %}
|
||||||
|
{% if entry.document.requires_signature %}
|
||||||
|
<button class="btn btn-sm btn-warning" data-action="show-sign-document" data-assignment-id="{{ assignment.id }}" title="{{ _('Return signed') }}">
|
||||||
|
<i class="fas fa-upload"></i> {{ _('Sign') }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('users.acknowledge_document', assignment_id=assignment.id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-success" data-confirm="{{ _('Confirm that you have read and understood this document') }}">
|
||||||
|
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% elif not teams_progress %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-file-contract"></i>
|
||||||
|
<h4>{{ _('No documents for now') }}</h4>
|
||||||
|
<p>{{ _('Nothing has been published for you yet. Check back later.') }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if teams_progress %}
|
||||||
|
{% for block in teams_progress %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-users"></i> {{ _('Team progress') }} — {{ block.team.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Member') }}</th>
|
||||||
|
<th>{{ _('Role') }}</th>
|
||||||
|
{% for doc in block.documents %}<th>{{ doc.title }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in block.members %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ member.user.username }}</td>
|
||||||
|
<td>{{ member.user.role | capitalize }}</td>
|
||||||
|
{% for doc in block.documents %}
|
||||||
|
{% set status = member.statuses.get(doc.id) %}
|
||||||
|
<td>
|
||||||
|
{% if status == 'signed' %}<span class="badge badge-success">{{ _('Signed') }}</span>
|
||||||
|
{% elif status == 'acknowledged' %}<span class="badge badge-success">{{ _('Acknowledged') }}</span>
|
||||||
|
{% elif status == 'pending' %}<span class="badge badge-warning">{{ _('Pending') }}</span>
|
||||||
|
{% else %}<span class="text-muted">{{ _('Not required') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Upload signed document modal -->
|
||||||
|
<div id="signDocumentModal" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-action="hide-sign-document"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ _('Upload Signed Document') }}</h3>
|
||||||
|
<button class="modal-close" data-action="hide-sign-document">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="signDocumentForm" method="POST" enctype="multipart/form-data" class="form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="signed_file">{{ _('Select Signed Document File') }}</label>
|
||||||
|
<input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||||
|
<small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" data-action="hide-sign-document">{{ _('Cancel') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ _('Upload Signed Document') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
function showSignDocumentForm(assignmentId) {
|
||||||
|
document.getElementById('signDocumentForm').action = '/users/documents/assignments/' + assignmentId + '/sign';
|
||||||
|
document.getElementById('signDocumentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSignDocumentForm() {
|
||||||
|
document.getElementById('signDocumentModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
registerActions({
|
||||||
|
'show-sign-document': function (element) {
|
||||||
|
showSignDocumentForm(element.getAttribute('data-assignment-id'));
|
||||||
|
},
|
||||||
|
'hide-sign-document': hideSignDocumentForm,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -192,7 +192,7 @@ function togglePresence(btn) {
|
|||||||
btn.classList.add('btn-outline');
|
btn.classList.add('btn-outline');
|
||||||
btn.innerHTML = 'Confirm';
|
btn.innerHTML = 'Confirm';
|
||||||
}
|
}
|
||||||
location.reload();
|
window.TT.refreshPage(window.location.pathname);
|
||||||
})
|
})
|
||||||
.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" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
|
<form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
|
<form method="POST" data-ajax action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
|
<form method="POST" data-ajax action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="">
|
<form id="rejectForm" method="POST" data-ajax 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">
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if coach %}
|
{% if coach %}
|
||||||
<form method="POST" action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
|
<form method="POST" data-ajax action="{{ url_for('users.one_on_one') }}" class="form" id="oneOnOneForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('users.manage_personal_notes') }}" class="form">
|
<form method="POST" data-ajax action="{{ url_for('users.manage_personal_notes') }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}{{ player.username }} - {{ _('Evaluations') }} - UdeS team manager{% endblock %}
|
||||||
|
{% block page_title %}{{ _('Player Evaluations') }}{% endblock %}
|
||||||
|
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('evaluations.list_evaluations') }}">{{ _('Evaluations') }}</a> / {{ player.username }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="view-toggle mb-3">
|
||||||
|
<a href="{{ url_for('evaluations.list_evaluations') }}" class="btn btn-sm btn-outline">
|
||||||
|
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=player.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-user"></i> {{ _('By player') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-user"></i> {{ player.username }}</h3>
|
||||||
|
<div class="player-nav">
|
||||||
|
{% if prev_player %}
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=prev_player.id) }}" class="btn btn-sm btn-secondary" title="{{ prev_player.username }}">
|
||||||
|
<i class="fas fa-chevron-left"></i> {{ prev_player.username }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-left"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_player %}
|
||||||
|
<a href="{{ url_for('evaluations.player_evaluations', player_id=next_player.id) }}" class="btn btn-sm btn-secondary" title="{{ next_player.username }}">
|
||||||
|
{{ next_player.username }} <i class="fas fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-sm btn-secondary is-disabled" aria-disabled="true"><i class="fas fa-chevron-right"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stats-grid mb-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-info"><i class="fas fa-star"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ combined_average | round(1) if combined_average is not none else '-' }}</h3>
|
||||||
|
<p>{{ _('Combined average') }} · {{ evaluation_count }} {{ _('evaluations') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for label, avg in criterion_averages %}
|
||||||
|
<div class="stat-card stat-card-sm">
|
||||||
|
<div class="stat-icon bg-info"><i class="fas fa-chart-bar"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ avg | round(1) if avg is not none else '-' }}</h3>
|
||||||
|
<p>{{ label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Evaluator') }}</th>
|
||||||
|
<th>{{ _('Tryout') }}</th>
|
||||||
|
{% for field, label in evaluation_criteria %}
|
||||||
|
<th>{{ label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>{{ _('Overall') }}</th>
|
||||||
|
<th>{{ _('Position') }}</th>
|
||||||
|
<th>{{ _('Date') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ev in evaluations %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</td>
|
||||||
|
<td>{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}</td>
|
||||||
|
{% for score in ev.scores %}
|
||||||
|
<td>{{ score if score is not none else '-' }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
|
||||||
|
<td>{{ ev.position or '-' }}</td>
|
||||||
|
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ evaluation_criteria | length + 5 }}" class="text-center">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-clipboard"></i>
|
||||||
|
<h3>{{ _('No evaluations for this player yet') }}</h3>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if evaluations %}
|
||||||
|
{% set comment_evals = evaluations | selectattr('comment') | list %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if comment_evals %}
|
||||||
|
<div class="eval-comments">
|
||||||
|
{% for ev in comment_evals %}
|
||||||
|
<article class="eval-comment">
|
||||||
|
<p class="eval-comment-text">{{ ev.comment }}</p>
|
||||||
|
<footer class="eval-comment-meta">
|
||||||
|
<span class="eval-comment-author">{{ _('Evaluator') }} : <strong>{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}</strong></span>
|
||||||
|
<span class="eval-comment-context">{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }} · {{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
<h3>{{ _('No comments') }}</h3>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<style>
|
||||||
|
.view-toggle { display: flex; gap: 8px; }
|
||||||
|
.player-nav { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.is-disabled { opacity: 0.5; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -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" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
|
<form method="POST" data-ajax action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
|
<form method="POST" data-ajax action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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';
|
||||||
}
|
}
|
||||||
// Reload to update presence bar
|
// Update in place to preserve state
|
||||||
location.reload();
|
window.TT.refreshPage(window.location.pathname);
|
||||||
})
|
})
|
||||||
.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" action="{{ url_for('users.manage_team_notes') }}" class="form">
|
<form method="POST" data-ajax action="{{ url_for('users.manage_team_notes') }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('teams.create_team') }}" class="form">
|
<form method="POST" data-ajax action="{{ url_for('teams.create_team') }}" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}">
|
<form method="POST" data-ajax action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
|
<form method="POST" data-ajax action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="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" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
|
<form method="POST" data-ajax action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="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" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
<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" action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
|
<form method="POST" data-ajax action="{{ url_for('teams.add_player', team_id=team.id) }}" class="form-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="" class="form">
|
<form id="editTeamForm" method="POST" data-ajax action="" class="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="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" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
|
<form method="POST" data-ajax action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
|
<form method="POST" data-ajax action="{{ url_for('tryouts.register_for_tryout', tryout_id=tryout.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
|
<form method="POST" data-ajax action="{{ url_for('tryouts.register_player', tryout_id=tryout.id) }}" class="form-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
|
<form method="POST" data-ajax action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
|
<form method="POST" data-ajax action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<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" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
|
<form method="POST" data-ajax action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="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,7 +249,14 @@
|
|||||||
|
|
||||||
{% for team in team_data %}
|
{% for team in team_data %}
|
||||||
<div class="team-card mb-3">
|
<div class="team-card mb-3">
|
||||||
<h4 class="team-name">{{ team.team.name }}</h4>
|
<div class="team-card-header">
|
||||||
|
<h4 class="team-name">{{ team.team.name }}</h4>
|
||||||
|
{% if can_edit and not tryout.is_ended %}
|
||||||
|
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
|
||||||
|
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<ul class="team-members">
|
<ul class="team-members">
|
||||||
{% for member in team.members %}
|
{% for member in team.members %}
|
||||||
<li>
|
<li>
|
||||||
@@ -263,32 +270,96 @@
|
|||||||
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if can_edit and not tryout.is_ended and registered_players %}
|
|
||||||
{% set positions = game_positions.get(tryout.game, []) %}
|
|
||||||
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
||||||
<select name="player_id" class="form-select" required>
|
|
||||||
<option value="">{{ _('Select player...') }}</option>
|
|
||||||
{% for p in registered_players %}
|
|
||||||
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
|
||||||
<option value="{{ p.id }}">{{ p.username }}</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% if positions %}
|
|
||||||
<select name="position" class="form-select mx-2">
|
|
||||||
<option value="">{{ _('-- Select Position --') }}</option>
|
|
||||||
{% for pos in positions %}
|
|
||||||
<option value="{{ pos }}">{{ pos }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% else %}
|
|
||||||
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2">
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit and not tryout.is_ended %}
|
||||||
|
{% set positions = game_positions.get(tryout.game, []) %}
|
||||||
|
<!-- Edit Team Modal -->
|
||||||
|
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
|
||||||
|
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Rename -->
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<label for="renameInput{{ team.team.id }}" class="mr-2" style="white-space:nowrap;">{{ _('Team name') }}</label>
|
||||||
|
<input type="text" id="renameInput{{ team.team.id }}" name="team_name" value="{{ team.team.name }}" required class="form-input" style="flex:1;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary ml-2">{{ _('Rename') }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<!-- Roster -->
|
||||||
|
<h5 class="mb-2"><i class="fas fa-users"></i> {{ _('Roster') }}</h5>
|
||||||
|
<div class="roster-rows">
|
||||||
|
{% for member in team.members %}
|
||||||
|
<div class="roster-row">
|
||||||
|
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
|
||||||
|
<span class="roster-player-name">{{ member.player.username }}</span>
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('tryouts.update_member_position', tryout_id=tryout.id, team_id=team.team.id, member_id=member.member_id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<select name="position" class="form-select roster-role-select" data-submit-on-change>
|
||||||
|
<option value="">{{ _('No role') }}</option>
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}" {% if member.position == pos %}selected{% endif %}>{{ pos }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('tryouts.remove_from_team', tryout_id=tryout.id, team_id=team.team.id, player_id=member.player_id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from this team?', player=member.player.username) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger roster-remove-btn" title="{{ _('Remove from team') }}">
|
||||||
|
<i class="fas fa-user-minus"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">{{ _('No players assigned yet.') }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if registered_players %}
|
||||||
|
<hr class="section-divider">
|
||||||
|
<h5 class="mb-2"><i class="fas fa-user-plus"></i> {{ _('Add Player') }}</h5>
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<select name="player_id" class="form-select" required style="flex:1;">
|
||||||
|
<option value="">{{ _('Select player...') }}</option>
|
||||||
|
{% for p in registered_players %}
|
||||||
|
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
||||||
|
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if positions %}
|
||||||
|
<select name="position" class="form-select mx-2">
|
||||||
|
<option value="">{{ _('-- Select Position --') }}</option>
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}">{{ pos }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="section-divider" style="border-color: var(--danger);">
|
||||||
|
<div class="text-center">
|
||||||
|
<form method="POST" data-ajax action="{{ url_for('tryouts.delete_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="inline-form" data-confirm="{{ _('Delete this team? Players already in the team will be removed and matches referencing it will be unlinked.') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% 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 %}
|
||||||
@@ -458,6 +529,12 @@
|
|||||||
<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') }}
|
||||||
@@ -568,8 +645,56 @@
|
|||||||
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'); }
|
||||||
|
|
||||||
@@ -592,41 +717,58 @@ 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('✅', '⏳');
|
||||||
}
|
}
|
||||||
location.reload();
|
window.TT.refreshPage(window.location.pathname);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
console.error('Error toggling tryout presence:', err);
|
console.error('Error toggling tryout presence:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini calendar for matches
|
// Mini calendar for matches. Re-initialised on fragment:updated so the
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// calendar survives AJAX edits that swap the page content in place.
|
||||||
|
var miniCalendar = null;
|
||||||
|
|
||||||
|
function initMiniCalendar() {
|
||||||
var miniCalendarEl = document.getElementById('mini-calendar');
|
var miniCalendarEl = document.getElementById('mini-calendar');
|
||||||
if (miniCalendarEl) {
|
if (!miniCalendarEl) {
|
||||||
var miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
|
return;
|
||||||
initialView: 'dayGridMonth',
|
|
||||||
headerToolbar: {
|
|
||||||
left: 'prev,next',
|
|
||||||
center: 'title',
|
|
||||||
right: 'dayGridMonth,timeGridWeek'
|
|
||||||
},
|
|
||||||
events: '/matches/api/events/{{ tryout.id }}',
|
|
||||||
height: '300px',
|
|
||||||
eventClick: function(info) {
|
|
||||||
if (info.event.extendedProps.match_id) {
|
|
||||||
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
miniCalendar.render();
|
|
||||||
}
|
}
|
||||||
});
|
if (miniCalendar) {
|
||||||
|
miniCalendar.destroy();
|
||||||
|
miniCalendar = null;
|
||||||
|
}
|
||||||
|
miniCalendar = new FullCalendar.Calendar(miniCalendarEl, {
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek'
|
||||||
|
},
|
||||||
|
events: '/matches/api/events/{{ tryout.id }}',
|
||||||
|
height: '300px',
|
||||||
|
eventClick: function(info) {
|
||||||
|
if (info.event.extendedProps.match_id) {
|
||||||
|
window.location.href = '/matches/' + info.event.extendedProps.match_id + '/edit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
miniCalendar.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initMiniCalendar);
|
||||||
|
document.addEventListener('fragment:updated', initMiniCalendar);
|
||||||
|
|
||||||
// Behaviours declared in the markup, dispatched by the delegated listener
|
// 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'),
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+33
-1
@@ -23,7 +23,7 @@ from marshmallow import (
|
|||||||
validates_schema,
|
validates_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
from app.models import DOCUMENT_KINDS, ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Custom Validators
|
# Custom Validators
|
||||||
@@ -605,6 +605,18 @@ class TryoutTeamMemberSchema(StripMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutTeamMemberPositionSchema(StripMixin):
|
||||||
|
"""Update a team member's in-game position (role)."""
|
||||||
|
|
||||||
|
position = fields.String(
|
||||||
|
load_default='',
|
||||||
|
validate=validate.Length(
|
||||||
|
max=50,
|
||||||
|
error=_l('Position must be 50 characters or less.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NoteContentSchema(StripMixin):
|
class NoteContentSchema(StripMixin):
|
||||||
"""Bounded text stored as a team or personal coaching note."""
|
"""Bounded text stored as a team or personal coaching note."""
|
||||||
|
|
||||||
@@ -1049,3 +1061,23 @@ class EvaluationSchema(StripMixin):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
load_default=None,
|
load_default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCreateSchema(StripMixin):
|
||||||
|
"""Validate the admin form that creates a new document.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
title: Human-readable name of the document (e.g. ``Contrat joueur``).
|
||||||
|
kind: One of DOCUMENT_KINDS. The kind decides who completes the
|
||||||
|
document and whether completion is a signature or an
|
||||||
|
acknowledgment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
title = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.Length(min=1, max=255, error=_l('Title is required.')),
|
||||||
|
)
|
||||||
|
kind = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.OneOf(DOCUMENT_KINDS, error=_l('That document type is not recognised.')),
|
||||||
|
)
|
||||||
|
|||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
"""Database seeding script for Team Tryouts application.
|
|
||||||
|
|
||||||
Wipes all data and creates an admin user. Run manually with:
|
|
||||||
python -m app.supporting_scripts.seed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.extensions import db, hash_password
|
|
||||||
from app.models import Admin
|
|
||||||
|
|
||||||
|
|
||||||
def seed_database():
|
|
||||||
"""Delete all existing data and create a single admin account."""
|
|
||||||
|
|
||||||
# Delete all data in FK-safe order
|
|
||||||
print("Deleting existing data...")
|
|
||||||
tables = [
|
|
||||||
'one_on_one_requests',
|
|
||||||
'player_disponibilities',
|
|
||||||
'coach_availabilities',
|
|
||||||
'match_participants',
|
|
||||||
'team_match_participants',
|
|
||||||
'matches',
|
|
||||||
'team_matches',
|
|
||||||
'team_players',
|
|
||||||
'team_members',
|
|
||||||
'teams',
|
|
||||||
'evaluations',
|
|
||||||
'tryout_registrations',
|
|
||||||
'tryouts',
|
|
||||||
'org_team_coaches',
|
|
||||||
'org_team_managers',
|
|
||||||
'team_notes',
|
|
||||||
'personal_notes',
|
|
||||||
'user_gamertags',
|
|
||||||
'org_teams',
|
|
||||||
'users',
|
|
||||||
]
|
|
||||||
for table in tables:
|
|
||||||
db.session.execute(db.text(f'DELETE FROM {table}'))
|
|
||||||
db.session.commit()
|
|
||||||
print("[OK] All data deleted.")
|
|
||||||
|
|
||||||
# Create the admin user
|
|
||||||
admin = Admin(
|
|
||||||
username='admin',
|
|
||||||
password_hash=hash_password('password'),
|
|
||||||
role='admin',
|
|
||||||
full_name='Admin',
|
|
||||||
email='[email protected]',
|
|
||||||
)
|
|
||||||
db.session.add(admin)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
print("[OK] Created admin user")
|
|
||||||
print("\n=== Admin Credentials ===")
|
|
||||||
print("Username: admin")
|
|
||||||
print("Password: password")
|
|
||||||
print("\n[SUCCESS] Database reset and seeded!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from app.app import create_app
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
with app.app_context():
|
|
||||||
seed_database()
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Migration: Create indexes on date columns used by the 18:00 daily reminder.
|
||||||
|
|
||||||
|
Run this script to add indexes on the date columns that the Discord bot's
|
||||||
|
daily reminder queries (Match.date, TeamMatch.date, Tryout.date,
|
||||||
|
OneOnOneRequest.date). The app uses create_all() for new tables, but existing
|
||||||
|
production databases need these added explicitly.
|
||||||
|
|
||||||
|
Usage: python migrations/add_reminder_date_indexes.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
indexes = [
|
||||||
|
('ix_matches_date', 'matches', 'date'),
|
||||||
|
('ix_team_matches_date', 'team_matches', 'date'),
|
||||||
|
('ix_tryouts_date', 'tryouts', 'date'),
|
||||||
|
('ix_one_on_one_requests_date', 'one_on_one_requests', 'date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for index_name, table, column in indexes:
|
||||||
|
db.session.execute(text(
|
||||||
|
f'CREATE INDEX IF NOT EXISTS {index_name} ON {table} ({column})'
|
||||||
|
))
|
||||||
|
print(f'Index {index_name} on {table}({column}) ensured.')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print('Migration complete.')
|
||||||
@@ -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,7 +41,9 @@ class TestBackupCreate:
|
|||||||
|
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/admin/backup/create', data={'notes': 'test backup'}, follow_redirects=False,
|
'/admin/backup/create',
|
||||||
|
data={'notes': 'test backup'},
|
||||||
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert _redirected(response)
|
assert _redirected(response)
|
||||||
|
|
||||||
@@ -61,7 +63,9 @@ class TestBackupCreate:
|
|||||||
|
|
||||||
as_role('admin')
|
as_role('admin')
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/admin/backup/create', data={}, follow_redirects=True,
|
'/admin/backup/create',
|
||||||
|
data={},
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'Backup failed' in html
|
assert 'Backup failed' in html
|
||||||
@@ -135,7 +139,8 @@ class TestBackupDownload:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
f'/admin/backup/{record_id}/download', follow_redirects=True,
|
f'/admin/backup/{record_id}/download',
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'missing from disk' in html.lower()
|
assert 'missing from disk' in html.lower()
|
||||||
@@ -162,7 +167,8 @@ class TestBackupDelete:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/admin/backup/{record_id}/delete', follow_redirects=False,
|
f'/admin/backup/{record_id}/delete',
|
||||||
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert _redirected(response)
|
assert _redirected(response)
|
||||||
|
|
||||||
@@ -214,14 +220,13 @@ class TestBackupRestore:
|
|||||||
record_id = record.id
|
record_id = record.id
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/admin/backup/{record_id}/restore', follow_redirects=True,
|
f'/admin/backup/{record_id}/restore',
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
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(
|
def test_restore_creates_safety_backup_first(self, app, client, as_role, monkeypatch, tmp_path):
|
||||||
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.
|
||||||
@@ -282,4 +287,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,7 +154,9 @@ class TestSeasonGateEnforcement:
|
|||||||
|
|
||||||
as_role(role)
|
as_role(role)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/team-matches/{match_id}/delete', data={}, follow_redirects=True,
|
f'/team-matches/{match_id}/delete',
|
||||||
|
data={},
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
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()
|
||||||
@@ -167,4 +169,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,9 +76,7 @@ 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(
|
def test_non_admin_cannot_edit_tryout_when_closed(self, app, client, as_role, make_user, role):
|
||||||
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(
|
||||||
@@ -119,7 +117,9 @@ class TestTryoutGateEnforcement:
|
|||||||
|
|
||||||
as_role(role)
|
as_role(role)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/tryouts/{tryout_id}/delete', data={}, follow_redirects=True,
|
f'/tryouts/{tryout_id}/delete',
|
||||||
|
data={},
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
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,7 +9,12 @@ import pytest
|
|||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AuditLog, BackupRecord, OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
AuditLog,
|
||||||
|
BackupRecord,
|
||||||
|
OrgTeam,
|
||||||
|
TeamMatch,
|
||||||
|
TeamMatchParticipant,
|
||||||
|
TeamPlayer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +26,9 @@ class TestWipeConfirmation:
|
|||||||
def test_wipe_without_confirmation_fails(self, app, client, as_role):
|
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', data={}, follow_redirects=True,
|
'/admin/teams/wipe',
|
||||||
|
data={},
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'WIPE' in html
|
assert 'WIPE' in html
|
||||||
@@ -29,7 +36,9 @@ 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', data={'confirm': 'yes'}, follow_redirects=True,
|
'/admin/teams/wipe',
|
||||||
|
data={'confirm': 'yes'},
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
html = response.data.decode()
|
html = response.data.decode()
|
||||||
assert 'WIPE' in html
|
assert 'WIPE' in html
|
||||||
@@ -116,4 +125,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
|
||||||
|
|||||||
+103
-3
@@ -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)['date'] == '2030-05-02'
|
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
||||||
|
|
||||||
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)['date'] == '2030-05-02'
|
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
||||||
|
|
||||||
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())['date'] == '2030-05-02'
|
assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
|
||||||
|
|
||||||
def test_the_viewers_own_participation_still_comes_back(
|
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,3 +258,103 @@ 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'
|
||||||
|
|||||||
@@ -179,3 +179,61 @@ class TestThroughTheForm:
|
|||||||
evaluation = Evaluation.query.one()
|
evaluation = Evaluation.query.one()
|
||||||
assert evaluation.mecanics_score is None
|
assert evaluation.mecanics_score is None
|
||||||
assert evaluation.overall_score == 4
|
assert evaluation.overall_score == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchEvaluate:
|
||||||
|
"""The batch page drives the same two helpers the single-player form does,
|
||||||
|
through ``{field}_{player_id}`` names. The helpers were lost once, in a
|
||||||
|
refactor, and the page 500'd on every POST until someone noticed."""
|
||||||
|
|
||||||
|
def _submit(self, client, setup, **fields):
|
||||||
|
data = {'player_ids': [str(setup['player_id'])]}
|
||||||
|
data.update(fields)
|
||||||
|
return client.post(
|
||||||
|
f'/evaluations/{setup["tryout_id"]}/batch',
|
||||||
|
data=data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_valid_batch_evaluation_is_stored(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client,
|
||||||
|
evaluation_setup,
|
||||||
|
**{
|
||||||
|
f'mecanics_score_{pid}': '8',
|
||||||
|
f'mental_score_{pid}': '6',
|
||||||
|
f'comments_{pid}': 'Solid.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
evaluation = Evaluation.query.one()
|
||||||
|
assert evaluation.mecanics_score == 8
|
||||||
|
assert evaluation.overall_score == 7
|
||||||
|
assert evaluation.comments == 'Solid.'
|
||||||
|
assert evaluation.position_recommendation is None
|
||||||
|
|
||||||
|
def test_an_out_of_range_batch_score_stores_nothing(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client,
|
||||||
|
evaluation_setup,
|
||||||
|
**{f'mecanics_score_{pid}': '11', f'mental_score_{pid}': '6'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
assert Evaluation.query.count() == 0
|
||||||
|
|
||||||
|
def test_a_second_batch_submission_replaces_the_first(self, app, client, evaluation_setup):
|
||||||
|
pid = evaluation_setup['player_id']
|
||||||
|
self._submit(
|
||||||
|
client, evaluation_setup, **{f'mecanics_score_{pid}': '8', f'mental_score_{pid}': '4'}
|
||||||
|
)
|
||||||
|
self._submit(
|
||||||
|
client, evaluation_setup, **{f'mecanics_score_{pid}': '4', f'mental_score_{pid}': '4'}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
evaluation = Evaluation.query.one()
|
||||||
|
assert evaluation.overall_score == 4
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Player-specific evaluation views (the "By player" toggle and its page).
|
||||||
|
|
||||||
|
These pages regroup the flat evaluation table by player: one row per player in
|
||||||
|
the table view, and a dedicated page per player with left/right navigation and
|
||||||
|
per-criterion averages. Visibility follows the viewer's role — managers and
|
||||||
|
coaches see staff-authored evaluations inside their own tryouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Evaluation, Tryout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def staff_setup(app, as_role, make_user):
|
||||||
|
"""A coach who manages one tryout, plus staff, a player, and a foreign tryout."""
|
||||||
|
coach_id = as_role('coach', username='viewer_coach')
|
||||||
|
manager_id = make_user('manager', username='other_manager')
|
||||||
|
admin_id = make_user('admin', username='the_admin')
|
||||||
|
player_id = make_user('player', username='the_player')
|
||||||
|
outsider_id = make_user('coach', username='outsider_coach')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Spring',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 4, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
other_tryout = Tryout(
|
||||||
|
title='Other',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 5, 1),
|
||||||
|
created_by=outsider_id,
|
||||||
|
)
|
||||||
|
db.session.add(other_tryout)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'coach_id': coach_id,
|
||||||
|
'manager_id': manager_id,
|
||||||
|
'admin_id': admin_id,
|
||||||
|
'player_id': player_id,
|
||||||
|
'tryout_id': tryout.id,
|
||||||
|
'other_tryout_id': other_tryout.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisibility:
|
||||||
|
def test_coach_sees_staff_evaluations_only_in_their_tryout(self, app, client, staff_setup):
|
||||||
|
with app.app_context():
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=staff_setup['tryout_id'],
|
||||||
|
player_id=staff_setup['player_id'],
|
||||||
|
evaluator_id=staff_setup['manager_id'],
|
||||||
|
overall_score=7,
|
||||||
|
comments='manager in own tryout',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=staff_setup['tryout_id'],
|
||||||
|
player_id=staff_setup['player_id'],
|
||||||
|
evaluator_id=staff_setup['admin_id'],
|
||||||
|
overall_score=5,
|
||||||
|
comments='admin authored',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=staff_setup['other_tryout_id'],
|
||||||
|
player_id=staff_setup['player_id'],
|
||||||
|
evaluator_id=staff_setup['manager_id'],
|
||||||
|
overall_score=9,
|
||||||
|
comments='manager other tryout',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get(f"/evaluations/player/{staff_setup['player_id']}").get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'manager in own tryout' in body
|
||||||
|
assert 'admin authored' not in body
|
||||||
|
assert 'manager other tryout' not in body
|
||||||
|
|
||||||
|
def test_admin_sees_every_evaluation(self, app, client, as_role, make_user):
|
||||||
|
admin_id = as_role('admin', username='boss')
|
||||||
|
coach_id = make_user('coach', username='some_coach')
|
||||||
|
player_id = make_user('player', username='star')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='T',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=admin_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=coach_id,
|
||||||
|
overall_score=6,
|
||||||
|
comments='coach comment',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=admin_id,
|
||||||
|
overall_score=4,
|
||||||
|
comments='admin comment',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'coach comment' in body
|
||||||
|
assert 'admin comment' in body
|
||||||
|
|
||||||
|
def test_a_player_cannot_open_the_player_page(self, client, as_role):
|
||||||
|
player_id = as_role('player')
|
||||||
|
response = client.get(f'/evaluations/player/{player_id}', follow_redirects=False)
|
||||||
|
assert response.status_code in (301, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAggregates:
|
||||||
|
def test_combined_and_per_criterion_averages(self, app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach', username='agg_coach')
|
||||||
|
coach2_id = make_user('coach', username='agg_coach2')
|
||||||
|
player_id = make_user('player', username='agg_player')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Agg',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
first = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach_id)
|
||||||
|
first.apply_scores({'mecanics_score': 8, 'mental_score': 6})
|
||||||
|
second = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach2_id)
|
||||||
|
second.apply_scores({'mecanics_score': 4, 'mental_score': 8})
|
||||||
|
db.session.add_all([first, second])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
# overall: (7 + 6) / 2 = 6.5; mecanics: 6.0; mental: 7.0.
|
||||||
|
assert '6.5' in body
|
||||||
|
assert '6.0' in body
|
||||||
|
assert '7.0' in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestNavigation:
|
||||||
|
def test_players_are_ordered_best_first_with_arrows(self, app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach', username='order_coach')
|
||||||
|
high = make_user('player', username='zzz_high')
|
||||||
|
mid = make_user('player', username='aaa_mid')
|
||||||
|
low = make_user('player', username='mmm_low')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Order',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
for player_id, score in [(high, 8), (mid, 5), (low, 3)]:
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=coach_id,
|
||||||
|
overall_score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
best = client.get(f'/evaluations/player/{high}').get_data(as_text=True)
|
||||||
|
middle = client.get(f'/evaluations/player/{mid}').get_data(as_text=True)
|
||||||
|
worst = client.get(f'/evaluations/player/{low}').get_data(as_text=True)
|
||||||
|
|
||||||
|
# Best: no previous (left arrow disabled), next is the middle player.
|
||||||
|
assert 'aria-disabled="true"' in best
|
||||||
|
assert f'/evaluations/player/{mid}' in best
|
||||||
|
assert f'/evaluations/player/{low}' not in best
|
||||||
|
|
||||||
|
# Middle: both arrows active, pointing at the neighbours.
|
||||||
|
assert f'/evaluations/player/{high}' in middle
|
||||||
|
assert f'/evaluations/player/{low}' in middle
|
||||||
|
assert 'aria-disabled="true"' not in middle
|
||||||
|
|
||||||
|
# Worst: no next (right arrow disabled), previous is the middle player.
|
||||||
|
assert 'aria-disabled="true"' in worst
|
||||||
|
assert f'/evaluations/player/{mid}' in worst
|
||||||
|
assert f'/evaluations/player/{high}' not in worst
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableView:
|
||||||
|
def test_table_groups_by_player_and_keeps_the_comment(self, app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach', username='table_coach')
|
||||||
|
player_id = make_user('player', username='table_player')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='T',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 1, 1),
|
||||||
|
created_by=coach_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=coach_id,
|
||||||
|
overall_score=7,
|
||||||
|
comments='a distinctive comment',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
body = client.get('/evaluations').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'table_player' in body
|
||||||
|
assert f'/evaluations/player/{player_id}' in body
|
||||||
|
assert 'a distinctive comment' in body
|
||||||
@@ -69,6 +69,21 @@ class TestNotifying:
|
|||||||
assert [call['reference_id'] for call in sent] == [11, 22]
|
assert [call['reference_id'] for call in sent] == [11, 22]
|
||||||
assert {call['event_date'] for call in sent} == {'2030-04-01'}
|
assert {call['event_date'] for call in sent} == {'2030-04-01'}
|
||||||
assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'}
|
assert {call['event_time'] for call in sent} == {'07:00 PM - 09:00 PM'}
|
||||||
|
assert {call['change_kind'] for call in sent} == {'scheduled'}
|
||||||
|
|
||||||
|
def test_modified_kind_is_forwarded(self, sent):
|
||||||
|
"""A modified notification carries 'modified' to the bot."""
|
||||||
|
scheduling.notify_participants(
|
||||||
|
title='Scrim',
|
||||||
|
date=date(2030, 4, 1),
|
||||||
|
start_time=time(19, 0),
|
||||||
|
end_time=time(21, 0),
|
||||||
|
participants=[(1, 11)],
|
||||||
|
fallback_id=99,
|
||||||
|
change_kind='modified',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sent[0]['change_kind'] == 'modified'
|
||||||
|
|
||||||
def test_a_participant_without_a_row_falls_back_to_the_match(self, sent):
|
def test_a_participant_without_a_row_falls_back_to_the_match(self, sent):
|
||||||
"""A Discord reaction resolves the reference back to a record; the
|
"""A Discord reaction resolves the reference back to a record; the
|
||||||
|
|||||||
Reference in New Issue
Block a user