Files
schedule/src/frontend/main.py
Vladislav Kopnin d177560f7d Видно, изменился ли файл при обновлении (sha256 + отметка в админке)
Просьба с эксплуатации: после «Обновить сейчас» непонятно, подхватились ли
правки таблицы. Теперь кэш считает sha256 скачанного файла и сравнивает с
предыдущим чтением:

- в логе: «Расписание загружено: N групп …; файл изменился/не изменился
  (sha256 ab12cd34)»;
- состояние для админки дополнено полями changed / changed_at / content_hash;
- в панели чипы «Файл при последнем обновлении», «Содержимое менялось»,
  «sha256 файла», а после кнопки — понятное сообщение: «обнаружены изменения»
  либо «файл не изменился (на Диске та же версия)» с подсказкой про задержку
  публикации у Яндекса;
- ответы API отдаются с Cache-Control: no-store, чтобы браузер не показывал
  закэшированные данные после обновления;
- запросы к Яндекс.Диску идут с no-cache заголовками и меткой времени —
  меньше шансов получить закэшированную версию файла.

Тест: test_state_reports_file_changes (первая загрузка, изменение файла,
повторное чтение без изменений).
2026-09-29 15:27:01 +03:00

179 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фронтенд расписания: статики, прокси к 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, JSONResponse
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 JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
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"),
headers={"Cache-Control": "no-store"},
)
# 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)