Бэкенд: пакет 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user