Compare commits
4
Commits
bfb25f4d16
...
b43c5b1734
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43c5b1734 | ||
|
|
85eca9edd4 | ||
|
|
c7b321a636 | ||
|
|
90c00ad29b |
@@ -98,6 +98,9 @@ coverage.xml
|
||||
# =============================================================================
|
||||
# Base SQLite locale (une base de démo a déjà été committée par le passé,
|
||||
# voir l'historique du commit `4fd27ba`).
|
||||
# Données PostgreSQL locales — gérées par app/local_postgres.py
|
||||
instance/pgdata/
|
||||
instance/pg.log
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
@@ -151,5 +151,14 @@ AUTO_CREATE_TABLES=true
|
||||
# FORCE_HTTPS=false no redirect to HTTPS
|
||||
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||
#
|
||||
# LOCAL_PG=true start a local PostgreSQL server (requires PostgreSQL
|
||||
# installed on the system). Uses port 5433,
|
||||
# data in instance/pgdata/, database team_tryouts.
|
||||
#
|
||||
# LOCAL_PG_PORT=5433 override the PostgreSQL port
|
||||
# LOCAL_PG_USER=team_tryouts_db override the PostgreSQL user
|
||||
# LOCAL_PG_DATABASE=team_tryouts_db override the database name
|
||||
#
|
||||
#
|
||||
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
|
||||
# development session cannot accidentally inherit a production binding.
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ babel = Babel()
|
||||
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
|
||||
# app.config, which create_app fills from the environment and defaults to
|
||||
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["1000 per day", "200 per hour"])
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Manage a local PostgreSQL instance for development.
|
||||
|
||||
Searches for PostgreSQL binaries on the system, initialises a data directory
|
||||
under ``instance/pgdata/``, starts the server on port 5433, creates the
|
||||
``team_tryouts`` database, and registers cleanup on exit.
|
||||
|
||||
Usage from run.py::
|
||||
|
||||
if os.getenv('LOCAL_PG', '').lower() == 'true':
|
||||
from app.local_postgres import start as start_local_pg
|
||||
os.environ['DATABASE_URL'] = start_local_pg()
|
||||
|
||||
Environment used (all optional):
|
||||
LOCAL_PG_PORT Port to bind (default 5433)
|
||||
LOCAL_PG_USER Database superuser name (default team_tryouts_dev)
|
||||
LOCAL_PG_DATABASE Database name (default team_tryouts)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DATADIR = pathlib.Path(__file__).resolve().parent.parent / 'instance' / 'pgdata'
|
||||
PG_LOG = DATADIR.parent / 'pg.log'
|
||||
|
||||
PORT = int(os.getenv('LOCAL_PG_PORT', '5433'))
|
||||
_USER = os.getenv('LOCAL_PG_USER', 'team_tryouts_dev')
|
||||
_DATABASE = os.getenv('LOCAL_PG_DATABASE', 'team_tryouts')
|
||||
|
||||
DATABASE_URL = f'postgresql://{_USER}@127.0.0.1:{PORT}/{_DATABASE}'
|
||||
|
||||
_started = False
|
||||
|
||||
#: Name of the system Windows PostgreSQL service we temporarily stopped.
|
||||
_stopped_system_service_name: str | None = None
|
||||
|
||||
logger = logging.getLogger('app.local_postgres')
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIN_PG_ROOTS = [
|
||||
pathlib.Path(os.environ.get('PROGRAMFILES', 'C:\\Program Files')) / 'PostgreSQL',
|
||||
pathlib.Path(os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)')) / 'PostgreSQL',
|
||||
]
|
||||
|
||||
|
||||
def _find_binary(name: str) -> pathlib.Path | None:
|
||||
"""Locate a PostgreSQL binary (pg_ctl, initdb, psql)."""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return pathlib.Path(found)
|
||||
|
||||
if sys.platform == 'win32':
|
||||
for root in _WIN_PG_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
versions = sorted(
|
||||
(p for p in root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
for version_dir in versions:
|
||||
candidate = version_dir / 'bin' / f'{name}.exe'
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows service management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Path to the Windows service controller.
|
||||
_SC_EXE = pathlib.Path(os.environ.get('SYSTEMROOT', 'C:\\Windows')) / 'System32' / 'sc.exe'
|
||||
|
||||
|
||||
def _detect_system_service() -> str | None:
|
||||
"""Find a running Windows PostgreSQL service installed by the EDB installer.
|
||||
|
||||
Returns the service name (e.g. ``postgresql-x64-18``) or None.
|
||||
"""
|
||||
if sys.platform != 'win32' or not _SC_EXE.is_file():
|
||||
return None
|
||||
for root in _WIN_PG_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
versions = sorted(
|
||||
(p for p in root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
for version_dir in versions:
|
||||
srv = f'postgresql-x64-{version_dir.name}'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(_SC_EXE), 'query', srv],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and 'RUNNING' in result.stdout:
|
||||
return srv
|
||||
except (FileNotFoundError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _stop_system_service(name: str):
|
||||
"""Stop a Windows system service."""
|
||||
print(f'[INFO] Stopping system PostgreSQL service "{name}" ...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(_SC_EXE), 'stop', name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
print(f'[WARNING] Could not stop service "{name}": {exc}')
|
||||
|
||||
|
||||
def _start_system_service(name: str):
|
||||
"""Start a Windows system service."""
|
||||
print(f'[INFO] Restarting system PostgreSQL service "{name}" ...')
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(_SC_EXE), 'start', name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
print(f'[WARNING] Could not restart service "{name}": {exc}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data directory management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TRUST_HBA = """\
|
||||
# Local development configuration — trust all local connections.
|
||||
# Installed by app/local_postgres.py.
|
||||
local all all trust
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
"""
|
||||
|
||||
|
||||
def _init_cluster():
|
||||
"""Create the data directory if it does not exist yet."""
|
||||
if DATADIR.is_dir() and (DATADIR / 'PG_VERSION').is_file():
|
||||
return
|
||||
|
||||
initdb = _find_binary('initdb')
|
||||
if not initdb:
|
||||
print(
|
||||
'[ERROR] PostgreSQL not found. Install it from:',
|
||||
'https://www.postgresql.org/download/windows/',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f'[INFO] Initialising PostgreSQL data directory at {DATADIR} ...')
|
||||
DATADIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write a non-empty password to a temporary file. PostgreSQL >= 18
|
||||
# rejects an empty password (NUL device), and we immediately overwrite
|
||||
# pg_hba.conf with trust auth anyway, so the value is irrelevant.
|
||||
pw_file = tempfile.NamedTemporaryFile( # noqa: SIM115 — delete=False needed for initdb --pwfile
|
||||
mode='w',
|
||||
delete=False,
|
||||
suffix='.pw',
|
||||
prefix='pg_',
|
||||
)
|
||||
pw_file.write('dev\n')
|
||||
pw_file.close()
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(initdb),
|
||||
'--locale=C',
|
||||
'--encoding=UTF8',
|
||||
'--data-checksums',
|
||||
'--username',
|
||||
_USER,
|
||||
'--pwfile',
|
||||
pw_file.name,
|
||||
'-D',
|
||||
str(DATADIR),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f'[ERROR] initdb failed (exit {exc.returncode}):')
|
||||
if exc.stderr:
|
||||
print(exc.stderr)
|
||||
if exc.stdout:
|
||||
print(exc.stdout)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(pw_file.name)
|
||||
|
||||
_set_postgresql_conf()
|
||||
|
||||
hba_path = DATADIR / 'pg_hba.conf'
|
||||
hba_path.write_text(_TRUST_HBA, encoding='utf-8')
|
||||
print('[INFO] pg_hba.conf set to trust local connections.')
|
||||
|
||||
|
||||
def _set_postgresql_conf():
|
||||
"""Write postgresql.conf to match our development settings."""
|
||||
conf_path = DATADIR / 'postgresql.conf'
|
||||
if not conf_path.is_file():
|
||||
return
|
||||
|
||||
lines = conf_path.read_text(encoding='utf-8').splitlines()
|
||||
lines.append('\n# ---- local_postgres.py overrides ----')
|
||||
lines.append(f'port = {PORT}')
|
||||
lines.append("listen_addresses = '127.0.0.1'")
|
||||
lines.append("unix_socket_directories = ''")
|
||||
lines.append('autovacuum = off') # avoid DLL conflicts with system PostgreSQL service
|
||||
lines.append('logging_collector = off')
|
||||
lines.append('log_destination = stderr')
|
||||
conf_path.write_text('\n'.join(lines), encoding='utf-8')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server start / stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pg_ctl(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run ``pg_ctl`` with the data directory pre-filled."""
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
if not pg_ctl:
|
||||
raise RuntimeError('pg_ctl binary not found')
|
||||
|
||||
return subprocess.run(
|
||||
[str(pg_ctl), *args, '-D', str(DATADIR), '-w'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or str(DATADIR),
|
||||
)
|
||||
|
||||
|
||||
def _server_running() -> bool:
|
||||
"""Return True if the server is already up on our port."""
|
||||
try:
|
||||
result = _pg_ctl(['status'])
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _create_database():
|
||||
"""Create the database if it does not exist."""
|
||||
psql = _find_binary('psql')
|
||||
if not psql:
|
||||
raise RuntimeError('psql binary not found')
|
||||
|
||||
check = subprocess.run(
|
||||
[
|
||||
str(psql),
|
||||
'-h',
|
||||
'127.0.0.1',
|
||||
'-p',
|
||||
str(PORT),
|
||||
'-U',
|
||||
_USER,
|
||||
'-d',
|
||||
'postgres',
|
||||
'-tAc',
|
||||
f"SELECT 1 FROM pg_database WHERE datname = '{_DATABASE}'",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, 'PGPASSWORD': ''},
|
||||
)
|
||||
|
||||
if check.stdout.strip() == '1':
|
||||
print(f'[INFO] Database "{_DATABASE}" already exists.')
|
||||
return
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
str(psql),
|
||||
'-h',
|
||||
'127.0.0.1',
|
||||
'-p',
|
||||
str(PORT),
|
||||
'-U',
|
||||
_USER,
|
||||
'-d',
|
||||
'postgres',
|
||||
'-c',
|
||||
f'CREATE DATABASE "{_DATABASE}"',
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, 'PGPASSWORD': ''},
|
||||
)
|
||||
print(f'[INFO] Created database "{_DATABASE}".')
|
||||
|
||||
|
||||
def _stop(*_args):
|
||||
"""Stop the PostgreSQL server with a fast shutdown.
|
||||
|
||||
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
||||
normal exit, and terminal close.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
if not _started:
|
||||
return
|
||||
_started = False
|
||||
|
||||
try:
|
||||
result = _pg_ctl(['stop', '-m', 'fast'])
|
||||
if result.returncode != 0:
|
||||
logger.info('pg_ctl stop returned %d: %s', result.returncode, result.stderr.strip())
|
||||
else:
|
||||
logger.info('Local PostgreSQL stopped.')
|
||||
except Exception as exc: # noqa: BLE001 — shutdown must never raise
|
||||
logger.warning('Failed to stop local PostgreSQL: %s', exc)
|
||||
|
||||
# Restart the system service if we stopped it earlier.
|
||||
if _stopped_system_service_name:
|
||||
srv = _stopped_system_service_name
|
||||
_stopped_system_service_name = None
|
||||
_start_system_service(srv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def start() -> str:
|
||||
"""Start a local PostgreSQL server for development.
|
||||
|
||||
Returns the ``DATABASE_URL`` the application should use.
|
||||
|
||||
The caller is expected to set this on ``os.environ['DATABASE_URL']``
|
||||
before invoking ``create_app()``.
|
||||
|
||||
This function is idempotent: if the server is already running and the
|
||||
database exists, it returns immediately.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
|
||||
# On Windows, temporarily stop the system PostgreSQL service so its child
|
||||
# processes (autovacuum, etc.) do not collide with our local instance.
|
||||
if _stopped_system_service_name is None: # only do this once per process
|
||||
srv = _detect_system_service()
|
||||
if srv:
|
||||
_stop_system_service(srv)
|
||||
_stopped_system_service_name = srv
|
||||
|
||||
# 1. Initialise if needed.
|
||||
_init_cluster()
|
||||
|
||||
# 2. Start if not already running.
|
||||
if not _server_running():
|
||||
print(f'[INFO] Starting PostgreSQL on port {PORT} ...')
|
||||
PG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(pg_ctl),
|
||||
'start',
|
||||
'-D',
|
||||
str(DATADIR),
|
||||
'-l',
|
||||
str(PG_LOG),
|
||||
'-w', # wait for readiness
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print('[ERROR] pg_ctl start failed.')
|
||||
# Also show the server log — it often has the real error.
|
||||
try:
|
||||
log_text = PG_LOG.read_text(encoding='utf-8')
|
||||
if log_text.strip():
|
||||
print(f'Server log ({PG_LOG}):')
|
||||
print(log_text)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sys.exit(1)
|
||||
print(f'[INFO] PostgreSQL is up (pid in {PG_LOG}).')
|
||||
else:
|
||||
print(f'[INFO] PostgreSQL is already running on port {PORT}.')
|
||||
|
||||
# 3. Create the application database (idempotent).
|
||||
_create_database()
|
||||
|
||||
# 4. Register cleanup.
|
||||
if not _started:
|
||||
_started = True
|
||||
atexit.register(_stop)
|
||||
with contextlib.suppress(ValueError, OSError):
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
||||
|
||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||
return DATABASE_URL
|
||||
@@ -71,9 +71,9 @@ PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket L
|
||||
|
||||
TRN_URLS = {
|
||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
|
||||
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
|
||||
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
|
||||
'Apex Legends': 'https://tracker.gg/apex/profile/{platform_code}/{username}',
|
||||
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
|
||||
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
|
||||
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
|
||||
|
||||
@@ -5,8 +5,7 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
|
||||
sessions.
|
||||
"""
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask import jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
@@ -201,16 +200,14 @@ def delete_disponibility(disponibility_id):
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
||||
@users_bp.route('/coach-availability', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def manage_coach_availability():
|
||||
"""Manage coach availability for One on One sessions."""
|
||||
if not isinstance(current_user, Coach):
|
||||
flash(_('Only coaches can manage availability.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
|
||||
@@ -248,14 +245,6 @@ def manage_coach_availability():
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'saved': len(accepted)})
|
||||
|
||||
existing_availability = CoachAvailability.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).all()
|
||||
|
||||
return render_template(
|
||||
'pages/coach_availability.html', existing_availability=existing_availability
|
||||
)
|
||||
|
||||
|
||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||
@json_endpoint
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}{{ _('Manage Availability') }} - UdeS team manager{% endblock %}
|
||||
{% block page_title %}{{ _('Manage Availability') }}{% endblock %}
|
||||
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> {{ _('Set Your Weekly Availability') }}</h3>
|
||||
<p class="text-muted small">{{ _('Select time slots when you\'re available for One on One sessions') }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="availability-grid" id="availability-grid">
|
||||
<p class="text-muted">{{ _('Loading availability grid...') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-4">
|
||||
<button type="button" class="btn btn-primary" data-action="save-availability">
|
||||
<i class="fas fa-save"></i> {{ _('Save Availability') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
||||
<i class="fas fa-trash"></i> {{ _('Clear All') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.availability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
padding: 6px 8px;
|
||||
margin: 4px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.time-slot:hover {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.time-slot.selected {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.time-slot.selected:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
// Time slots from 8:00 AM to 10:00 PM (30-minute intervals)
|
||||
const TIME_SLOTS = [];
|
||||
for (let h = 8; h <= 22; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
||||
const displayHour = h > 12 ? h - 12 : h;
|
||||
const displayAmpm = h >= 12 ? 'PM' : 'AM';
|
||||
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
|
||||
TIME_SLOTS.push({ time: timeStr, display: displayTime });
|
||||
}
|
||||
}
|
||||
|
||||
// Day names
|
||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
// Track selected slots: {day_of_week: [time_strings]}
|
||||
let selectedSlots = {};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadExistingAvailability();
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
function loadExistingAvailability() {
|
||||
// Load from existing data
|
||||
{% for av in existing_availability %}
|
||||
if (!selectedSlots[{{ av.day_of_week }}]) {
|
||||
selectedSlots[{{ av.day_of_week }}] = [];
|
||||
}
|
||||
selectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('availability-grid');
|
||||
let html = '';
|
||||
|
||||
DAYS.forEach((day, dayIndex) => {
|
||||
html += '<div class="day-column">';
|
||||
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
|
||||
|
||||
TIME_SLOTS.forEach(slot => {
|
||||
const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time);
|
||||
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
|
||||
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" data-action="toggle-slot">' + slot.display + '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleSlot(element) {
|
||||
const dayOfWeek = Number(element.getAttribute('data-day'));
|
||||
const timeStr = element.getAttribute('data-time');
|
||||
if (!selectedSlots[dayOfWeek]) {
|
||||
selectedSlots[dayOfWeek] = [];
|
||||
}
|
||||
|
||||
const index = selectedSlots[dayOfWeek].indexOf(timeStr);
|
||||
if (index === -1) {
|
||||
selectedSlots[dayOfWeek].push(timeStr);
|
||||
element.classList.add('selected');
|
||||
} else {
|
||||
selectedSlots[dayOfWeek].splice(index, 1);
|
||||
element.classList.remove('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function saveAvailability() {
|
||||
const slots = [];
|
||||
for (let day in selectedSlots) {
|
||||
selectedSlots[day].forEach(time => {
|
||||
slots.push({ day_of_week: parseInt(day), start_time: time });
|
||||
});
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.manage_coach_availability") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ slots: slots })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
flash('Availability saved!', 'success');
|
||||
} else {
|
||||
flash(data.error || 'Error saving availability.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Save error:', error);
|
||||
flash('Error saving availability.', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function clearAllAvailability() {
|
||||
if (!confirm('Are you sure you want to clear all your availability slots?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("users.clear_coach_availability") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
selectedSlots = {};
|
||||
renderGrid();
|
||||
flash('Availability cleared!', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flash(message, type) {
|
||||
const flashContainer = document.querySelector('.flash-messages');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-' + type + ' alert-dismissible';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'alert-close';
|
||||
close.dataset.action = 'remove-element';
|
||||
close.textContent = '×';
|
||||
alert.append(text, close);
|
||||
flashContainer.appendChild(alert);
|
||||
}
|
||||
|
||||
// Auto-save on change (debounced)
|
||||
let saveTimeout;
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('time-slot')) {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(saveAvailability, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Behaviours are declared in the markup with data-action / data-change and
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'save-availability': saveAvailability,
|
||||
'clear-availability': clearAllAvailability,
|
||||
'toggle-slot': toggleSlot,
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -145,49 +145,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Disponibilities Card -->
|
||||
{% if user.role == 'player' %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> My Disponibilities</h3>
|
||||
<p class="text-muted small">Your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="disponibilities-grid">
|
||||
<p class="text-muted">Loading...</p>
|
||||
</div>
|
||||
<div class="form-actions mt-3">
|
||||
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
|
||||
<i class="fas fa-save"></i> Save Disponibilities
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Coach Availability Card -->
|
||||
{% if user.role == 'coach' %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock"></i> My Availability</h3>
|
||||
<p class="text-muted small">Select time slots when you're available for One on One sessions (8am to 10pm).</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="availability-grid" id="availability-grid">
|
||||
<p class="text-muted">Loading availability grid...</p>
|
||||
</div>
|
||||
<div class="form-actions mt-3">
|
||||
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contracts Card -->
|
||||
{% if user.role == 'player' %}
|
||||
<div class="card">
|
||||
@@ -436,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCoachAvailability();
|
||||
renderCoachGrid();
|
||||
});
|
||||
|
||||
// Behaviours are declared in the markup with data-action / data-change and
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'clear-availability': clearAllAvailability,
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -650,9 +614,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'save-disponibilities': saveDisponibilities,
|
||||
'clear-disponibilities': clearDisponibilities,
|
||||
'clear-availability': clearAllAvailability,
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -170,8 +170,8 @@ was marked by the automated pass. Jinja escaped it to `&times;`, so
|
||||
the button would have shown the literal text `×` instead of ×.
|
||||
Anything that is markup rather than prose must stay out of `_()`.
|
||||
|
||||
**Strings inside `<script>` blocks.** Eight remain, in `match_form.html`,
|
||||
`one_on_one.html` and `coach_availability.html`. They work, but they are
|
||||
**Strings inside `<script>` blocks.** Seven remain, in `match_form.html` and
|
||||
`one_on_one.html`. They work, but they are
|
||||
fragile: Jinja escapes `& < > " '` inside a script block, and those
|
||||
entities are *not* decoded there — a translation containing an apostrophe
|
||||
would land in the JavaScript string as `'`. The French wording uses
|
||||
|
||||
@@ -25,9 +25,53 @@ import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.app import create_app # noqa: E402
|
||||
from app.extensions import db as _db # noqa: E402
|
||||
from app.extensions import hash_password # noqa: E402
|
||||
from app.local_postgres import start as start_local_pg # noqa: E402
|
||||
from app.models import Admin, Coach, Manager, Player # noqa: E402
|
||||
|
||||
# ==========================================================================
|
||||
# Local PostgreSQL (development only)
|
||||
# ==========================================================================
|
||||
if os.getenv('LOCAL_PG', '').lower() == 'true':
|
||||
os.environ['DATABASE_URL'] = start_local_pg()
|
||||
|
||||
# ==========================================================================
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
|
||||
# --- Seed the database with default test users (idempotent) ---------
|
||||
with app.app_context():
|
||||
if not Admin.query.first():
|
||||
print('[INFO] Seeding database with default test users ...')
|
||||
users = [
|
||||
Admin(username='admin', role='admin', full_name='Admin', email='[email protected]'),
|
||||
Coach(
|
||||
username='coach',
|
||||
role='coach',
|
||||
full_name='Coach Test',
|
||||
email='[email protected]',
|
||||
),
|
||||
Manager(
|
||||
username='manager',
|
||||
role='manager',
|
||||
full_name='Manager Test',
|
||||
email='[email protected]',
|
||||
),
|
||||
Player(
|
||||
username='player',
|
||||
role='player',
|
||||
full_name='Player Test',
|
||||
email='[email protected]',
|
||||
),
|
||||
]
|
||||
for u in users:
|
||||
u.password_hash = hash_password('password')
|
||||
_db.session.add(u)
|
||||
_db.session.commit()
|
||||
print('[INFO] Seeded 4 default users (password for all: password).')
|
||||
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
if debug_mode:
|
||||
app.logger.warning(
|
||||
|
||||
@@ -126,14 +126,6 @@ class TestInlineHandlerRatchet:
|
||||
assert "' + playerName + '" not in content
|
||||
assert content.count('escapeHtml(playerName)') == 6
|
||||
|
||||
def test_api_messages_are_written_as_text(self):
|
||||
template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html')
|
||||
with open(template, encoding='utf-8') as handle:
|
||||
content = handle.read()
|
||||
|
||||
assert 'text.textContent = message' in content
|
||||
assert "alert.innerHTML = '<span>' + message" not in content
|
||||
|
||||
@pytest.mark.parametrize('relative,full', list(_templates()))
|
||||
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
||||
allowed = HANDLER_BUDGET.get(relative, 0)
|
||||
|
||||
Reference in New Issue
Block a user