diff --git a/.gitignore b/.gitignore index 624e58b..619ebc2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/.env.example b/app/.env.example index d2f2cac..3e5d47c 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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. diff --git a/app/local_postgres.py b/app/local_postgres.py new file mode 100644 index 0000000..1793d6a --- /dev/null +++ b/app/local_postgres.py @@ -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 diff --git a/app/routes/users/availability.py b/app/routes/users/availability.py index fcd3400..6312d57 100644 --- a/app/routes/users/availability.py +++ b/app/routes/users/availability.py @@ -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,60 +200,50 @@ 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')) + data = request.get_json(silent=True) or {} + accepted, rejected = _load_slots(data.get('slots')) - # Validate everything before deleting anything. - # - # This route replaces the coach's availability: it deleted every - # existing slot and then re-added the ones it could parse, skipping - # the rest in silence and answering `{'success': true}`. A payload - # the browser mangled therefore wiped a coach's bookable hours and - # reported success — and one-on-one requests are refused against - # exactly this table, so the coach became unbookable with nothing to - # show for it. Refusing the whole batch is the only safe answer when - # the operation is a replacement (MNT-12). - if rejected: - return jsonify( - { - 'error': 'Invalid slots; nothing was changed.', - 'rejected': rejected, - } - ), 400 + # Validate everything before deleting anything. + # + # This route replaces the coach's availability: it deleted every + # existing slot and then re-added the ones it could parse, skipping + # the rest in silence and answering `{'success': true}`. A payload + # the browser mangled therefore wiped a coach's bookable hours and + # reported success — and one-on-one requests are refused against + # exactly this table, so the coach became unbookable with nothing to + # show for it. Refusing the whole batch is the only safe answer when + # the operation is a replacement (MNT-12). + if rejected: + return jsonify( + { + 'error': 'Invalid slots; nothing was changed.', + 'rejected': rejected, + } + ), 400 - CoachAvailability.query.filter_by(coach_id=current_user.id).delete() + CoachAvailability.query.filter_by(coach_id=current_user.id).delete() - for slot in accepted: - start_time = slot['start_time'] - db.session.add( - CoachAvailability( - coach_id=current_user.id, - day_of_week=slot['day_of_week'], - start_time=start_time, - end_time=slot_end(start_time), - ) + for slot in accepted: + start_time = slot['start_time'] + db.session.add( + CoachAvailability( + coach_id=current_user.id, + day_of_week=slot['day_of_week'], + start_time=start_time, + end_time=slot_end(start_time), ) + ) - 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 - ) + db.session.commit() + return jsonify({'success': True, 'saved': len(accepted)}) @users_bp.route('/coach-availability/clear', methods=['POST']) diff --git a/app/templates/pages/profile.html b/app/templates/pages/profile.html index 04a40e8..6585c65 100644 --- a/app/templates/pages/profile.html +++ b/app/templates/pages/profile.html @@ -145,49 +145,6 @@ - - {% if user.role == 'player' %} -
Your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.
-Loading...
-Select time slots when you're available for One on One sessions (8am to 10pm).
-Loading availability grid...
-