Админка осталась отдельной ветвью admin-panel; в master её нет (ни кода, ни страниц, ни переменных окружения, ни тома настроек). Что перенесено: - parsing: имена групп вида «ИВТ-ТОП-11БО» распознаются, порядок групп = порядок столбцов таблицы (похожие заголовки с пробелами группой не считаются); - main: GET /groups отдаёт группы в порядке столбцов; интервал обновления из SCHEDULE_INTERVAL_MINUTES (со старым SCHEDULE_CACHE_TTL как fallback); - cache: по интервалу сверяем modified — если файл не менялся, не скачиваем; при загрузке считаем sha256 и пишем в лог и /status, изменился ли файл; - source: запросы к Яндекс.Диску с no-cache и меткой времени; - frontend: ответы API с Cache-Control: no-store, общий .env из корня проекта (и backend, и frontend), страницы/прокси админки нет; - Dockerfile/compose/.env.example: без ADMIN_* и без тома настроек; в api-образ попадают все модули backend (проверяется тестом test_deploy); - Makefile: make help/venv/run/run-api/run-front/test + docker-цели — «make run» поднимает всё локально без Docker; - README: быстрый старт, полный список зависимостей, запуск локально и в Docker; - deploy/: пример nginx и инструкция «второй стенд рядом с основным». Тесты: 114 (удалены админские), все зелёные.
134 lines
3.7 KiB
Python
134 lines
3.7 KiB
Python
"""Фронтенд расписания: статики + прокси к API бэкенда."""
|
|
|
|
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
|
|
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")
|
|
|
|
|
|
def _find_dotenv() -> Path | None:
|
|
"""Ищем .env вверх от каталога модуля: один общий .env в корне проекта."""
|
|
path = Path(__file__).resolve().parent
|
|
for _ in range(5):
|
|
env = path / ".env"
|
|
if env.is_file():
|
|
return env
|
|
if path.parent == path:
|
|
break
|
|
path = path.parent
|
|
return None
|
|
|
|
|
|
_dotenv_path = _find_dotenv()
|
|
if _dotenv_path:
|
|
load_dotenv(_dotenv_path)
|
|
|
|
# Адрес бэкенда: в docker это http://api:8000 (задаётся в docker-compose),
|
|
# локально — http://127.0.0.1:8000.
|
|
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
|
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
|
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
|
|
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"})
|
|
|
|
|
|
# --- 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.get("/")
|
|
def index():
|
|
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
|
|
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)
|