Базовая функциональность

This commit is contained in:
2026-09-08 11:14:33 +03:00
commit dcd9b854a0
18 changed files with 2855 additions and 0 deletions

298
src/backend/main.py Normal file
View 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)