Базовая функциональность
This commit is contained in:
298
src/backend/main.py
Normal file
298
src/backend/main.py
Normal file
@@ -0,0 +1,298 @@
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
|
||||
from schedule_parser import (
|
||||
DAYS,
|
||||
ScheduleError,
|
||||
build_day_times,
|
||||
build_group_schedule,
|
||||
build_room_schedule,
|
||||
build_teacher_room_index,
|
||||
build_teacher_schedule,
|
||||
current_parity,
|
||||
download_schedule,
|
||||
extract_week_ranges,
|
||||
fetch_table_modified,
|
||||
parse_rooms_info,
|
||||
parse_schedule,
|
||||
parse_teachers_full,
|
||||
parse_workbook,
|
||||
sort_rooms,
|
||||
)
|
||||
|
||||
def _find_dotenv():
|
||||
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)
|
||||
|
||||
SCHEDULE_LINK = os.getenv("schedule_link") or os.getenv("SCHEDULE_LINK", "").strip()
|
||||
CACHE_TTL = int(os.getenv("SCHEDULE_CACHE_TTL", "900"))
|
||||
|
||||
_cache = {
|
||||
"data": None,
|
||||
"weeks": None,
|
||||
"modified": None,
|
||||
"loaded_at": 0.0,
|
||||
"error": None,
|
||||
"index": None,
|
||||
"teacher_full": {},
|
||||
"room_info": {},
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
try:
|
||||
_load_data()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Расписание",
|
||||
description="Расписание групп из Яндекс.Таблицы",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
def _load_data(force: bool = False):
|
||||
if not SCHEDULE_LINK:
|
||||
raise ScheduleError("Ссылка schedule_link не задана в .env")
|
||||
now = time.time()
|
||||
if (
|
||||
not force
|
||||
and _cache["data"] is not None
|
||||
and now - _cache["loaded_at"] < CACHE_TTL
|
||||
and _cache["error"] is None
|
||||
):
|
||||
return _cache["data"], _cache["weeks"]
|
||||
|
||||
modified = fetch_table_modified(SCHEDULE_LINK)
|
||||
|
||||
if (
|
||||
not force
|
||||
and modified
|
||||
and modified == _cache["modified"]
|
||||
and _cache["data"] is not None
|
||||
and _cache["error"] is None
|
||||
):
|
||||
_cache["loaded_at"] = now
|
||||
return _cache["data"], _cache["weeks"]
|
||||
|
||||
xlsx = download_schedule(SCHEDULE_LINK)
|
||||
sheets = parse_workbook(xlsx)
|
||||
data = parse_schedule(sheets)
|
||||
weeks = extract_week_ranges(sheets)
|
||||
_cache["data"] = data
|
||||
_cache["weeks"] = weeks
|
||||
_cache["modified"] = modified
|
||||
_cache["loaded_at"] = now
|
||||
_cache["error"] = None
|
||||
_cache["index"] = None
|
||||
_cache["teacher_full"] = parse_teachers_full(sheets)
|
||||
_cache["room_info"] = parse_rooms_info(sheets)
|
||||
return data, weeks
|
||||
|
||||
|
||||
def _get_index(force: bool = False):
|
||||
data, _ = _get_data(force=force)
|
||||
if _cache["index"] is None:
|
||||
_cache["index"] = build_teacher_room_index(data)
|
||||
return _cache["index"]
|
||||
|
||||
|
||||
def _get_data(force: bool = False):
|
||||
try:
|
||||
return _load_data(force=force)
|
||||
except ScheduleError as exc:
|
||||
_cache["error"] = str(exc)
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return {
|
||||
"title": "Расписание",
|
||||
"endpoints": {
|
||||
"/groups": "Список групп",
|
||||
"/schedule/{group}": "Расписание группы",
|
||||
"/schedule/{group}/{day}": "Расписание группы на день",
|
||||
"/today?group={group}": "Расписание группы на сегодня",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/groups")
|
||||
def groups(force: bool = Query(False)):
|
||||
data, _ = _get_data(force=force)
|
||||
return sorted(
|
||||
data.keys(),
|
||||
key=lambda g: (
|
||||
0 if g.endswith("БО") else (1 if g.endswith("МО") else 2),
|
||||
g,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/teachers")
|
||||
def teachers(force: bool = Query(False)):
|
||||
teachers_map, _ = _get_index(force=force)
|
||||
return sorted(teachers_map.keys())
|
||||
|
||||
|
||||
@app.get("/rooms")
|
||||
def rooms(force: bool = Query(False)):
|
||||
_, rooms_map = _get_index(force=force)
|
||||
return sort_rooms(rooms_map.keys())
|
||||
|
||||
|
||||
@app.get("/teacher/{name}")
|
||||
def teacher(name: str, force: bool = Query(False)):
|
||||
teachers_map, _ = _get_index(force=force)
|
||||
result = build_teacher_schedule(teachers_map, name)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Преподаватель «{name}» не найден")
|
||||
result["full_name"] = _cache.get("teacher_full", {}).get(name, name)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/room/{label}")
|
||||
def room(label: str, force: bool = Query(False)):
|
||||
data, _ = _get_data(force=force)
|
||||
_, rooms_map = _get_index(force=force)
|
||||
result = build_room_schedule(rooms_map, label, build_day_times(data))
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Аудитория «{label}» не найдена")
|
||||
result["info"] = _cache.get("room_info", {}).get(label, {}).get("info", "")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/rooms-free")
|
||||
def rooms_free(
|
||||
day: str,
|
||||
period: int,
|
||||
force: bool = Query(False),
|
||||
):
|
||||
normalized = day.strip().lower()
|
||||
if normalized not in DAYS:
|
||||
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
|
||||
if period < 1 or period > 8:
|
||||
raise HTTPException(status_code=400, detail="Пара должна быть от 1 до 8")
|
||||
_, rooms_map = _get_index(force=force)
|
||||
room_info = _cache.get("room_info", {})
|
||||
free = []
|
||||
for label in sort_rooms(room_info.keys()):
|
||||
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
|
||||
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
|
||||
if not busy:
|
||||
free.append({"room": label, "info": room_info[label].get("info", "")})
|
||||
return free
|
||||
|
||||
|
||||
@app.get("/rooms-day")
|
||||
def rooms_day(day: str, force: bool = Query(False)):
|
||||
normalized = day.strip().lower()
|
||||
if normalized not in DAYS:
|
||||
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
|
||||
data, _ = _get_data(force=force)
|
||||
_, rooms_map = _get_index(force=force)
|
||||
room_info = _cache.get("room_info", {})
|
||||
day_times = build_day_times(data).get(normalized, {})
|
||||
|
||||
labels = set(rooms_map.keys()) | set(room_info.keys())
|
||||
rooms_out = []
|
||||
for label in sort_rooms(labels):
|
||||
info = room_info.get(label, {}).get("info", "")
|
||||
occupied_day = rooms_map.get(label, {}).get(normalized, {})
|
||||
periods = {}
|
||||
keys = set(occupied_day.keys()) | set(day_times.keys())
|
||||
for period in sorted(keys, key=lambda p: int(p)):
|
||||
slot = occupied_day.get(period, {})
|
||||
periods[str(period)] = {
|
||||
"time": day_times.get(period, slot.get("time", "")),
|
||||
"ч": slot.get("ч", []) or [],
|
||||
"з": slot.get("з", []) or [],
|
||||
}
|
||||
rooms_out.append({"room": label, "info": info, "periods": periods})
|
||||
return {"day": normalized, "rooms": rooms_out}
|
||||
|
||||
|
||||
@app.get("/week")
|
||||
def week(force: bool = Query(False)):
|
||||
_, weeks = _get_data(force=force)
|
||||
today = datetime.now().date()
|
||||
return {
|
||||
"today": today.isoformat(),
|
||||
"parity": current_parity(weeks, today),
|
||||
"weeks": [
|
||||
{"parity": w["parity"], "start": w["start"].isoformat(), "end": w["end"].isoformat()}
|
||||
for w in weeks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
def status(force: bool = Query(False)):
|
||||
_get_data(force=force)
|
||||
return {
|
||||
"cache_ttl_seconds": CACHE_TTL,
|
||||
"last_updated": (
|
||||
datetime.fromtimestamp(_cache["loaded_at"], datetime.now().astimezone().tzinfo).isoformat() if _cache["loaded_at"] else None
|
||||
),
|
||||
"table_modified": _cache["modified"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/schedule/{group}")
|
||||
def schedule(group: str, force: bool = Query(False)):
|
||||
data, _ = _get_data(force=force)
|
||||
if group not in data:
|
||||
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
|
||||
return build_group_schedule(data, group)
|
||||
|
||||
|
||||
@app.get("/schedule/{group}/{day}")
|
||||
def schedule_day(group: str, day: str, force: bool = Query(False)):
|
||||
data, _ = _get_data(force=force)
|
||||
if group not in data:
|
||||
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
|
||||
normalized = day.strip().lower()
|
||||
if normalized not in DAYS:
|
||||
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
|
||||
sched = build_group_schedule(data, group)
|
||||
day_data = sched["days"].get(normalized, {})
|
||||
return {"group": group, "day": normalized, "periods": day_data}
|
||||
|
||||
|
||||
@app.get("/today")
|
||||
def today(group: str, force: bool = Query(False)):
|
||||
data, _ = _get_data(force=force)
|
||||
if group not in data:
|
||||
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
|
||||
weekday = datetime.now().weekday()
|
||||
day_name = DAYS[weekday]
|
||||
sched = build_group_schedule(data, group)
|
||||
return {"group": group, "date": datetime.now().date().isoformat(), "day": day_name, "periods": sched["days"].get(day_name, {})}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
|
||||
Reference in New Issue
Block a user