"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки. Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py): сам путь не публикуется, входа с сайта нет. """ from __future__ import annotations import logging import os from pathlib import Path import requests import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request, Response from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") load_dotenv(Path(__file__).parent / ".env") # Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше. SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/") PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150")) ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/") STATIC_DIR = Path(__file__).parent / "static" # страница админки лежит ВНЕ static: прямой доступ по /admin.html запрещён ADMIN_PAGE = Path(__file__).parent / "admin.html" app = FastAPI(title="Расписание — фронт") @app.get("/healthz") def healthz() -> dict: """Живость процесса; не зависит от бэкенда.""" return {"ok": True} def _proxy(path: str, params: dict) -> object: try: resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=PROXY_TIMEOUT) except requests.RequestException as exc: raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}") if resp.status_code != 200: try: detail = resp.json().get("detail", resp.text) except Exception: detail = resp.text raise HTTPException(status_code=resp.status_code, detail=detail) return resp.json() def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response: """Проксирует админ-запрос как есть: метод, тело, куки и Set-Cookie.""" try: resp = requests.request( method, f"{SCHEDULE_API}/{path}", data=body or None, headers=headers, timeout=PROXY_TIMEOUT, ) except requests.RequestException as exc: raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}") out = Response( content=resp.content, status_code=resp.status_code, media_type=resp.headers.get("content-type"), ) # Set-Cookie (вход/выход админки) передаём как есть raw_headers = getattr(resp.raw, "headers", None) for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []: out.headers.append("set-cookie", cookie) return out # --- API-маршруты ----------------------------------------------------------- @app.get("/api/groups") def api_groups(): return _proxy("groups", {}) @app.get("/api/schedule/{group}") def api_schedule(group: str): return _proxy(f"schedule/{group}", {}) @app.get("/api/teachers") def api_teachers(): return _proxy("teachers", {}) @app.get("/api/rooms") def api_rooms(): return _proxy("rooms", {}) @app.get("/api/teacher/{name}") def api_teacher(name: str): return _proxy(f"teacher/{name}", {}) @app.get("/api/room/{label}") def api_room(label: str): return _proxy(f"room/{label}", {}) @app.get("/api/rooms-free") def api_rooms_free(day: str, period: int): return _proxy("rooms-free", {"day": day, "period": period}) @app.get("/api/rooms-day") def api_rooms_day(day: str): return _proxy("rooms-day", {"day": day}) @app.get("/api/week") def api_week(): return _proxy("week", {}) @app.get("/api/status") def api_status(): return _proxy("status", {}) @app.api_route("/api/admin/{rest:path}", methods=["GET", "POST"]) async def api_admin(rest: str, request: Request): """Админ-API: тело и куки передаём в бэкенд, ответ — обратно как есть.""" body = await request.body() headers = {} content_type = request.headers.get("content-type") if content_type: headers["Content-Type"] = content_type cookie = request.headers.get("cookie") if cookie: headers["Cookie"] = cookie return await run_in_threadpool( _proxy_admin, request.method, f"api/admin/{rest}", body, headers ) # --- статики ----------------------------------------------------------------- @app.get("/") def index(): return FileResponse(STATIC_DIR / "index.html") if ADMIN_PATH: @app.get(ADMIN_PATH, include_in_schema=False) def admin_page(): """Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет.""" return FileResponse( ADMIN_PAGE, headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"}, ) log.info("Страница админки включена по секретному пути") else: log.info("ADMIN_PATH не задан — страница админки выключена") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") if __name__ == "__main__": uvicorn.run("main:app", host="127.0.0.1", port=8080, reload=True)