meme commit que avant
CI - Security, Lint & Tests / validate (push) Failing after 58s

This commit is contained in:
cedrick2711
2026-09-01 14:17:47 -04:00
parent 85eca9edd4
commit b43c5b1734
8 changed files with 527 additions and 100 deletions
+3
View File
@@ -98,6 +98,9 @@ coverage.xml
# ============================================================================= # =============================================================================
# Base SQLite locale (une base de démo a déjà été committée par le passé, # Base SQLite locale (une base de démo a déjà été committée par le passé,
# voir l'historique du commit `4fd27ba`). # voir l'historique du commit `4fd27ba`).
# Données PostgreSQL locales — gérées par app/local_postgres.py
instance/pgdata/
instance/pg.log
instance/ instance/
*.db *.db
*.sqlite *.sqlite
+9
View File
@@ -151,5 +151,14 @@ AUTO_CREATE_TABLES=true
# FORCE_HTTPS=false no redirect to HTTPS # FORCE_HTTPS=false no redirect to HTTPS
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback # 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 # `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
# development session cannot accidentally inherit a production binding. # development session cannot accidentally inherit a production binding.
+428
View File
@@ -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
+3 -14
View File
@@ -5,8 +5,7 @@ weekly availability blocks, and a coach's bookable slots for one-on-one
sessions. sessions.
""" """
from flask import flash, jsonify, redirect, render_template, request, url_for from flask import jsonify, request
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
@@ -201,16 +200,14 @@ def delete_disponibility(disponibility_id):
return jsonify({'success': True}) return jsonify({'success': True})
@users_bp.route('/coach-availability', methods=['GET', 'POST']) @users_bp.route('/coach-availability', methods=['POST'])
@json_endpoint @json_endpoint
@login_required @login_required
def manage_coach_availability(): def manage_coach_availability():
"""Manage coach availability for One on One sessions.""" """Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach): if not isinstance(current_user, Coach):
flash(_('Only coaches can manage availability.'), 'danger') return jsonify({'error': 'Unauthorized'}), 403
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots')) accepted, rejected = _load_slots(data.get('slots'))
@@ -248,14 +245,6 @@ def manage_coach_availability():
db.session.commit() db.session.commit()
return jsonify({'success': True, 'saved': len(accepted)}) 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']) @users_bp.route('/coach-availability/clear', methods=['POST'])
@json_endpoint @json_endpoint
+7 -45
View File
@@ -145,49 +145,6 @@
</div> </div>
</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 --> <!-- Contracts Card -->
{% if user.role == 'player' %} {% if user.role == 'player' %}
<div class="card"> <div class="card">
@@ -436,6 +393,13 @@ document.addEventListener('DOMContentLoaded', function() {
loadCoachAvailability(); loadCoachAvailability();
renderCoachGrid(); 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> </script>
{% endif %} {% endif %}
@@ -650,9 +614,7 @@ document.addEventListener('DOMContentLoaded', function() {
// dispatched by the delegated listener in main.js. This replaces inline // dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise. // onclick attributes, which no CSP nonce is able to authorise.
registerActions({ registerActions({
'save-disponibilities': saveDisponibilities,
'clear-disponibilities': clearDisponibilities, 'clear-disponibilities': clearDisponibilities,
'clear-availability': clearAllAvailability,
}); });
</script> </script>
{% endif %} {% endif %}
+2 -2
View File
@@ -170,8 +170,8 @@ was marked by the automated pass. Jinja escaped it to `&amp;times;`, so
the button would have shown the literal text `&times;` instead of ×. the button would have shown the literal text `&times;` instead of ×.
Anything that is markup rather than prose must stay out of `_()`. Anything that is markup rather than prose must stay out of `_()`.
**Strings inside `<script>` blocks.** Eight remain, in `match_form.html`, **Strings inside `<script>` blocks.** Seven remain, in `match_form.html` and
`one_on_one.html` and `coach_availability.html`. They work, but they are `one_on_one.html`. They work, but they are
fragile: Jinja escapes `& < > " '` inside a script block, and those fragile: Jinja escapes `& < > " '` inside a script block, and those
entities are *not* decoded there — a translation containing an apostrophe entities are *not* decoded there — a translation containing an apostrophe
would land in the JavaScript string as `&#39;`. The French wording uses would land in the JavaScript string as `&#39;`. The French wording uses
+44
View File
@@ -25,9 +25,53 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.app import create_app # noqa: E402 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__': if __name__ == '__main__':
app = create_app() 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' debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
if debug_mode: if debug_mode:
app.logger.warning( app.logger.warning(
-8
View File
@@ -126,14 +126,6 @@ class TestInlineHandlerRatchet:
assert "' + playerName + '" not in content assert "' + playerName + '" not in content
assert content.count('escapeHtml(playerName)') == 6 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())) @pytest.mark.parametrize('relative,full', list(_templates()))
def test_a_template_never_gains_an_inline_handler(self, relative, full): def test_a_template_never_gains_an_inline_handler(self, relative, full):
allowed = HANDLER_BUDGET.get(relative, 0) allowed = HANDLER_BUDGET.get(relative, 0)