Бэкенд: пакет schedule/ по зонам ответственности, обобщённый разбор ячеек

- xlsx.py: чтение книги (мержи, скрытые столбцы, красный текст), фикс дат до 1900
- lessons.py: общая схема ролей (предмет/преподаватель/аудитория/заметка)
  вместо ветвлений по числу строк; маркеры к.в., многострочные заметки,
  ФИО с/без отчества, организации, локации, метки групп -> заметки
- source.py: загрузка с Яндекс.Диска с понятными ошибками
- parsing.py: листы -> расписание, недели семестра, индексы преподов/аудиторий
- cache.py: lock, TTL, проверка modified, stale-while-error с retry cooldown
- main.py: create_app, /healthz, логирование; force-обновление убрано (вернётся с авторизацией)
- переменная окружения SCHEDULE_LINK
This commit is contained in:
2026-09-09 11:33:03 +03:00
parent dcd9b854a0
commit 86c2a3097d
10 changed files with 1601 additions and 1057 deletions

View File

@@ -1,33 +1,27 @@
"""API-приложение расписания (FastAPI)."""
from __future__ import annotations
import logging
import os
import time
import threading
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import date
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,
)
from schedule import ScheduleCache, ScheduleError
from schedule import parsing
def _find_dotenv():
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def _find_dotenv() -> Path | None:
"""Ищем .env вверх от каталога модуля (локальная разработка)."""
path = Path(__file__).resolve().parent
for _ in range(5):
env = path / ".env"
@@ -43,256 +37,234 @@ _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": {},
}
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "900"))
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
@asynccontextmanager
async def lifespan(app: FastAPI):
def _default_cache() -> ScheduleCache:
try:
_load_data()
except Exception:
pass
yield
return ScheduleCache(SCHEDULE_LINK, ttl_seconds=CACHE_TTL, retry_after=RETRY_AFTER)
except ScheduleError:
log.error("SCHEDULE_LINK не задан — API данных работать не будет")
return _BrokenCache()
app = FastAPI(
title="Расписание",
description="Расписание групп из Яндекс.Таблицы",
lifespan=lifespan,
)
class _BrokenCache(ScheduleCache):
"""Кэш без ссылки: все запросы данных отвечают понятной ошибкой."""
def __init__(self) -> None:
self._link = None
self._ttl = CACHE_TTL
self._retry_after = RETRY_AFTER
self._lock = threading.RLock()
self._data = None
self._weeks = []
self._index = None
self._day_times = {}
self._teacher_full = {}
self._room_info = {}
self._loaded_at = 0.0
self._modified = None
self._error = "Не задана переменная окружения SCHEDULE_LINK"
self._last_failed = 0.0
def _ensure_fresh(self) -> None:
raise ScheduleError(self._error)
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 _day_arg(day: str) -> str:
normalized = day.strip().lower()
if normalized not in parsing.DAYS:
raise HTTPException(
status_code=400,
detail=f"День должен быть одним из: {', '.join(parsing.DAYS)}",
)
return normalized
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 create_app(cache: ScheduleCache | None = None) -> FastAPI:
cache = cache or _default_cache()
@asynccontextmanager
async def lifespan(app: FastAPI):
cache.warmup()
yield
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 = FastAPI(
title="Расписание",
description="Расписание групп из Яндекс.Таблицы",
lifespan=lifespan,
)
app.state.cache = cache
def _data() -> tuple[dict, list[dict]]:
try:
return cache.get()
except ScheduleError as exc:
raise HTTPException(status_code=502, detail=str(exc))
def _index() -> tuple[dict, dict]:
try:
return cache.get_index()
except ScheduleError as exc:
raise HTTPException(status_code=502, detail=str(exc))
# --- обслуживание -----------------------------------------------------------
@app.get("/healthz")
def healthz() -> dict:
"""Живость процесса; не зависит от доступности таблицы."""
return {"ok": True}
@app.get("/status")
def status() -> dict:
return cache.status()
# --- справочники -------------------------------------------------------------
@app.get("/")
def index() -> dict:
return {
"title": "Расписание",
"endpoints": {
"/groups": "Список групп",
"/schedule/{group}": "Расписание группы",
"/schedule/{group}/{day}": "Расписание группы на день",
"/today?group={group}": "Расписание группы на сегодня",
"/teachers": "Список преподавателей",
"/teacher/{name}": "Расписание преподавателя",
"/rooms": "Список аудиторий",
"/room/{label}": "Занятость аудитории",
"/rooms-day?day={день}": "Занятость всех аудиторий на день",
"/rooms-free?day={день}&period={N}": "Свободные аудитории",
"/week": "Недели семестра и текущая чётность",
"/status": "Состояние кэша",
},
}
@app.get("/groups")
def groups() -> list[str]:
data, _ = _data()
return sorted(
data.keys(),
key=lambda g: (0 if g.endswith("БО") else (1 if g.endswith("МО") else 2), g),
)
@app.get("/teachers")
def teachers() -> list[str]:
teachers_map, _ = _index()
return sorted(teachers_map.keys())
@app.get("/rooms")
def rooms() -> list[str]:
_, rooms_map = _index()
return parsing.sort_rooms(set(rooms_map) | set(cache.room_info))
# --- занятия -----------------------------------------------------------------
@app.get("/schedule/{group}")
def schedule(group: str) -> dict:
data, _ = _data()
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
return parsing.build_group_schedule(data, group)
@app.get("/schedule/{group}/{day}")
def schedule_day(group: str, day: str) -> dict:
data, _ = _data()
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
normalized = _day_arg(day)
sched = parsing.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) -> dict:
data, _ = _data()
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
day_name = parsing.DAYS[date.today().weekday()]
sched = parsing.build_group_schedule(data, group)
return {
"group": group,
"date": date.today().isoformat(),
"day": day_name,
"periods": sched["days"].get(day_name, {}),
}
@app.get("/teacher/{name}")
def teacher(name: str) -> dict:
teachers_map, _ = _index()
result = parsing.build_teacher_schedule(teachers_map, name)
if result is None:
raise HTTPException(status_code=404, detail=f"Преподаватель «{name}» не найден")
result["full_name"] = cache.teacher_full.get(name, name)
return result
@app.get("/room/{label}")
def room(label: str) -> dict:
_, rooms_map = _index()
result = parsing.build_room_schedule(rooms_map, label, cache.get_day_times())
if result is None:
raise HTTPException(status_code=404, detail=f"Аудитория «{label}» не найдена")
result["info"] = cache.room_info.get(label, {}).get("info", "")
return result
@app.get("/rooms-free")
def rooms_free(day: str, period: int) -> list[dict]:
normalized = _day_arg(day)
if period < 1 or period > 8:
raise HTTPException(status_code=400, detail="Пара должна быть от 1 до 8")
_, rooms_map = _index()
room_info = cache.room_info
free = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
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.get(label, {}).get("info", "")})
return free
@app.get("/rooms-day")
def rooms_day(day: str) -> dict:
normalized = _day_arg(day)
_, rooms_map = _index()
room_info = cache.room_info
day_times = cache.get_day_times().get(normalized, {})
rooms_out = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
info = room_info.get(label, {}).get("info", "")
occupied_day = rooms_map.get(label, {}).get(normalized, {})
periods = {}
for period in sorted(set(occupied_day) | set(day_times), key=int):
slot = occupied_day.get(period, {})
periods[period] = {
"time": slot.get("time") or day_times.get(period, ""),
"ч": 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() -> dict:
_, weeks = _data()
today = date.today()
return {
"today": today.isoformat(),
"parity": parsing.current_parity(weeks, today),
"weeks": [
{"parity": w["parity"], "start": w["start"].isoformat(), "end": w["end"].isoformat()}
for w in weeks
],
}
return app
@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, {})}
app = create_app()
if __name__ == "__main__":
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)