Spaces:
Running
Running
Fix arranque en HF: import de goalkicks opcional (feature WIP no commiteada) + re-aplicar p=2 y plegables
77bf160 | from __future__ import annotations | |
| print("[boot] web.app: arrancando imports", flush=True) | |
| import json | |
| import mimetypes | |
| from datetime import date | |
| from pathlib import Path | |
| from typing import Any | |
| import uvicorn | |
| from fastapi import FastAPI, Form, HTTPException, Request | |
| from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse | |
| from fastapi.templating import Jinja2Templates | |
| print("[boot] fastapi/uvicorn importados", flush=True) | |
| from racing_reports import vendor_env | |
| from racing_reports.api import jobs as jobs_mod | |
| from racing_reports.api.routes_jobs import router as api_jobs_router | |
| from racing_reports.api.routes_meta import router as api_meta_router | |
| from racing_reports.api.routes_reports import router as api_reports_router | |
| from racing_reports.api.routes_team_vars import router as api_team_vars_router | |
| from racing_reports.api.routes_ejes import router as api_ejes_router | |
| try: # feature en desarrollo (archivos pueden no estar en el deploy) | |
| from racing_reports.api.routes_goalkicks import router as api_goalkicks_router | |
| except Exception: # noqa: BLE001 | |
| api_goalkicks_router = None | |
| from racing_reports.api.routes_lowblock import router as api_lowblock_router | |
| from racing_reports.config import DEFAULT_SETTINGS | |
| from racing_reports.datastore import DataStore | |
| from racing_reports.match_index import MatchIndex | |
| from racing_reports.models import ReportRequest | |
| from racing_reports.runner import ReportRunner | |
| print("[boot] módulos racing_reports importados", flush=True) | |
| DEFAULT_LEAGUE = "Spanish Segunda Division" | |
| DEFAULT_SEASON = "25-26" | |
| REPORT_LABELS = { | |
| "pre_match": "Reporte previo (head-to-head)", | |
| "post_match": "Reporte post-partido", | |
| "block_zscore": "Bloques (z-score)", | |
| } | |
| TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" | |
| app = FastAPI(title="Reportes Racing") | |
| templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) | |
| # API JSON (Sprint 1). El frontend moderno consume esto; las rutas HTML viejas | |
| # siguen funcionando en paralelo para no romper el flujo actual. | |
| app.include_router(api_meta_router) | |
| app.include_router(api_reports_router) | |
| app.include_router(api_jobs_router) | |
| app.include_router(api_team_vars_router) | |
| app.include_router(api_ejes_router) | |
| if api_goalkicks_router is not None: | |
| app.include_router(api_goalkicks_router) | |
| app.include_router(api_lowblock_router) | |
| # Estado en memoria (single container en HF Space). Re-export para retrocompat. | |
| Job = jobs_mod.Job | |
| JOBS = jobs_mod.JOBS | |
| MATCH_CACHE: dict[tuple[str, str], list[dict[str, Any]]] = {} | |
| def _team_list() -> list[str]: | |
| try: | |
| ids = json.loads(vendor_env.team_ids_path().read_text(encoding="utf-8")) | |
| return sorted(ids.keys()) | |
| except Exception: | |
| return [] | |
| def _password_ok(request: Request) -> bool: | |
| pwd = DEFAULT_SETTINGS.access_password | |
| if not pwd: | |
| return True | |
| return request.cookies.get("rr_auth") == pwd | |
| def _start(job: Job, target, *args) -> None: | |
| jobs_mod.start(job, target, *args) | |
| # ── auth ────────────────────────────────────────────────────────────────── | |
| def login_form(request: Request): | |
| return templates.TemplateResponse(request, "login.html", {"request": request, "error": ""}) | |
| def login(request: Request, password: str = Form(...)): | |
| if password != DEFAULT_SETTINGS.access_password: | |
| return templates.TemplateResponse( | |
| request, "login.html", {"request": request, "error": "Contraseña incorrecta."} | |
| ) | |
| resp = RedirectResponse(url="/", status_code=303) | |
| # En HF Spaces la app vive dentro de un iframe en huggingface.co → cookies | |
| # cross-site. Necesitamos SameSite=None + Secure para que el browser las | |
| # acepte. En localhost (HTTP) caemos a Lax + secure=False para no romper dev. | |
| is_https = request.url.scheme == "https" | |
| resp.set_cookie( | |
| "rr_auth", | |
| password, | |
| httponly=True, | |
| secure=is_https, | |
| samesite="none" if is_https else "lax", | |
| max_age=60 * 60 * 12, | |
| ) | |
| return resp | |
| # ── home (SPA nuevo) ────────────────────────────────────────────────────── | |
| def home(request: Request, league: str | None = None, season: str | None = None): | |
| if not _password_ok(request): | |
| return RedirectResponse(url="/login", status_code=303) | |
| from racing_reports.api.routes_meta import _default_league_season | |
| def_league, def_season = _default_league_season() | |
| from racing_reports import web_meta | |
| return templates.TemplateResponse( | |
| request, | |
| "home.html", | |
| { | |
| "request": request, | |
| "league": league or def_league, | |
| "season": season or def_season, | |
| "nn_enabled": DEFAULT_SETTINGS.enable_nn_predictions, | |
| "data_max_date": web_meta.max_date(), | |
| }, | |
| ) | |
| # ── home legacy (HTML viejo, mantenido por compat) ──────────────────────── | |
| def legacy_home(request: Request, league: str = DEFAULT_LEAGUE, season: str = DEFAULT_SEASON): | |
| if not _password_ok(request): | |
| return RedirectResponse(url="/login", status_code=303) | |
| matches = MATCH_CACHE.get((league, season)) | |
| return templates.TemplateResponse( | |
| request, | |
| "legacy_home.html", | |
| { | |
| "request": request, | |
| "league": league, | |
| "season": season, | |
| "teams": _team_list(), | |
| "matches": matches, | |
| "post_match_enabled": DEFAULT_SETTINGS.enable_nn_predictions, | |
| }, | |
| ) | |
| # ── cargar lista de partidos jugados (job) ──────────────────────────────── | |
| def _build_matches(job: Job, league: str, season: str) -> None: | |
| idx = MatchIndex(DataStore()) | |
| df = idx.build(league, season) | |
| today = date.today().isoformat() | |
| df = df[df["date"] <= today] | |
| MATCH_CACHE[(league, season)] = df.sort_values("date", ascending=False).to_dict( | |
| "records" | |
| ) | |
| job.redirect = f"/?league={league}&season={season}" | |
| job.message = "Lista de partidos lista." | |
| def load_matches(request: Request, league: str = Form(...), season: str = Form(...)): | |
| if not _password_ok(request): | |
| raise HTTPException(status_code=403, detail="No autorizado.") | |
| job = jobs_mod.new_job( | |
| kind="matches", | |
| title="Cargando partidos jugados", | |
| message="Descargando datos y armando la lista de partidos… puede tardar.", | |
| ) | |
| _start(job, _build_matches, league, season) | |
| return RedirectResponse(url=f"/status/{job.id}", status_code=303) | |
| # ── generar reportes (job) ──────────────────────────────────────────────── | |
| def _run_report(job: Job, req: ReportRequest) -> None: | |
| result = ReportRunner().run(req) | |
| tabs = [] | |
| for name, path in result.html_paths.items(): | |
| tabs.append( | |
| { | |
| "name": name, | |
| "label": REPORT_LABELS.get(name, name), | |
| "view": f"/view?path={path}", | |
| "download": f"/download?path={path}", | |
| } | |
| ) | |
| job.tabs = tabs | |
| job.manifest_url = f"/download?path={result.manifest_path}" | |
| job.message = "Reportes listos." | |
| def generate( | |
| request: Request, | |
| flow: str = Form(...), | |
| league: str = Form(DEFAULT_LEAGUE), | |
| season: str = Form(DEFAULT_SEASON), | |
| team_a: str = Form(""), | |
| team_b: str = Form(""), | |
| pre_match: str | None = Form(None), | |
| block_zscore: str | None = Form(None), | |
| date_from: str = Form(""), | |
| date_to: str = Form(""), | |
| match_id: str = Form(""), | |
| ): | |
| if not _password_ok(request): | |
| raise HTTPException(status_code=403, detail="No autorizado.") | |
| if flow == "post": | |
| if not DEFAULT_SETTINGS.enable_nn_predictions: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Reporte post-partido no disponible en el MVP " | |
| "(requiere artifacts del modelo NN).", | |
| ) | |
| if not match_id: | |
| raise HTTPException(status_code=400, detail="Elegí un partido jugado.") | |
| req = ReportRequest( | |
| league=league, | |
| season=season, | |
| home_team="", | |
| away_team="", | |
| report_types=["post_match"], # runner antepone el pre automáticamente | |
| match_id=match_id, | |
| ) | |
| title = "Reporte post-partido (corre también el previo)" | |
| else: | |
| report_types = [] | |
| if pre_match: | |
| report_types.append("pre_match") | |
| if block_zscore: | |
| report_types.append("block_zscore") | |
| if not report_types: | |
| raise HTTPException(status_code=400, detail="Elegí al menos un reporte.") | |
| if not team_a or not team_b: | |
| raise HTTPException(status_code=400, detail="Elegí Equipo A y Equipo B.") | |
| req = ReportRequest( | |
| league=league, | |
| season=season, | |
| home_team=team_a, | |
| away_team=team_b, | |
| report_types=report_types, | |
| settings={ | |
| "block_zscore": { | |
| "team_a": team_a, | |
| "team_b": team_b, | |
| "date_from": date_from or None, | |
| "date_to": date_to or None, | |
| } | |
| }, | |
| ) | |
| title = f"{team_a} vs {team_b}" | |
| job = jobs_mod.new_job(kind="report", title=title) | |
| _start(job, _run_report, req) | |
| return RedirectResponse(url=f"/status/{job.id}", status_code=303) | |
| def status(request: Request, job_id: str): | |
| if not _password_ok(request): | |
| raise HTTPException(status_code=401, detail="No autorizado.") | |
| job = JOBS.get(job_id) | |
| if job is None: | |
| raise HTTPException(status_code=404, detail="Trabajo no encontrado.") | |
| if job.status == "done" and job.kind == "matches" and job.redirect: | |
| return RedirectResponse(url=job.redirect, status_code=303) | |
| return templates.TemplateResponse(request, "status.html", {"request": request, "job": job}) | |
| # ── ver / descargar outputs ─────────────────────────────────────────────── | |
| def _safe_output_path(path: str) -> Path: | |
| candidate = Path(path).expanduser().resolve() | |
| output_root = DEFAULT_SETTINGS.output_dir.resolve() | |
| try: | |
| candidate.relative_to(output_root) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=403, detail="Path fuera de outputs.") from exc | |
| if not candidate.exists() or not candidate.is_file(): | |
| raise HTTPException(status_code=404, detail="Archivo no encontrado.") | |
| return candidate | |
| def view_report(request: Request, path: str): | |
| if not _password_ok(request): | |
| raise HTTPException(status_code=401, detail="No autorizado.") | |
| return HTMLResponse(_safe_output_path(path).read_text(encoding="utf-8")) | |
| def download(request: Request, path: str): | |
| if not _password_ok(request): | |
| raise HTTPException(status_code=401, detail="No autorizado.") | |
| resolved = _safe_output_path(path) | |
| media_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream" | |
| return FileResponse(resolved, media_type=media_type, filename=resolved.name) | |
| def main() -> None: | |
| import os | |
| port = int(os.getenv("PORT", "7860")) | |
| print(f"[boot] lanzando uvicorn en :{port}", flush=True) | |
| # forwarded_allow_ips="*" hace que uvicorn respete X-Forwarded-Proto del proxy | |
| # de HF Spaces (termina TLS antes del container). Sin esto, request.url.scheme | |
| # queda como "http" aunque el cliente llegue por HTTPS, y la cookie de auth se | |
| # setea sin secure → el browser la descarta en iframe cross-site. | |
| uvicorn.run( | |
| "racing_reports.web.app:app", | |
| host="0.0.0.0", | |
| port=port, | |
| reload=False, | |
| proxy_headers=True, | |
| forwarded_allow_ips="*", | |
| ) | |
| if __name__ == "__main__": | |
| main() | |