From 86c2a3097debf268cada95d72b55e4bdb9129368 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 9 Sep 2026 11:33:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4:=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=20schedule/=20=D0=BF=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=D0=BC=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8,=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BE=D0=B1=D1=89=D1=91=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D1=8F=D1=87=D0=B5=D0=B5?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 3 +- src/backend/main.py | 486 +++++++++---------- src/backend/schedule/__init__.py | 14 + src/backend/schedule/cache.py | 170 +++++++ src/backend/schedule/errors.py | 5 + src/backend/schedule/lessons.py | 511 ++++++++++++++++++++ src/backend/schedule/parsing.py | 390 +++++++++++++++ src/backend/schedule/source.py | 45 ++ src/backend/schedule/xlsx.py | 235 +++++++++ src/backend/schedule_parser.py | 799 ------------------------------- 10 files changed, 1601 insertions(+), 1057 deletions(-) create mode 100644 src/backend/schedule/__init__.py create mode 100644 src/backend/schedule/cache.py create mode 100644 src/backend/schedule/errors.py create mode 100644 src/backend/schedule/lessons.py create mode 100644 src/backend/schedule/parsing.py create mode 100644 src/backend/schedule/source.py create mode 100644 src/backend/schedule/xlsx.py delete mode 100644 src/backend/schedule_parser.py diff --git a/.env.example b/.env.example index cff39dd..570529b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -schedule_link= +SCHEDULE_LINK= SCHEDULE_CACHE_TTL=900 +SCHEDULE_RETRY_AFTER=60 API_PORT=8000 FRONTEND_PORT=8080 SCHEDULE_API=http://api:8000 diff --git a/src/backend/main.py b/src/backend/main.py index 3fba284..c911a3b 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -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) diff --git a/src/backend/schedule/__init__.py b/src/backend/schedule/__init__.py new file mode 100644 index 0000000..9346326 --- /dev/null +++ b/src/backend/schedule/__init__.py @@ -0,0 +1,14 @@ +"""Пакет для загрузки и разбора расписания из Яндекс.Таблиц (xlsx). + +Структура: + xlsx — чтение xlsx-рабочей книги (ячейки, мержи, скрытые столбцы, красный текст); + source — загрузка файла по публичной ссылке Яндекс.Диска; + lessons — разбор текста ячейки занятия (предмет/преподаватель/аудитория/заметки); + parsing — разбор листов: группы, недели, индексы преподавателей и аудиторий; + cache — кэш загруженной таблицы (TTL, перезагрузка, stale-while-error). +""" + +from .cache import ScheduleCache +from .errors import ScheduleError + +__all__ = ["ScheduleCache", "ScheduleError"] diff --git a/src/backend/schedule/cache.py b/src/backend/schedule/cache.py new file mode 100644 index 0000000..13042b7 --- /dev/null +++ b/src/backend/schedule/cache.py @@ -0,0 +1,170 @@ +"""Кэш загруженного расписания. + +- однопоточная перезагрузка (lock): параллельные запросы во время загрузки + ждут один download вместо N скачиваний; +- TTL, а также проверка modified таблицы: если таблица не менялась, + скачивание не повторяется; +- stale-while-error: если таблица перестала скачиваться, отдаём последнее + удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд + (вместо скачивания на каждый запрос). +""" + +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime + +from . import parsing +from .errors import ScheduleError +from .source import download_schedule, fetch_table_modified +from .xlsx import parse_workbook + +log = logging.getLogger(__name__) + + +class ScheduleCache: + def __init__( + self, + link: str | None, + ttl_seconds: float = 900.0, + retry_after: float = 60.0, + ) -> None: + if not link: + raise ScheduleError("Не задана переменная окружения SCHEDULE_LINK") + self._link = link + self._ttl = ttl_seconds + self._retry_after = retry_after + self._lock = threading.RLock() + + self._data: dict | None = None + self._weeks: list[dict] = [] + self._index: tuple[dict, dict] | None = None + self._day_times: dict = {} + self._teacher_full: dict = {} + self._room_info: dict = {} + self._loaded_at: float = 0.0 + self._modified: str | None = None + self._error: str | None = None + self._last_failed: float = 0.0 + + # --- публичное API --------------------------------------------------------- + + @property + def teacher_full(self) -> dict: + return self._teacher_full + + @property + def room_info(self) -> dict: + return self._room_info + + def get(self) -> tuple[dict, list[dict]]: + """(data, weeks); бросает ScheduleError, если данных нет совсем.""" + with self._lock: + self._ensure_fresh() + assert self._data is not None + return self._data, self._weeks + + def get_index(self) -> tuple[dict, dict]: + """(teachers, rooms) — индексы занятий по преподавателям и аудиториям.""" + with self._lock: + self._ensure_fresh() + assert self._index is not None + return self._index + + def get_day_times(self) -> dict: + with self._lock: + self._ensure_fresh() + return self._day_times + + def status(self) -> dict: + with self._lock: + return { + "cache_ttl_seconds": self._ttl, + "last_updated": self._iso(self._loaded_at), + "table_modified": self._modified, + "error": self._error, + } + + def warmup(self) -> None: + """Первый запуск данных на старте приложения (ошибка не фатальна).""" + try: + self.get() + except ScheduleError as exc: + log.warning("Начальная загрузка расписания не удалась: %s", exc) + + # --- internals -------------------------------------------------------------- + + @staticmethod + def _iso(ts: float) -> str | None: + if not ts: + return None + return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat() + + def _ensure_fresh(self) -> None: + now = time.time() + fresh = ( + self._data is not None + and self._loaded_at + and self._error is None + and now - self._loaded_at < self._ttl + ) + if fresh: + return + self._reload(now) + + def _reload(self, now: float) -> None: + # служим старые данные, пока не истекло время повторной попытки + if ( + self._data is not None + and self._error is not None + and now - self._last_failed < self._retry_after + ): + return + modified = fetch_table_modified(self._link) + if ( + self._data is not None + and modified is not None + and modified == self._modified + ): + # таблица не менялась — источник доступен, данные актуальны + self._loaded_at = now + self._error = None + return + try: + log.info("Загружаю расписание по ссылке %s ...", self._link) + started = time.time() + xlsx = download_schedule(self._link) + sheets = parse_workbook(xlsx) + data = parsing.parse_schedule(sheets) + weeks = parsing.extract_week_ranges(sheets) + index = parsing.build_teacher_room_index(data) + self._data = data + self._weeks = weeks + self._index = index + self._day_times = parsing.build_day_times(data) + self._teacher_full = parsing.parse_teachers_full(sheets) + self._room_info = parsing.parse_rooms_info(sheets) + self._modified = modified + self._loaded_at = now + self._error = None + log.info( + "Расписание загружено: %d групп за %.1f с", len(data), time.time() - started + ) + except ScheduleError as exc: + self._fail(exc, now) + except Exception as exc: # непредвиденное в парсинге/сети + log.exception("Ошибка при загрузке расписания") + self._fail(ScheduleError(f"Не удалось обработать таблицу: {exc}"), now) + + def _fail(self, exc: ScheduleError, now: float) -> None: + self._error = str(exc) + self._last_failed = now + if self._data is None: + raise exc + log.warning( + "Обновление не удалось (%s), отдам предыдущее состояние ещё %.0f с", + exc, + self._retry_after, + ) diff --git a/src/backend/schedule/errors.py b/src/backend/schedule/errors.py new file mode 100644 index 0000000..307653a --- /dev/null +++ b/src/backend/schedule/errors.py @@ -0,0 +1,5 @@ +"""Исключения пакета schedule, которые показывают пользователю как текст ошибки.""" + + +class ScheduleError(Exception): + """Ошибка загрузки или разбора расписания (ссылка, сеть, xlsx, лист).""" diff --git a/src/backend/schedule/lessons.py b/src/backend/schedule/lessons.py new file mode 100644 index 0000000..8ba44de --- /dev/null +++ b/src/backend/schedule/lessons.py @@ -0,0 +1,511 @@ +"""Разбор текста ячейки занятия. + +В одной ячейке может быть одно или несколько занятий, оформлено строго +необязательное: маркер, время, локация, примечания пишутся как придумается. +Поэтому разбор не ветвится по количеству строк, а проходит один конвейер: + +1. красные фрагменты таблицы (⟪...⟫) выносятся в заметки и удаляются из текста — + они служат только примечаниями, даже если внутри «вложено» другое занятие; +2. извлекается маркер «к.в.» (своя строка или префикс первой строки); +3. каждая строка классифицируется по ролям: + - аудитория — есть «ауд.»/«каб.», URL, «онлайн», «зал...» (вместе с названием + корпуса, номерами вариантов, временем начала); + - преподаватель — фамилия + инициалы или полное ФИО, организация; + в строке с преподавателем может присутствовать аудитория; + - предмет — всё, что идёт до преподавателя/аудитории; + - заметка — время/даты/ссылки; + - организация — короткое имя капсом («НПО Криста», «ПравоТех»): преподаватель, + если преподавателя ещё нет, иначе — место проведения. +4. остаток собирается по позиции: предмет сначала, после преподавателя/аудитории + неклассифицированное без аудитории — локация (аудитория), дальше — заметки. +""" + +from __future__ import annotations + +import re +from dataclasses import asdict, dataclass, replace +from typing import Optional + +from .xlsx import RED_CLOSE, RED_OPEN + +# --- маркер недели занятия («к.в.» = курсовая/ведомостная неделя) --------------- +_MARKER_LINE_RE = re.compile(r"^\s*к\.в\.?\s*[-–—]?\s*(\d{1,2})?\s*$", re.I) +_MARKER_PREFIX_RE = re.compile(r"^\s*(к\.в\.?\s*[-–—]?\s*(\d{1,2})?)\s+(?=\S)", re.I) + +# --- роли ------------------------------------------------------------------- +_TIME_RE = re.compile(r"\d{1,2}:\d{2}|\d{1,2}\.\d{1,2}\.\d{2,4}") +_URL_RE = re.compile(r"https?://\S+") +_ROOM_REF_RE = re.compile(r"(?:ауд|каб)\.?\s*[\w\-]*", re.I) +_ONLINE_RE = re.compile(r"\bонлайн\b", re.I) +_ROOM_START_RE = re.compile(r"^\s*(?:зал|каб)\b", re.I) +# время в начале строки: «начало в 12:00», «с 13:45 до 15:15» +_TIME_PREFIX_RE = re.compile( + r"^(?P