Бэкенд: пакет 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)
|
||||
|
||||
14
src/backend/schedule/__init__.py
Normal file
14
src/backend/schedule/__init__.py
Normal file
@@ -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"]
|
||||
170
src/backend/schedule/cache.py
Normal file
170
src/backend/schedule/cache.py
Normal file
@@ -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,
|
||||
)
|
||||
5
src/backend/schedule/errors.py
Normal file
5
src/backend/schedule/errors.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Исключения пакета schedule, которые показывают пользователю как текст ошибки."""
|
||||
|
||||
|
||||
class ScheduleError(Exception):
|
||||
"""Ошибка загрузки или разбора расписания (ссылка, сеть, xlsx, лист)."""
|
||||
511
src/backend/schedule/lessons.py
Normal file
511
src/backend/schedule/lessons.py
Normal file
@@ -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<time>.*?\d{1,2}:\d{2}(?:\s*(?:до|—|–|-)\s*\d{1,2}:\d{2})?)\s+(?P<rest>.*)$"
|
||||
)
|
||||
|
||||
# Имя: «Фамилия И.И.», «Фамилия А.М.», «Фамилия Г.К» — минимум одна точка
|
||||
# (чтобы «Группа Б» не принималась за преподавателя)
|
||||
_NAME_INITIALS_RE = re.compile(
|
||||
r"[А-ЯЁ][а-яё-]{1,}\s+(?:[А-ЯЁ]\.(?:\s*[А-ЯЁ]\.{0,1}){0,1}|[А-ЯЁ]\.{0,1}\s+[А-ЯЁ]\.)"
|
||||
)
|
||||
# Полное ФИО в начале строки: три и более слова, каждое с большой и не короче 3 букв
|
||||
# (под предлогами «с/в/и/по» предметы не путаются)
|
||||
_FULL_NAME_RE = re.compile(
|
||||
r"^[А-ЯЁ][а-яё-]{2,}(?:\s+[А-ЯЁ][а-яё-]{2,}){2,}(?:\s*\([^()]*\))?"
|
||||
)
|
||||
# Организация/локация: «НПО Криста», «ПравоТех», «МАИС»
|
||||
_ORG_WORD_RE = re.compile(r"^[A-ZА-ЯЁ][A-Za-zА-ЯЁа-яё0-9&\-]*$")
|
||||
|
||||
|
||||
def _is_org_line(line: str) -> bool:
|
||||
"""Организация или аббревиатура-локация (но не «Деловое общение»)."""
|
||||
if len(line) > 30 or _TIME_RE.search(line):
|
||||
return False
|
||||
words = line.split()
|
||||
if not 1 <= len(words) <= 3:
|
||||
return False
|
||||
if any(not _ORG_WORD_RE.match(w) for w in words):
|
||||
return False
|
||||
if len(words) == 1:
|
||||
# одно слово: аббревиатура (МАИС, ДСП) или camelcase-название (ПравоТех)
|
||||
return words[0].isupper() and len(words[0]) <= 12 or re.search(
|
||||
r"[a-zа-яё][A-ZА-ЯЁ]", words[0]
|
||||
)
|
||||
# несколько слов: первое — короче 6 букв и все заглавные (НПО, НИУ)
|
||||
return words[0].isupper() and len(words[0]) <= 6
|
||||
|
||||
_PAREN_RE = re.compile(r"\(([^()]*)\)")
|
||||
_ROOM_WORD_RE = re.compile(r"(?:ауд|каб)\.?\s*", re.I)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Lesson:
|
||||
marker: str = ""
|
||||
subject: str = ""
|
||||
teacher: str = ""
|
||||
room: str = ""
|
||||
note: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _normalize_marker(num: Optional[str]) -> str:
|
||||
return f"к.в. - {num}" if num else "к.в."
|
||||
|
||||
|
||||
def _squish(text: str) -> str:
|
||||
return re.sub(r"\s{2,}", " ", text).strip()
|
||||
|
||||
|
||||
def _clean_name_parens(line: str) -> tuple[str, list[str]]:
|
||||
"""Скобки в строке преподавателя: со временем/датой -> заметка, остальное остаётся."""
|
||||
notes: list[str] = []
|
||||
|
||||
def repl(m: re.Match) -> str:
|
||||
inner = m.group(1).strip()
|
||||
if not inner:
|
||||
return "" # пустые скобки после выноса красного фрагмента
|
||||
if inner and _TIME_RE.search(inner):
|
||||
notes.append(inner)
|
||||
return ""
|
||||
return m.group(0)
|
||||
|
||||
return _squish(_PAREN_RE.sub(repl, line)), notes
|
||||
|
||||
|
||||
def _clean_room_parens(line: str) -> tuple[str, list[str]]:
|
||||
"""Скобки в строке аудитории: «(221)»/«(л)» остаются, пояснения -> заметки."""
|
||||
notes: list[str] = []
|
||||
|
||||
def repl(m: re.Match) -> str:
|
||||
inner = m.group(1).strip()
|
||||
keep = (
|
||||
not inner
|
||||
or len(inner) <= 2
|
||||
or inner.isupper()
|
||||
or not re.search(r"[а-яёА-ЯЁ]", inner)
|
||||
)
|
||||
if keep:
|
||||
return m.group(0)
|
||||
notes.append(inner)
|
||||
return ""
|
||||
|
||||
cleaned = _PAREN_RE.sub(repl, line)
|
||||
cleaned = _ROOM_WORD_RE.sub(
|
||||
lambda m: "ауд. " if m.group(0)[:3].lower() == "ауд" else "каб. ", cleaned
|
||||
)
|
||||
return _squish(cleaned).strip(" ,"), notes
|
||||
|
||||
|
||||
@dataclass
|
||||
class _LineRoles:
|
||||
subject: str = ""
|
||||
teacher: str = ""
|
||||
room: str = ""
|
||||
org: str = ""
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _line_is_room(line: str) -> bool:
|
||||
core = _PAREN_RE.sub("", line) # «… (онлайн)» — маркер предмета, не аудитория
|
||||
return bool(
|
||||
_ROOM_REF_RE.search(core)
|
||||
or _URL_RE.search(line)
|
||||
or _ONLINE_RE.search(core)
|
||||
or _ROOM_START_RE.match(core)
|
||||
)
|
||||
|
||||
|
||||
def _classify_line(line: str) -> _LineRoles:
|
||||
roles = _LineRoles()
|
||||
|
||||
if _line_is_room(line):
|
||||
rest = line
|
||||
# «начало занятия в 18:30 онлайн» -> время в заметку, аудитория отдельно
|
||||
prefix_m = _TIME_PREFIX_RE.match(rest)
|
||||
if prefix_m and _line_is_room(prefix_m.group("rest")):
|
||||
roles.notes = (prefix_m.group("time").strip(),)
|
||||
rest = prefix_m.group("rest")
|
||||
initial_matches = list(_NAME_INITIALS_RE.finditer(rest))
|
||||
full_m = _FULL_NAME_RE.match(rest)
|
||||
# инициалы весомее полного имени: «Сети Петри Башкин В.А. ауд. 224» —
|
||||
# три слова предмета, а не ФИО
|
||||
if initial_matches:
|
||||
first = initial_matches[0].start()
|
||||
tail = initial_matches[-1].end()
|
||||
teacher_text = _squish(" ".join(m.group(0) for m in initial_matches))
|
||||
elif full_m is not None:
|
||||
first, tail = 0, full_m.end()
|
||||
teacher_text = full_m.group(0)
|
||||
else:
|
||||
first, tail, teacher_text = -1, 0, ""
|
||||
# до аудитории без имени: предмет и (реже) организация-преподаватель
|
||||
anchors = [
|
||||
m.start()
|
||||
for m in (
|
||||
_ROOM_REF_RE.search(rest),
|
||||
_URL_RE.search(rest),
|
||||
_ONLINE_RE.search(rest),
|
||||
_ROOM_START_RE.match(rest),
|
||||
)
|
||||
if m is not None
|
||||
]
|
||||
if anchors:
|
||||
pos = min(anchors)
|
||||
if pos > 0 and rest[:1].isupper():
|
||||
pre = _squish(rest[:pos])
|
||||
# «Методы сжатия КрасновМ.В.» — инициалы без пробела
|
||||
m_unsp = re.search(
|
||||
r"([А-ЯЁ][А-ЯЁа-яё\-]*\s*[А-ЯЁ]\.\s*[А-ЯЁ]?\.*)$", pre
|
||||
)
|
||||
if m_unsp:
|
||||
teacher_text = m_unsp.group(1).strip()
|
||||
pre = _squish(pre[: m_unsp.start()])
|
||||
words = pre.split()
|
||||
last = words[-1] if words else ""
|
||||
if len(words) >= 3 and len(last) >= 4 and not last.isupper():
|
||||
# «...модели принятия решения Наука ауд.221»
|
||||
teacher_text = last
|
||||
pre = _squish(" ".join(words[:-1]))
|
||||
if pre:
|
||||
roles.subject = pre
|
||||
tail = pos
|
||||
# иначе «юридический корпус, 3 этаж ауд. 22» — целиком локация
|
||||
if first >= 0 and not roles.subject and first > 0:
|
||||
roles.subject = _squish(rest[:first])
|
||||
if teacher_text:
|
||||
roles.teacher, name_notes = _clean_name_parens(teacher_text)
|
||||
roles.notes = roles.notes + tuple(name_notes)
|
||||
room_part = _squish(rest[tail:])
|
||||
if room_part:
|
||||
roles.room, room_notes = _clean_room_parens(room_part)
|
||||
roles.notes = roles.notes + tuple(room_notes)
|
||||
return roles
|
||||
|
||||
initial_matches = list(_NAME_INITIALS_RE.finditer(line))
|
||||
if initial_matches:
|
||||
first = initial_matches[0].start()
|
||||
if first > 0:
|
||||
roles.subject = _squish(line[:first])
|
||||
roles.teacher, notes = _clean_name_parens(line[first:])
|
||||
roles.notes = tuple(notes)
|
||||
return roles
|
||||
full_m = _FULL_NAME_RE.match(line)
|
||||
if full_m is not None:
|
||||
roles.teacher, notes = _clean_name_parens(full_m.group(0))
|
||||
if full_m.end() < len(line):
|
||||
roles.subject = _squish(line[full_m.end() :])
|
||||
roles.notes = tuple(notes)
|
||||
return roles
|
||||
|
||||
if _TIME_RE.search(line):
|
||||
frag = line.strip()
|
||||
if frag.startswith("(") and frag.endswith(")") and frag.count("(") == 1:
|
||||
frag = frag[1:-1].strip()
|
||||
roles.notes = (frag,)
|
||||
return roles
|
||||
|
||||
if _is_org_line(line):
|
||||
roles.org = line
|
||||
return roles
|
||||
|
||||
subject = _squish(line)
|
||||
# «Методы сжатия КрасновМ.В.» — инициалы без пробела
|
||||
if " " in subject:
|
||||
m = re.search(r"^(\S.*\s+)([А-ЯЁ][А-ЯЁа-яё\-]*\s*[А-ЯЁ]\.\s*[А-ЯЁ]?\.*)[\s,]*$", subject)
|
||||
if m:
|
||||
roles.subject, roles.teacher = m.group(1).strip(), m.group(2).strip()
|
||||
return roles
|
||||
# «Предмет Денис Верховцев (Сигма)» — ФИО без отчества в конце строки
|
||||
m = re.search(
|
||||
r"^(.*\s)([А-ЯЁ][а-яё]{2,}\s+[А-ЯЁ][а-яё]{2,})(\s*\([^()]*\))?\s*$", subject
|
||||
)
|
||||
if m and len(subject.split()) >= 4:
|
||||
teacher = _squish(m.group(2) + " " + (m.group(3) or ""))
|
||||
roles.subject, roles.teacher = m.group(1).strip(), teacher
|
||||
return roles
|
||||
roles.subject = subject
|
||||
return roles
|
||||
|
||||
|
||||
def _line_kind(line: str) -> str:
|
||||
"""Вид строки только для разбиения ячейки на блоки занятий."""
|
||||
if _MARKER_LINE_RE.match(line) or _MARKER_PREFIX_RE.match(line):
|
||||
return "marker"
|
||||
if _line_is_room(line):
|
||||
return "room"
|
||||
if (
|
||||
_FULL_NAME_RE.match(line)
|
||||
or _NAME_INITIALS_RE.search(line)
|
||||
or _TIME_RE.search(line)
|
||||
):
|
||||
return "not-subject"
|
||||
if _is_org_line(line):
|
||||
return "org"
|
||||
if line[:1].isupper():
|
||||
return "subject"
|
||||
return "other"
|
||||
|
||||
|
||||
def _starts_new_lesson(line: str) -> bool:
|
||||
"""Строка начинает новое занятие: с прописной, и не сама аудитория/ссылка/локация."""
|
||||
if not line[:1].isupper():
|
||||
return False
|
||||
if re.match(r"(?i)^(?:ауд|каб)\.?", line):
|
||||
return False
|
||||
if _URL_RE.match(line) or re.match(r"(?i)^онлайн\b", line):
|
||||
return False
|
||||
if _ROOM_START_RE.match(line):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _split_blocks(lines: list[str]) -> list[list[str]]:
|
||||
"""Ячейка -> блоки занятий.
|
||||
|
||||
Новый блок начинается:
|
||||
- после пустой строки;
|
||||
- на строке с маркером «к.в.»;
|
||||
- когда после аудитории или преподавателя строка начинается с названия
|
||||
предмета (даже если она сама содержит аудиторию, «Методы сжатия ... ауд.231»).
|
||||
Организация/локация («НПО Криста», «ИТ-11») новым занятием не считается.
|
||||
"""
|
||||
blocks: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
prev_kind = ""
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
if current:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
prev_kind = ""
|
||||
continue
|
||||
kind = _line_kind(line)
|
||||
if current and (
|
||||
kind == "marker"
|
||||
or (
|
||||
prev_kind in ("room", "not-subject")
|
||||
and kind != "org"
|
||||
and _starts_new_lesson(line)
|
||||
)
|
||||
):
|
||||
blocks.append(current)
|
||||
current = []
|
||||
current.append(line)
|
||||
prev_kind = kind
|
||||
if current:
|
||||
blocks.append(current)
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_marker(lines: list[str]) -> tuple[str, list[str]]:
|
||||
first = lines[0]
|
||||
m = _MARKER_LINE_RE.match(first)
|
||||
if m:
|
||||
return _normalize_marker(m.group(1)), lines[1:]
|
||||
m = _MARKER_PREFIX_RE.match(first)
|
||||
if m:
|
||||
rest = first[m.end(1) :].strip()
|
||||
lines = lines[1:]
|
||||
if rest:
|
||||
lines = [rest, *lines]
|
||||
return _normalize_marker(m.group(2)), lines
|
||||
return "", lines
|
||||
|
||||
|
||||
def _red_fragments(text: str) -> tuple[list[str], str]:
|
||||
"""Красные фрагменты -> примечания; текст остаётся на месте (пробелом)."""
|
||||
frag_re = re.escape(RED_OPEN) + r"(.*?)" + re.escape(RED_CLOSE)
|
||||
frags = []
|
||||
for m in re.finditer(frag_re, text, re.S):
|
||||
frag = _squish(m.group(1))
|
||||
if frag.startswith("(") and frag.endswith(")"):
|
||||
frag = frag[1:-1].strip()
|
||||
if frag:
|
||||
frags.append(frag)
|
||||
return frags, re.sub(frag_re, " ", text, flags=re.S)
|
||||
|
||||
|
||||
def _balanced(text: str) -> str:
|
||||
"""Убирает осиротевшие скобки (в таблице их иногда не закрывают)."""
|
||||
text = text.strip()
|
||||
while text and (
|
||||
(text[-1] == "(" and text.count("(") > text.count(")"))
|
||||
or (text[-1] == ")" and text.count(")") > text.count("("))
|
||||
):
|
||||
text = text[:-1].rstrip()
|
||||
return text
|
||||
|
||||
|
||||
def _parse_block(block: list[str]) -> Optional[Lesson]:
|
||||
text = "\n".join(block)
|
||||
red_notes, text = _red_fragments(text)
|
||||
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
|
||||
if not lines:
|
||||
if red_notes:
|
||||
# в ячейке только примечание (например, «пары не будет»)
|
||||
return Lesson(note="\n".join(dict.fromkeys(red_notes)))
|
||||
return None
|
||||
|
||||
marker, lines = _extract_marker(lines)
|
||||
if not lines:
|
||||
if red_notes:
|
||||
# в ячейке только примечание (например, «пары не будет»)
|
||||
return Lesson(marker=marker, note="\n".join(dict.fromkeys(red_notes)))
|
||||
return None # в ячейке только маркер — занятия ещё нет
|
||||
|
||||
subjects: list[str] = []
|
||||
teachers: list[str] = []
|
||||
rooms: list[str] = []
|
||||
notes: list[str] = list(red_notes)
|
||||
person_seen = False
|
||||
|
||||
for line in lines:
|
||||
roles = _classify_line(line)
|
||||
notes.extend(roles.notes)
|
||||
if roles.subject:
|
||||
if not person_seen:
|
||||
# однословная строка в позиции преподавателя — организация: «Хинт»
|
||||
if (
|
||||
subjects
|
||||
and " " not in roles.subject
|
||||
and not re.search(r"\d", roles.subject)
|
||||
and 2 <= len(roles.subject) <= 12
|
||||
):
|
||||
teachers.append(roles.subject)
|
||||
person_seen = True
|
||||
else:
|
||||
subjects.append(roles.subject)
|
||||
elif not rooms:
|
||||
rooms.append(roles.subject) # локация без номера: «первый этаж, ...»
|
||||
else:
|
||||
notes.append(roles.subject) # «ссылка в эл. университете»
|
||||
if roles.teacher:
|
||||
teachers.append(roles.teacher)
|
||||
person_seen = True
|
||||
if roles.room:
|
||||
rooms.append(roles.room)
|
||||
person_seen = True
|
||||
if roles.org:
|
||||
if not person_seen and not subjects:
|
||||
subjects.append(roles.org) # «МАИС», «БЖД» — предмет-аббревиатура
|
||||
elif " " not in roles.org and re.search(r"\d", roles.org):
|
||||
notes.append(roles.org) # «ИТ-11» — метка параллельной группы
|
||||
elif teachers:
|
||||
rooms.append(roles.org) # место проведения: «НПО Криста»
|
||||
else:
|
||||
teachers.append(roles.org) # занятие ведёт организация: «ПравоТех»
|
||||
|
||||
return Lesson(
|
||||
marker=marker,
|
||||
subject=_squish(" ".join(subjects)),
|
||||
teacher=_balanced(_squish(", ".join(dict.fromkeys(t for t in teachers if t)))),
|
||||
room=_balanced(_squish(" / ".join(dict.fromkeys(r for r in rooms if r)))),
|
||||
note=_balanced("\n".join(dict.fromkeys(n for n in notes if n))),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_marker_of(line: str) -> Optional[str]:
|
||||
m = _MARKER_LINE_RE.match(line) or _MARKER_PREFIX_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
num = m.group(1) if m.re is _MARKER_LINE_RE else m.group(2)
|
||||
return _normalize_marker(num or None)
|
||||
|
||||
|
||||
def parse_lesson_cell(cell_text: str) -> list[Lesson]:
|
||||
"""Текст ячейки -> список занятий."""
|
||||
if not cell_text or not cell_text.strip():
|
||||
return []
|
||||
lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
lessons: list[Lesson] = []
|
||||
last_marker = ""
|
||||
for block in _split_blocks(lines):
|
||||
if len(block) == 1 and not _line_is_room(block[0]):
|
||||
standalone = _normalize_marker_of(block[0])
|
||||
if standalone:
|
||||
last_marker = standalone
|
||||
continue
|
||||
lesson = _parse_block(block)
|
||||
if lesson is None:
|
||||
continue
|
||||
if lesson.marker:
|
||||
last_marker = lesson.marker
|
||||
elif last_marker:
|
||||
lesson = replace(lesson, marker=last_marker)
|
||||
lessons.append(lesson)
|
||||
return lessons
|
||||
|
||||
|
||||
# --- извлечение имён преподавателей (для индексов) -----------------------------
|
||||
|
||||
def is_teacher_name(text: str) -> bool:
|
||||
return bool(_NAME_INITIALS_RE.search(text) or _FULL_NAME_RE.match(text.strip()))
|
||||
|
||||
|
||||
def extract_teacher_names(teacher: str) -> list[str]:
|
||||
"""Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса).
|
||||
|
||||
«Смирнов А.В., Петров П.П.» -> оба; «Лабеев Игорь Владимирович» -> «Лабеев И.В.»;
|
||||
«ПравоТех» -> как есть.
|
||||
"""
|
||||
if not teacher:
|
||||
return []
|
||||
names = [m.strip() for m in _NAME_INITIALS_RE.findall(teacher) if m.strip()]
|
||||
if not names:
|
||||
full = re.sub(r"\s*\([^()]*\)", "", teacher).strip()
|
||||
full_m = _FULL_NAME_RE.match(full)
|
||||
if full_m:
|
||||
parts = full_m.group(0).split()
|
||||
short = parts[0] + " " + parts[1][0].upper() + "."
|
||||
if len(parts) > 2:
|
||||
short += parts[2][0].upper() + "."
|
||||
return [short]
|
||||
if (
|
||||
not re.search(r"\d|\s", teacher)
|
||||
or (len(teacher) <= 30 and " " in teacher)
|
||||
) and not re.search(r"[а-яё][А-ЯЁ]", teacher):
|
||||
return [teacher] # организация одним словом или «НПО Криста»
|
||||
return names
|
||||
390
src/backend/schedule/parsing.py
Normal file
390
src/backend/schedule/parsing.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""Разбор листов рабочей книги в структуру расписания.
|
||||
|
||||
Основной лист «Бакалавры и магистры» имеет сетку:
|
||||
столбец 0-1 — даты недели числителей («с»/«по»),
|
||||
столбец 2-3 — dates недели знаменателей,
|
||||
столбец 4 — день недели (в первой строке блока дня),
|
||||
столбец 5 — номер пары и время (объединён на обе строки пары),
|
||||
столбец 6+ — столбцы групп (скрытые столбцы игнорируются).
|
||||
|
||||
Строки блока дня идут в хронологии семестра парами: первая строка пары —
|
||||
неделя числителей, вторая — знаменателей (даты в левой колонке каждой пары).
|
||||
Ячейки занятия разбирает lessons.parse_lesson_cell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from .errors import ScheduleError
|
||||
from .lessons import extract_teacher_names, parse_lesson_cell
|
||||
from .xlsx import RED_CLOSE, RED_OPEN, Worksheet, excel_serial_to_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAIN_SHEET = "Бакалавры и магистры"
|
||||
ROOMS_SHEET = "аудитории"
|
||||
TEACHERS_SHEET = "Преподаватели"
|
||||
|
||||
DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||
PARITIES = ("ч", "з")
|
||||
|
||||
_COL_DAY = 4 # день недели
|
||||
_COL_PERIOD = 5 # пара и время
|
||||
_FIRST_GROUP_COL = 6
|
||||
|
||||
_GROUP_RE = re.compile(r"^[А-ЯЁ]{2,4}-\d")
|
||||
_PERIOD_RE = re.compile(r"^\s*(\d{1,2})\s*(.*)$")
|
||||
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.I)
|
||||
_SEMESTER_DATE_RE = re.compile(r"(\d{1,2}\.\d{1,2}\.\d{2})")
|
||||
_TIME_SPAN_RE = re.compile(r"\s*[-–—]\s*")
|
||||
|
||||
|
||||
def _strip_marks(text: str) -> str:
|
||||
return text.replace(RED_OPEN, "").replace(RED_CLOSE, "").strip()
|
||||
|
||||
|
||||
def _normalize_header(text: str) -> str:
|
||||
return _strip_marks(text).split("\n", 1)[0].strip()
|
||||
|
||||
|
||||
def _semester_of(header_cell: str) -> str | None:
|
||||
"""Дата окончания семестра из второй строки заголовка группы."""
|
||||
parts = header_cell.split("\n", 1)
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
m = _SEMESTER_DATE_RE.search(_strip_marks(parts[1]))
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _parse_period_cell(text: str) -> tuple[int | None, str]:
|
||||
"""'1|9:00-|10:35' / '3 13:20- 14:55' -> (номер пары, '13:20-14:55')."""
|
||||
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
|
||||
if not lines:
|
||||
return None, ""
|
||||
m = _PERIOD_RE.match(lines[0])
|
||||
if not m:
|
||||
return None, ""
|
||||
parts = [m.group(2).strip(), *lines[1:]]
|
||||
return int(m.group(1)), _TIME_SPAN_RE.sub("-", " ".join(p for p in parts if p))
|
||||
|
||||
|
||||
# --- недели и чётность ---------------------------------------------------------
|
||||
|
||||
|
||||
def extract_week_ranges(sheets: dict[str, Worksheet]) -> list[dict]:
|
||||
"""Все циклы семестра: [{'parity', 'start', 'end'}...] в хронологии."""
|
||||
sheet = sheets.get(MAIN_SHEET)
|
||||
if sheet is None:
|
||||
return []
|
||||
ranges: list[dict] = []
|
||||
seen: set[tuple[str, date]] = set()
|
||||
for row in sheet.rows[1:]:
|
||||
if not any(row[i] for i in range(4)):
|
||||
continue
|
||||
for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)):
|
||||
start = excel_serial_to_date(row[start_col])
|
||||
end = excel_serial_to_date(row[end_col])
|
||||
if start is None or end is None:
|
||||
continue
|
||||
key = (parity, start)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
ranges.append({"parity": parity, "start": start, "end": end})
|
||||
return sorted(ranges, key=lambda w: w["start"])
|
||||
|
||||
|
||||
def current_parity(week_ranges: list[dict], today: date | None = None) -> str | None:
|
||||
today = today or date.today()
|
||||
for week in week_ranges:
|
||||
if week["start"] <= today <= week["end"]:
|
||||
return week["parity"]
|
||||
return None
|
||||
|
||||
|
||||
# --- главный лист -------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_group_columns(header: list[str], hidden_cols: set[int]) -> dict[int, str]:
|
||||
columns: dict[int, str] = {}
|
||||
seen: set[str] = set()
|
||||
for idx, cell in enumerate(header):
|
||||
if idx in hidden_cols:
|
||||
continue
|
||||
name = _normalize_header(cell)
|
||||
if name and _GROUP_RE.match(name) and name not in seen:
|
||||
columns[idx] = name
|
||||
seen.add(name)
|
||||
return columns
|
||||
|
||||
|
||||
def parse_schedule(sheets: dict[str, Worksheet]) -> dict:
|
||||
"""Главный лист -> {группа: {'semester_until', 'days': {день: {пара: slot}}}}.
|
||||
|
||||
slot = {'time', 'ч': raw|None, 'з': raw|None, 'lessons': {'ч': [...], 'з': [...]}}
|
||||
"""
|
||||
sheet = sheets.get(MAIN_SHEET)
|
||||
if sheet is None:
|
||||
raise ScheduleError(f"Лист «{MAIN_SHEET}» не найден в таблице")
|
||||
rows = sheet.rows
|
||||
header = rows[0]
|
||||
group_columns = _find_group_columns(header, sheet.hidden_cols)
|
||||
groups = {
|
||||
name: {"semester_until": _semester_of(header[col]), "days": {}}
|
||||
for col, name in group_columns.items()
|
||||
}
|
||||
day_times: dict[str, dict[int, str]] = {}
|
||||
|
||||
cur_day: str | None = None
|
||||
cur_period: int | None = None
|
||||
cur_time = ""
|
||||
parity: str | None = None
|
||||
rows_in_day = 0
|
||||
|
||||
for row in rows[1:]:
|
||||
if not any(row[i] for i in range(4)):
|
||||
continue
|
||||
day = _normalize_header(row[_COL_DAY]).lower()
|
||||
if day in DAYS and day != cur_day:
|
||||
if cur_day is not None and rows_in_day % 2:
|
||||
log.warning(
|
||||
"День «%s»: нечётное число строк (%d), недели могли сдвинуться",
|
||||
cur_day,
|
||||
rows_in_day,
|
||||
)
|
||||
cur_day = day
|
||||
parity = None
|
||||
rows_in_day = 0
|
||||
if cur_day is None:
|
||||
continue
|
||||
rows_in_day += 1
|
||||
|
||||
period_cell = row[_COL_PERIOD]
|
||||
if period_cell:
|
||||
period, time = _parse_period_cell(period_cell)
|
||||
if period is not None:
|
||||
cur_period = period
|
||||
cur_time = time
|
||||
day_times.setdefault(cur_day, {}).setdefault(period, time)
|
||||
if cur_period is None:
|
||||
continue
|
||||
parity = "з" if parity == "ч" else "ч"
|
||||
|
||||
for col, group_name in group_columns.items():
|
||||
text = row[col]
|
||||
if not text:
|
||||
continue
|
||||
days_map = groups[group_name]["days"].setdefault(cur_day, {})
|
||||
slot = days_map.setdefault(
|
||||
cur_period, {"time": cur_time, "ч": None, "з": None, "lessons": {}}
|
||||
)
|
||||
slot[parity] = text if slot[parity] is None else slot[parity]
|
||||
|
||||
# пары без занятий заполняем временем, как раньше
|
||||
for group in groups.values():
|
||||
for day, periods in day_times.items():
|
||||
days_map = group["days"].setdefault(day, {})
|
||||
for period, time in periods.items():
|
||||
days_map.setdefault(
|
||||
period, {"time": time, "ч": None, "з": None, "lessons": {}}
|
||||
)
|
||||
|
||||
for group in groups.values():
|
||||
for day, periods in group["days"].items():
|
||||
for slot in periods.values():
|
||||
for par in PARITIES:
|
||||
raw = slot[par]
|
||||
slot["lessons"][par] = (
|
||||
[l.to_dict() for l in parse_lesson_cell(raw)] if raw else None
|
||||
)
|
||||
group["days"][day] = dict(sorted(periods.items()))
|
||||
return groups
|
||||
|
||||
|
||||
# --- представление для API -----------------------------------------------------
|
||||
|
||||
|
||||
def build_group_schedule(data: dict, group_name: str) -> dict | None:
|
||||
group = data.get(group_name)
|
||||
if group is None:
|
||||
return None
|
||||
out = {"group": group_name, "semester_until": group["semester_until"], "days": {}}
|
||||
for day, periods in group["days"].items():
|
||||
out["days"][day] = {}
|
||||
for period, slot in sorted(periods.items()):
|
||||
entry = {"time": slot["time"]}
|
||||
for par in PARITIES:
|
||||
raw = slot[par]
|
||||
entry[par] = (
|
||||
None
|
||||
if not raw
|
||||
else {"raw": raw, "lessons": (slot["lessons"] or {}).get(par) or []}
|
||||
)
|
||||
out["days"][day][str(period)] = entry
|
||||
return out
|
||||
|
||||
|
||||
def iter_lessons(schedule_data: dict):
|
||||
"""Все занятия: (group, day, period, time, parity, lesson_dict)."""
|
||||
for group, gdata in schedule_data.items():
|
||||
for day, periods in gdata["days"].items():
|
||||
for period, slot in periods.items():
|
||||
for par in PARITIES:
|
||||
if not slot[par]:
|
||||
continue
|
||||
for lesson in slot["lessons"].get(par) or []:
|
||||
yield group, day, period, slot["time"], par, lesson
|
||||
|
||||
|
||||
def _merge_entry(slot_list: list, entry_key: dict, extra: dict) -> dict:
|
||||
for item in slot_list:
|
||||
if all(item.get(k) == v for k, v in entry_key.items()):
|
||||
return item
|
||||
item = dict(entry_key)
|
||||
item["groups"] = []
|
||||
item.update(extra)
|
||||
slot_list.append(item)
|
||||
return item
|
||||
|
||||
|
||||
def build_teacher_room_index(schedule_data: dict) -> tuple[dict, dict]:
|
||||
"""Расписание -> (индекс преподавателей, индекс аудиторий)."""
|
||||
teachers: dict = {}
|
||||
rooms: dict = {}
|
||||
for group, day, period, time, parity, lesson in iter_lessons(schedule_data):
|
||||
subject = lesson["subject"]
|
||||
room = lesson["room"].strip()
|
||||
|
||||
names = extract_teacher_names(lesson["teacher"])
|
||||
if names:
|
||||
# в ячейке может быть несколько преподавателей — заносим в каждый
|
||||
for name in names:
|
||||
slot = teachers.setdefault(name, {}).setdefault(day, {}).setdefault(
|
||||
str(period), {"time": time, "ч": [], "з": []}
|
||||
)
|
||||
item = _merge_entry(
|
||||
slot[parity],
|
||||
{"subject": subject, "room": room},
|
||||
{"marker": lesson["marker"]},
|
||||
)
|
||||
if group not in item["groups"]:
|
||||
item["groups"].append(group)
|
||||
|
||||
room_m = _ROOM_RE.match(room)
|
||||
if room_m:
|
||||
label = "ауд. " + room_m.group(1)
|
||||
slot = rooms.setdefault(label, {}).setdefault(day, {}).setdefault(
|
||||
str(period), {"time": time, "ч": [], "з": []}
|
||||
)
|
||||
display_teacher = ", ".join(names)
|
||||
item = _merge_entry(
|
||||
slot[parity],
|
||||
{"subject": subject, "teacher": display_teacher},
|
||||
{"marker": lesson["marker"]},
|
||||
)
|
||||
if group not in item["groups"]:
|
||||
item["groups"].append(group)
|
||||
return teachers, rooms
|
||||
|
||||
|
||||
def sort_rooms(room_names) -> list:
|
||||
def key(name: str):
|
||||
m = re.search(r"(\d+)", name)
|
||||
return (0, int(m.group(1))) if m else (1, name)
|
||||
|
||||
return sorted(room_names, key=key)
|
||||
|
||||
|
||||
def build_day_times(schedule_data: dict) -> dict:
|
||||
"""Время пар по дням: {'понедельник': {'1': '9:00-10:35', ...}}."""
|
||||
day_times: dict = {}
|
||||
for gdata in schedule_data.values():
|
||||
for day, periods in gdata["days"].items():
|
||||
for period, slot in periods.items():
|
||||
day_times.setdefault(day, {}).setdefault(
|
||||
str(period), slot.get("time", "")
|
||||
)
|
||||
return day_times
|
||||
|
||||
|
||||
def build_teacher_schedule(teachers: dict, name: str) -> dict | None:
|
||||
if name not in teachers:
|
||||
return None
|
||||
return {"name": name, "days": teachers[name]}
|
||||
|
||||
|
||||
def build_room_schedule(rooms: dict, label: str, day_times: dict | None = None) -> dict | None:
|
||||
if label not in rooms:
|
||||
return None
|
||||
data = rooms[label]
|
||||
day_times = day_times or {}
|
||||
out = {"room": label, "days": {}}
|
||||
all_days = {*data.keys(), *day_times.keys()}
|
||||
for day in sorted(all_days, key=lambda d: DAYS.index(d) if d in DAYS else 99):
|
||||
times = day_times.get(day, {})
|
||||
periods = {*data.get(day, {}).keys(), *times.keys()}
|
||||
out["days"][day] = {}
|
||||
for period in sorted(periods, key=int):
|
||||
occupied = data.get(day, {}).get(period, {})
|
||||
entry = {"time": occupied.get("time", times.get(period, ""))}
|
||||
for par in PARITIES:
|
||||
lessons = occupied.get(par) or []
|
||||
entry[par] = lessons if lessons else None
|
||||
out["days"][day][str(period)] = entry
|
||||
return out
|
||||
|
||||
|
||||
# --- вспомогательные листы ------------------------------------------------------
|
||||
|
||||
|
||||
def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict:
|
||||
"""Лист «аудитории»: 'ауд. N' -> {'room', 'info'} (оснащение, вместимость)."""
|
||||
sheet = sheets.get(ROOMS_SHEET)
|
||||
if sheet is None or len(sheet.rows) < 2:
|
||||
return {}
|
||||
header, desc = sheet.rows[0], sheet.rows[1]
|
||||
info: dict = {}
|
||||
for col in range(_FIRST_GROUP_COL, max(len(header), len(desc))):
|
||||
m = re.search(r"(\d+)", str(header[col]))
|
||||
if not m:
|
||||
continue
|
||||
number = m.group(1)
|
||||
text = " ".join(
|
||||
ln.strip() for ln in str(desc[col]).split("\n") if ln.strip()
|
||||
)
|
||||
label = f"ауд. {number}"
|
||||
info[label] = {"room": label, "info": _strip_marks(text)}
|
||||
return info
|
||||
|
||||
|
||||
def _initials_from_parts(first: str, patronymic: str) -> str:
|
||||
initials = ""
|
||||
if first:
|
||||
initials += first[0].upper() + "."
|
||||
if patronymic:
|
||||
initials += patronymic[0].upper() + "."
|
||||
return initials
|
||||
|
||||
|
||||
def parse_teachers_full(sheets: dict[str, Worksheet]) -> dict:
|
||||
"""Лист «Преподаватели»: 'Фамилия И.О.' -> полное ФИО (строка-заголовок)."""
|
||||
sheet = sheets.get(TEACHERS_SHEET)
|
||||
if sheet is None or len(sheet.rows) < 2:
|
||||
return {}
|
||||
header = sheet.rows[1]
|
||||
result: dict = {}
|
||||
for col in range(_FIRST_GROUP_COL, len(header)):
|
||||
value = _strip_marks(str(header[col]))
|
||||
parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()]
|
||||
if not parts:
|
||||
continue
|
||||
surname = parts[0]
|
||||
first = parts[1] if len(parts) > 1 else ""
|
||||
patronymic = parts[2] if len(parts) > 2 else ""
|
||||
short = f"{surname} {_initials_from_parts(first, patronymic)}".strip()
|
||||
full = " ".join(parts)
|
||||
if short and full:
|
||||
result.setdefault(short, full)
|
||||
return result
|
||||
45
src/backend/schedule/source.py
Normal file
45
src/backend/schedule/source.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Загрузка файла расписания по публичной ссылке Яндекс.Диска."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from .errors import ScheduleError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources"
|
||||
|
||||
|
||||
def download_schedule(public_url: str, timeout: float = 60.0) -> bytes:
|
||||
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{CLOUD_API}/download",
|
||||
params={"public_key": public_url},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
href = resp.json()["href"]
|
||||
file_resp = requests.get(href, timeout=timeout)
|
||||
file_resp.raise_for_status()
|
||||
return file_resp.content
|
||||
except (requests.RequestException, KeyError, ValueError) as exc:
|
||||
raise ScheduleError(f"Не удалось скачать таблицу: {exc}") from exc
|
||||
|
||||
|
||||
def fetch_table_modified(public_url: str, timeout: float = 20.0) -> str | None:
|
||||
"""Время последнего изменения файла на диске или None, если не удалось узнать."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
CLOUD_API,
|
||||
params={"public_key": public_url},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("modified")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
log.debug("Не удалось получить modified таблицы: %s", exc)
|
||||
return None
|
||||
235
src/backend/schedule/xlsx.py
Normal file
235
src/backend/schedule/xlsx.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Чтение xlsx-рабочей книги без сторонних библиотек.
|
||||
|
||||
Извлекает только то, что нужно для расписания:
|
||||
- строки каждого листа как матрицу строк (объединённые ячейки раскатываются
|
||||
до верхне-левого значения, пустые ячейки — ""),
|
||||
- номера скрытых столбцов (в таблице ими помечены служебные данные),
|
||||
- красные фрагменты текста, которые в таблице используются как примечания:
|
||||
они оборачиваются служебными знаками RED_OPEN/RED_CLOSE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from .errors import ScheduleError
|
||||
|
||||
XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
|
||||
RED_OPEN = "\u27EA" # ⟪ — начало красного фрагмента (примечание)
|
||||
RED_CLOSE = "\u27EB" # ⟫ — конец красного фрагмента
|
||||
|
||||
_REF_RE = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Worksheet:
|
||||
rows: list[list[str]]
|
||||
hidden_cols: set[int]
|
||||
|
||||
def cell(self, row_idx: int, col_idx: int) -> str:
|
||||
"""Значение ячейки, "" вне границ матрицы."""
|
||||
if row_idx < 0 or row_idx >= len(self.rows):
|
||||
return ""
|
||||
row = self.rows[row_idx]
|
||||
if col_idx < 0 or col_idx >= len(row):
|
||||
return ""
|
||||
return row[col_idx]
|
||||
|
||||
|
||||
def col_index(col_letter: str) -> int:
|
||||
"""'A' -> 0, 'Z' -> 25, 'AA' -> 26."""
|
||||
idx = 0
|
||||
for ch in col_letter:
|
||||
idx = idx * 26 + (ord(ch) - ord("A") + 1)
|
||||
return idx - 1
|
||||
|
||||
|
||||
def _parse_ref(ref: str) -> tuple[int, int]:
|
||||
m = _REF_RE.match(ref)
|
||||
if not m:
|
||||
raise ScheduleError(f"Некорректная ссылка на ячейку: {ref!r}")
|
||||
return col_index(m.group(1)), int(m.group(2)) - 1
|
||||
|
||||
|
||||
def excel_serial_to_date(serial: object) -> date | None:
|
||||
"""Серийная дата Excel (число или строка) в date; None, если не похоже на дату."""
|
||||
try:
|
||||
value = float(str(serial).replace(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
# Excel считает 1.01.1900 = 1 и знает о несуществующем 29.02.1900 (серия 60).
|
||||
if value <= 59:
|
||||
value += 1
|
||||
return (datetime(1899, 12, 30) + timedelta(days=value)).date()
|
||||
|
||||
|
||||
def _is_red_rgb(rgb: str | None) -> bool:
|
||||
"""Красный цвет ячейки вида FFRRGGBB."""
|
||||
if not rgb or len(rgb) < 8 or not rgb.startswith("FF"):
|
||||
return False
|
||||
try:
|
||||
r = int(rgb[2:4], 16)
|
||||
g = int(rgb[4:6], 16)
|
||||
b = int(rgb[6:8], 16)
|
||||
except ValueError:
|
||||
return False
|
||||
return r > 0x90 and g < 0x70 and b < 0x70
|
||||
|
||||
|
||||
def _parse_shared_strings(xml_bytes: bytes) -> list[str]:
|
||||
"""sharedStrings.xml -> список значений.
|
||||
|
||||
Красные ранки оборачиваются RED_OPEN/RED_CLOSE, чтобы на этапе разбора
|
||||
занятий примечания отделить от основного текста.
|
||||
"""
|
||||
root = ET.fromstring(xml_bytes)
|
||||
out: list[str] = []
|
||||
for si in root.iter(f"{{{XLSX_NS}}}si"):
|
||||
parts: list[tuple[str, bool]] = []
|
||||
for child in si:
|
||||
if child.tag == f"{{{XLSX_NS}}}t":
|
||||
parts.append((child.text or "", False))
|
||||
continue
|
||||
if child.tag != f"{{{XLSX_NS}}}r":
|
||||
continue
|
||||
t = child.find(f"{{{XLSX_NS}}}t")
|
||||
red = False
|
||||
rpr = child.find(f"{{{XLSX_NS}}}rPr")
|
||||
if rpr is not None:
|
||||
color = rpr.find(f"{{{XLSX_NS}}}color")
|
||||
if color is not None:
|
||||
red = _is_red_rgb(color.get("rgb"))
|
||||
parts.append((t.text or "", red))
|
||||
out.append(
|
||||
"".join(
|
||||
f"{RED_OPEN}{text}{RED_CLOSE}" if red else text for text, red in parts
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_sheet(xml_bytes: bytes, shared: list[str]) -> Worksheet:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
cells: dict[tuple[int, int], str] = {}
|
||||
max_row = 0
|
||||
max_col = 0
|
||||
|
||||
hidden_cols: set[int] = set()
|
||||
cols_node = root.find(f".//{{{XLSX_NS}}}cols")
|
||||
if cols_node is not None:
|
||||
for col in cols_node.iter(f"{{{XLSX_NS}}}col"):
|
||||
try:
|
||||
c_min = int(col.get("min"))
|
||||
c_max = int(col.get("max"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if col.get("hidden") == "1":
|
||||
hidden_cols.update(range(c_min - 1, c_max))
|
||||
|
||||
for row in root.iter(f"{{{XLSX_NS}}}row"):
|
||||
row_num = row.get("r")
|
||||
if not row_num:
|
||||
continue
|
||||
r = int(row_num) - 1
|
||||
for cell in row.iter(f"{{{XLSX_NS}}}c"):
|
||||
ref = cell.get("r")
|
||||
if not ref:
|
||||
continue
|
||||
c = col_index("".join(ch for ch in ref if ch.isalpha()))
|
||||
t = cell.get("t")
|
||||
v = cell.find(f"{{{XLSX_NS}}}v")
|
||||
if t == "s" and v is not None and v.text is not None:
|
||||
try:
|
||||
idx = int(v.text)
|
||||
except ValueError:
|
||||
continue
|
||||
value = shared[idx] if idx < len(shared) else ""
|
||||
elif v is not None and v.text is not None:
|
||||
value = v.text
|
||||
else:
|
||||
is_node = cell.find(f"{{{XLSX_NS}}}is")
|
||||
value = "".join(_iter_text(is_node)) if is_node is not None else ""
|
||||
if value != "":
|
||||
cells[(r, c)] = value
|
||||
max_row = max(max_row, r)
|
||||
max_col = max(max_col, c)
|
||||
|
||||
merge_node = root.find(f".//{{{XLSX_NS}}}mergeCells")
|
||||
if merge_node is not None:
|
||||
for mc in merge_node.iter(f"{{{XLSX_NS}}}mergeCell"):
|
||||
ref = mc.get("ref")
|
||||
if not ref or ":" not in ref:
|
||||
continue
|
||||
a, b = ref.split(":")
|
||||
c1, r1 = _parse_ref(a)
|
||||
c2, r2 = _parse_ref(b)
|
||||
max_row = max(max_row, r2)
|
||||
max_col = max(max_col, c2)
|
||||
top = cells.get((r1, c1), "")
|
||||
if not top:
|
||||
continue
|
||||
for rr in range(r1, r2 + 1):
|
||||
for cc in range(c1, c2 + 1):
|
||||
if (rr, cc) not in cells:
|
||||
cells[(rr, cc)] = top
|
||||
|
||||
rows = [
|
||||
[cells.get((r, c), "") for c in range(max_col + 1)]
|
||||
for r in range(max_row + 1)
|
||||
]
|
||||
return Worksheet(rows=rows, hidden_cols=hidden_cols)
|
||||
|
||||
|
||||
def _iter_text(node: ET.Element):
|
||||
for el in node.iter(f"{{{XLSX_NS}}}t"):
|
||||
if el.text:
|
||||
yield el.text
|
||||
|
||||
|
||||
def parse_workbook(xlsx_bytes: bytes) -> dict[str, Worksheet]:
|
||||
"""xlsx -> {имя листа: Worksheet}."""
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(xlsx_bytes))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ScheduleError("Скачанный файл не является xlsx") from exc
|
||||
|
||||
try:
|
||||
shared = _parse_shared_strings(zf.read("xl/sharedStrings.xml"))
|
||||
except KeyError:
|
||||
shared = []
|
||||
|
||||
rels_root = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
|
||||
rel_map = {
|
||||
rel.get("Id"): rel.get("Target")
|
||||
for rel in rels_root
|
||||
if rel.get("Id") and rel.get("Target")
|
||||
}
|
||||
|
||||
sheets: dict[str, Worksheet] = {}
|
||||
wb_root = ET.fromstring(zf.read("xl/workbook.xml"))
|
||||
for sheet in wb_root.iter(f"{{{XLSX_NS}}}sheet"):
|
||||
name = sheet.get("name")
|
||||
rid = sheet.get(f"{{{REL_NS}}}id")
|
||||
target = rel_map.get(rid or "")
|
||||
if not (name and target):
|
||||
continue
|
||||
target = target.lstrip("/")
|
||||
if target.startswith("xl/"):
|
||||
target = target[3:]
|
||||
if target.startswith("worksheets/"):
|
||||
try:
|
||||
sheets[name] = _parse_sheet(zf.read("xl/" + target), shared)
|
||||
except ScheduleError:
|
||||
raise
|
||||
except ET.ParseError as exc:
|
||||
raise ScheduleError(f"Не удалось разобрать лист «{name}»") from exc
|
||||
return sheets
|
||||
@@ -1,799 +0,0 @@
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
MAIN_SHEET = "Бакалавры и магистры"
|
||||
|
||||
DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||
|
||||
_PERIOD_RE = re.compile(r"^\s*(\d+)\s*(.*)$")
|
||||
_REF_RE = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
|
||||
|
||||
class ScheduleError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _iter_text(node):
|
||||
for el in node.iter(f"{{{XLSX_NS}}}t"):
|
||||
if el.text:
|
||||
yield el.text
|
||||
|
||||
|
||||
def _col_index(col_letter):
|
||||
idx = 0
|
||||
for ch in col_letter:
|
||||
idx = idx * 26 + (ord(ch) - ord("A") + 1)
|
||||
return idx - 1
|
||||
|
||||
|
||||
def _parse_ref(ref):
|
||||
m = _REF_RE.match(ref)
|
||||
if not m:
|
||||
raise ValueError(f"Некорректная ссылка на ячейку: {ref}")
|
||||
return _col_index(m.group(1)), int(m.group(2)) - 1
|
||||
|
||||
|
||||
RED_OPEN = "\u27EA"
|
||||
RED_CLOSE = "\u27EB"
|
||||
|
||||
|
||||
def _is_red_rgb(rgb):
|
||||
try:
|
||||
if not rgb or len(rgb) < 8 or not rgb.startswith("FF"):
|
||||
return False
|
||||
r = int(rgb[2:4], 16)
|
||||
g = int(rgb[4:6], 16)
|
||||
b = int(rgb[6:8], 16)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return r > 0x90 and g < 0x70 and b < 0x70
|
||||
|
||||
|
||||
def _parse_shared_strings(xml_bytes):
|
||||
root = ET.fromstring(xml_bytes)
|
||||
out = []
|
||||
for si in root.iter(f"{{{XLSX_NS}}}si"):
|
||||
parts = []
|
||||
for child in si:
|
||||
if child.tag == f"{{{XLSX_NS}}}t":
|
||||
parts.append((child.text or "", False))
|
||||
continue
|
||||
if child.tag != f"{{{XLSX_NS}}}r":
|
||||
continue
|
||||
t = child.find(f"{{{XLSX_NS}}}t")
|
||||
text = t.text if t is not None else ""
|
||||
red = False
|
||||
rpr = child.find(f"{{{XLSX_NS}}}rPr")
|
||||
if rpr is not None:
|
||||
color = rpr.find(f"{{{XLSX_NS}}}color")
|
||||
if color is not None:
|
||||
red = _is_red_rgb(color.get("rgb"))
|
||||
parts.append((text or "", red))
|
||||
chunks = []
|
||||
for text, red in parts:
|
||||
if red:
|
||||
chunks.append(RED_OPEN + text + RED_CLOSE)
|
||||
else:
|
||||
chunks.append(text)
|
||||
out.append("".join(chunks))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_sheet(xml_bytes, shared):
|
||||
root = ET.fromstring(xml_bytes)
|
||||
cells = {}
|
||||
max_row = 0
|
||||
max_col = 0
|
||||
hidden_cols = set()
|
||||
|
||||
cols_node = root.find(f".//{{{XLSX_NS}}}cols")
|
||||
if cols_node is not None:
|
||||
for col in cols_node.iter(f"{{{XLSX_NS}}}col"):
|
||||
if col.get("hidden") == "1":
|
||||
try:
|
||||
c_min = int(col.get("min"))
|
||||
c_max = int(col.get("max"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
hidden_cols.update(range(c_min - 1, c_max))
|
||||
|
||||
for row in root.iter(f"{{{XLSX_NS}}}row"):
|
||||
r = int(row.get("r")) - 1
|
||||
for cell in row.iter(f"{{{XLSX_NS}}}c"):
|
||||
ref = cell.get("r")
|
||||
if not ref:
|
||||
continue
|
||||
c = _col_index("".join(ch for ch in ref if ch.isalpha()))
|
||||
t = cell.get("t")
|
||||
v = cell.find(f"{{{XLSX_NS}}}v")
|
||||
if t == "s" and v is not None and v.text:
|
||||
idx = int(v.text)
|
||||
value = shared[idx] if idx < len(shared) else ""
|
||||
elif v is not None and v.text is not None:
|
||||
value = v.text
|
||||
else:
|
||||
is_node = cell.find(f"{{{XLSX_NS}}}is")
|
||||
value = "".join(_iter_text(is_node)) if is_node is not None else ""
|
||||
if value != "":
|
||||
cells[(r, c)] = value
|
||||
max_row = max(max_row, r)
|
||||
max_col = max(max_col, c)
|
||||
|
||||
merge_node = root.find(f".//{{{XLSX_NS}}}mergeCells")
|
||||
if merge_node is not None:
|
||||
for mc in merge_node.iter(f"{{{XLSX_NS}}}mergeCell"):
|
||||
ref = mc.get("ref")
|
||||
if not ref or ":" not in ref:
|
||||
continue
|
||||
a, b = ref.split(":")
|
||||
c1, r1 = _parse_ref(a)
|
||||
c2, r2 = _parse_ref(b)
|
||||
max_row = max(max_row, r2)
|
||||
max_col = max(max_col, c2)
|
||||
top = cells.get((r1, c1), "")
|
||||
if not top:
|
||||
continue
|
||||
for rr in range(r1, r2 + 1):
|
||||
for cc in range(c1, c2 + 1):
|
||||
if (rr, cc) not in cells:
|
||||
cells[(rr, cc)] = top
|
||||
|
||||
matrix = [
|
||||
[cells.get((r, c), "") for c in range(max_col + 1)]
|
||||
for r in range(max_row + 1)
|
||||
]
|
||||
return matrix, hidden_cols
|
||||
|
||||
|
||||
def _read_cell(row, col_idx):
|
||||
if col_idx < len(row):
|
||||
return row[col_idx]
|
||||
return ""
|
||||
|
||||
|
||||
def download_schedule(public_url):
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://cloud-api.yandex.net/v1/disk/public/resources/download",
|
||||
params={"public_key": public_url},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
href = resp.json()["href"]
|
||||
file_resp = requests.get(href, timeout=60)
|
||||
file_resp.raise_for_status()
|
||||
return file_resp.content
|
||||
except Exception as exc:
|
||||
raise ScheduleError(f"Не удалось скачать таблицу: {exc}") from exc
|
||||
|
||||
|
||||
def fetch_table_modified(public_url):
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://cloud-api.yandex.net/v1/disk/public/resources",
|
||||
params={"public_key": public_url},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("modified")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_workbook(xlsx_bytes):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(xlsx_bytes))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ScheduleError("Скачанный файл не является xlsx") from exc
|
||||
|
||||
shared = []
|
||||
try:
|
||||
shared = _parse_shared_strings(zf.read("xl/sharedStrings.xml"))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
rels_root = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
|
||||
rel_map = {}
|
||||
for rel in rels_root:
|
||||
rid = rel.get("Id")
|
||||
target = rel.get("Target")
|
||||
if rid and target:
|
||||
rel_map[rid] = target
|
||||
|
||||
wb_root = ET.fromstring(zf.read("xl/workbook.xml"))
|
||||
sheets = {}
|
||||
for sheet in wb_root.iter(f"{{{XLSX_NS}}}sheet"):
|
||||
name = sheet.get("name")
|
||||
rid = sheet.get(f"{{{REL_NS}}}id")
|
||||
if name and rid and rid in rel_map:
|
||||
target = rel_map[rid]
|
||||
if target.startswith("worksheets/"):
|
||||
matrix, hidden_cols = _parse_sheet(zf.read("xl/" + target), shared)
|
||||
sheets[name] = {"rows": matrix, "hidden_cols": hidden_cols}
|
||||
|
||||
return sheets
|
||||
|
||||
|
||||
def _normalize_header(text):
|
||||
return text.split("\n", 1)[0].strip()
|
||||
|
||||
|
||||
def _excel_serial_to_date(serial):
|
||||
try:
|
||||
value = float(str(serial).replace(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
if value <= 60:
|
||||
value -= 1
|
||||
return (datetime(1899, 12, 30) + timedelta(days=value)).date()
|
||||
|
||||
|
||||
def extract_week_ranges(sheets):
|
||||
if MAIN_SHEET not in sheets:
|
||||
return []
|
||||
matrix = sheets[MAIN_SHEET]["rows"]
|
||||
ranges = []
|
||||
seen = set()
|
||||
for row in matrix[1:]:
|
||||
if not any(_read_cell(row, i) for i in range(4)):
|
||||
continue
|
||||
for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)):
|
||||
start = _excel_serial_to_date(_read_cell(row, start_col))
|
||||
end = _excel_serial_to_date(_read_cell(row, end_col))
|
||||
if start and end:
|
||||
key = (parity, start)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
ranges.append({"parity": parity, "start": start, "end": end})
|
||||
return sorted(ranges, key=lambda w: w["start"])
|
||||
|
||||
|
||||
def current_parity(week_ranges, today=None):
|
||||
today = today or date.today()
|
||||
for week in week_ranges:
|
||||
if week["start"] <= today <= week["end"]:
|
||||
return week["parity"]
|
||||
return None
|
||||
|
||||
|
||||
def parse_schedule(sheets):
|
||||
if MAIN_SHEET not in sheets:
|
||||
raise ScheduleError(f"Лист «{MAIN_SHEET}» не найден в таблице")
|
||||
sheet = sheets[MAIN_SHEET]
|
||||
matrix = sheet["rows"]
|
||||
hidden_cols = sheet["hidden_cols"]
|
||||
|
||||
header = matrix[0]
|
||||
group_columns = {}
|
||||
seen_names = set()
|
||||
group_re = re.compile(r"^[А-ЯЁ]{2,4}-\d")
|
||||
for idx, cell in enumerate(header):
|
||||
if idx in hidden_cols:
|
||||
continue
|
||||
name = _normalize_header(cell)
|
||||
if name and group_re.match(name) and name not in seen_names:
|
||||
group_columns[idx] = name
|
||||
seen_names.add(name)
|
||||
|
||||
groups = {}
|
||||
for col, name in group_columns.items():
|
||||
header_text = _read_cell(header, col)
|
||||
semester = None
|
||||
if "\n" in header_text:
|
||||
second = header_text.split("\n", 1)[1].strip()
|
||||
m = re.search(r"(\d{1,2}\.\d{2}\.\d{2})", second)
|
||||
if m:
|
||||
semester = m.group(1)
|
||||
groups[name] = {"semester_until": semester, "days": {}}
|
||||
|
||||
day_periods = {}
|
||||
cur_day = None
|
||||
period = None
|
||||
cur_time = ""
|
||||
parity = None
|
||||
|
||||
for row in matrix[1:]:
|
||||
has_dates = any(_read_cell(row, i) for i in range(4))
|
||||
if not has_dates:
|
||||
continue
|
||||
|
||||
day = _read_cell(row, 4)
|
||||
if day:
|
||||
normalized = day.strip().lower()
|
||||
if normalized in DAYS:
|
||||
if normalized != cur_day:
|
||||
cur_day = normalized
|
||||
parity = None
|
||||
if cur_day is None:
|
||||
continue
|
||||
|
||||
period_cell = _read_cell(row, 5)
|
||||
if period_cell:
|
||||
lines = [ln.strip() for ln in period_cell.split("\n") if ln.strip()]
|
||||
m = _PERIOD_RE.match(lines[0])
|
||||
if m:
|
||||
period = int(m.group(1))
|
||||
time_parts = [m.group(2).strip()]
|
||||
time_parts += lines[1:]
|
||||
cur_time = " ".join(p for p in time_parts if p)
|
||||
cur_time = re.sub(r"\s*[-–—]\s*", "-", cur_time)
|
||||
day_periods.setdefault(cur_day, {}).setdefault(period, cur_time)
|
||||
|
||||
if period is None:
|
||||
continue
|
||||
|
||||
parity = "з" if parity == "ч" else "ч"
|
||||
|
||||
for col, group_name in group_columns.items():
|
||||
cell_text = _read_cell(row, col)
|
||||
if cell_text:
|
||||
days_map = groups[group_name]["days"].setdefault(cur_day, {})
|
||||
slot = days_map.setdefault(period, {"time": cur_time, "ч": None, "з": None})
|
||||
slot[parity] = cell_text
|
||||
|
||||
for group_name in groups:
|
||||
for day, periods in day_periods.items():
|
||||
days_map = groups[group_name]["days"]
|
||||
for period, time in periods.items():
|
||||
days_map.setdefault(day, {})[period] = days_map.get(day, {}).get(
|
||||
period, {"time": time, "ч": None, "з": None}
|
||||
)
|
||||
days_map[day] = dict(sorted(days_map[day].items()))
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
_MARKER_RE = re.compile(r"к\.в\.?\s*-?\s*\d*\s*", re.IGNORECASE)
|
||||
|
||||
|
||||
_ROOM_LINE_RE = re.compile(r"ауд\.\s*[\w\-]+", re.IGNORECASE)
|
||||
_SUBJECT_START_RE = re.compile(r"^[А-ЯЁA-Z]")
|
||||
|
||||
|
||||
def _split_lesson_blocks(cell_text):
|
||||
lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
blocks = []
|
||||
current = []
|
||||
prev_blank = True
|
||||
prev_room_line = False
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
prev_blank = True
|
||||
continue
|
||||
starts_marker = bool(_MARKER_RE.match(line))
|
||||
room_line = bool(_ROOM_LINE_RE.search(line))
|
||||
subject_start = bool(_SUBJECT_START_RE.match(line)) and not any(
|
||||
w in line for w in ("не будет", "отменяется", "переносится")
|
||||
)
|
||||
if current and (prev_blank or starts_marker or (prev_room_line and subject_start)):
|
||||
blocks.append(current)
|
||||
current = []
|
||||
current.append(line)
|
||||
prev_blank = False
|
||||
prev_room_line = room_line
|
||||
if current:
|
||||
blocks.append(current)
|
||||
return blocks
|
||||
|
||||
|
||||
def _lesson(marker, subject, teacher, room):
|
||||
room = re.sub(r"ауд\.?\s*", "ауд. ", room, flags=re.IGNORECASE)
|
||||
room = re.sub(r"\s{2,}", " ", room).strip()
|
||||
return {
|
||||
"marker": marker,
|
||||
"subject": subject,
|
||||
"teacher": teacher,
|
||||
"room": room,
|
||||
}
|
||||
|
||||
|
||||
def _split_subject_teacher(head):
|
||||
head = head.strip()
|
||||
name_re = re.compile(
|
||||
r"([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+)(\s*\([^)]*\))*$"
|
||||
)
|
||||
name_m = name_re.search(head)
|
||||
if name_m:
|
||||
teacher = name_m.group(1).strip()
|
||||
subject = (head[: name_m.start()] + (name_m.group(2) or "")).strip()
|
||||
return subject, teacher
|
||||
words = head.split()
|
||||
if len(words) >= 2 and words[-1][:1].isupper():
|
||||
return " ".join(words[:-1]), words[-1]
|
||||
return head, ""
|
||||
|
||||
|
||||
def _parse_inline(text, marker):
|
||||
text = text.strip()
|
||||
m = None
|
||||
for match in re.finditer(r"ауд\.?\s*[\d\-]+\w*", text, re.IGNORECASE):
|
||||
m = match
|
||||
if not m:
|
||||
return _lesson(marker, text, "", "")
|
||||
room = m.group(0).strip()
|
||||
head = text[: m.start()].strip()
|
||||
subject, teacher = _split_subject_teacher(head)
|
||||
return _lesson(marker, subject, teacher, room)
|
||||
|
||||
|
||||
def _parse_lesson(lines):
|
||||
lines = [
|
||||
ln.replace(RED_OPEN, "").replace(RED_CLOSE, "").strip()
|
||||
for ln in lines
|
||||
if ln.strip()
|
||||
]
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
marker = None
|
||||
m = re.match(r"^(к\.в\.?\s*-?\s*\d*)\s*(.*)$", lines[0], re.IGNORECASE)
|
||||
if m:
|
||||
marker = m.group(1).strip()
|
||||
rest_head = m.group(2).strip()
|
||||
if rest_head:
|
||||
lines[0] = rest_head
|
||||
else:
|
||||
lines = lines[1:]
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
if len(lines) == 1:
|
||||
return _parse_inline(lines[0], marker)
|
||||
|
||||
if len(lines) == 2:
|
||||
second = lines[1]
|
||||
if re.search(r"(ауд\.|http|зал|каб\.|онлайн)", second, re.IGNORECASE):
|
||||
subject, teacher = _split_subject_teacher(lines[0])
|
||||
return _lesson(marker, subject, teacher, second)
|
||||
if re.search(r"\b[А-ЯЁ]\.[А-ЯЁ]\.?$", second):
|
||||
return _lesson(marker, lines[0], second, "")
|
||||
return _lesson(marker, lines[0], "", second)
|
||||
|
||||
# >=3 строк: ищем строку аудитории, преподаватель — строка перед ней;
|
||||
# строки после аудитории (примечания) не ломают разбор
|
||||
room_idx = None
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if _ROOM_LINE_RE.match(lines[i]):
|
||||
room_idx = i
|
||||
break
|
||||
if room_idx is None:
|
||||
room = lines[-1]
|
||||
teacher = lines[-2]
|
||||
subject = " ".join(lines[:-2])
|
||||
return _lesson(marker, subject, teacher, room)
|
||||
|
||||
room = lines[room_idx]
|
||||
before = lines[:room_idx]
|
||||
teacher = ""
|
||||
teacher_idx = None
|
||||
for i in range(len(before) - 1, -1, -1):
|
||||
cand = before[i]
|
||||
if _looks_like_teacher_line(cand):
|
||||
teacher_idx = i
|
||||
teacher = cand
|
||||
break
|
||||
if teacher_idx is not None:
|
||||
subject = " ".join(before[:teacher_idx])
|
||||
else:
|
||||
subject = " ".join(before)
|
||||
return _lesson(marker, subject, teacher, room)
|
||||
|
||||
|
||||
def _unwrap_parens(text):
|
||||
text = text.strip()
|
||||
if text.startswith("(") and text.endswith(")"):
|
||||
inner = text[1:-1].strip()
|
||||
if inner:
|
||||
return inner
|
||||
return text
|
||||
|
||||
|
||||
def _collect_notes(text):
|
||||
notes = []
|
||||
for m in re.findall(r"⟪([^⟫]*)⟫", text):
|
||||
frag = m.strip()
|
||||
if frag:
|
||||
notes.append((_unwrap_parens(frag), True))
|
||||
plain = re.sub(r"⟪[^⟫]*⟫", "", text)
|
||||
for m in re.finditer(r"\(([^()]*)\)", plain):
|
||||
inner = m.group(1).strip()
|
||||
if not inner:
|
||||
continue
|
||||
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", inner):
|
||||
continue
|
||||
if not re.search(r"[а-яёА-ЯЁa-zA-Z]", inner):
|
||||
continue
|
||||
notes.append((_unwrap_parens(inner), False))
|
||||
return notes
|
||||
|
||||
|
||||
def _strip_note_markers(value):
|
||||
return value.replace(RED_OPEN, "").replace(RED_CLOSE, "")
|
||||
|
||||
|
||||
def _split_lessons(cell_text):
|
||||
lessons = []
|
||||
last_marker = None
|
||||
for block in _split_lesson_blocks(cell_text):
|
||||
block_text = "\n".join(block)
|
||||
lesson = _parse_lesson(block)
|
||||
if lesson is None:
|
||||
continue
|
||||
for key in ("marker", "subject", "teacher", "room"):
|
||||
if lesson[key]:
|
||||
lesson[key] = _strip_note_markers(lesson[key])
|
||||
fields = " ".join(
|
||||
x for x in (lesson["subject"], lesson["teacher"], lesson["room"]) if x
|
||||
)
|
||||
keep = []
|
||||
seen_notes = set()
|
||||
for note_text, is_red in _collect_notes(block_text):
|
||||
if not note_text or note_text in seen_notes:
|
||||
continue
|
||||
if not is_red and note_text in fields:
|
||||
continue
|
||||
seen_notes.add(note_text)
|
||||
keep.append(note_text)
|
||||
lesson["note"] = "\n".join(keep)
|
||||
if lesson["marker"]:
|
||||
last_marker = lesson["marker"]
|
||||
elif last_marker:
|
||||
lesson["marker"] = last_marker
|
||||
lessons.append(lesson)
|
||||
return lessons
|
||||
|
||||
|
||||
def build_group_schedule(data, group_name):
|
||||
group = data.get(group_name)
|
||||
if group is None:
|
||||
return None
|
||||
out = {
|
||||
"group": group_name,
|
||||
"semester_until": group.get("semester_until"),
|
||||
"days": {},
|
||||
}
|
||||
for day, periods in group["days"].items():
|
||||
out["days"][day] = {}
|
||||
for period, slot in periods.items():
|
||||
entry = {"time": slot.get("time", "")}
|
||||
for parity in ("ч", "з"):
|
||||
raw = slot.get(parity)
|
||||
if raw:
|
||||
entry[parity] = {"raw": raw, "lessons": _split_lessons(raw)}
|
||||
else:
|
||||
entry[parity] = None
|
||||
out["days"][day][str(period)] = entry
|
||||
return out
|
||||
|
||||
|
||||
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _iter_lessons(schedule_data):
|
||||
for group, gdata in schedule_data.items():
|
||||
for day, periods in gdata.get("days", {}).items():
|
||||
for period, slot in periods.items():
|
||||
time = slot.get("time", "")
|
||||
for parity in ("ч", "з"):
|
||||
raw = slot.get(parity)
|
||||
if not raw:
|
||||
continue
|
||||
for lesson in _split_lessons(raw):
|
||||
yield group, day, period, time, parity, lesson
|
||||
|
||||
|
||||
def _is_name(text):
|
||||
if not text:
|
||||
return False
|
||||
if text.startswith(("(", "—", "–", "-", "«", '"')):
|
||||
return False
|
||||
if any(w in text for w in ("не будет", "отменяется", "переносится", "пара будет")):
|
||||
return False
|
||||
if not re.search(r"[А-ЯЁа-яё]", text):
|
||||
return False
|
||||
if re.search(r"\d", text) and not re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_NAME_RE = re.compile(r"[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+")
|
||||
|
||||
|
||||
def _extract_teachers(text):
|
||||
return [m.strip() for m in _NAME_RE.findall(text) if m.strip()]
|
||||
|
||||
|
||||
def _is_org(text):
|
||||
if not _is_name(text):
|
||||
return False
|
||||
if any(ch in text for ch in "()"):
|
||||
return False
|
||||
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_ORG_LINE_RE = re.compile(
|
||||
r"[А-ЯЁ][A-Za-zА-ЯЁа-яё-]*(?:\s+[А-ЯЁ][A-Za-zА-ЯЁа-яё-]+)?"
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_teacher_line(text):
|
||||
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", text):
|
||||
return True
|
||||
return bool(_ORG_LINE_RE.fullmatch(text)) and len(text) <= 40
|
||||
|
||||
|
||||
def _merge_entry(slot_list, entry_key, fields):
|
||||
for item in slot_list:
|
||||
if all(item.get(k) == v for k, v in entry_key.items()):
|
||||
return item
|
||||
item = dict(entry_key)
|
||||
item["groups"] = []
|
||||
item.update(fields)
|
||||
slot_list.append(item)
|
||||
return item
|
||||
|
||||
|
||||
def build_teacher_room_index(schedule_data):
|
||||
teachers = {}
|
||||
rooms = {}
|
||||
|
||||
for group, day, period, time, parity, lesson in _iter_lessons(schedule_data):
|
||||
subject = lesson["subject"]
|
||||
room = lesson["room"].strip()
|
||||
|
||||
if lesson["teacher"]:
|
||||
teachers_found = _extract_teachers(lesson["teacher"])
|
||||
if not teachers_found and _is_org(lesson["teacher"]):
|
||||
teachers_found = [lesson["teacher"].strip()]
|
||||
|
||||
for teacher in teachers_found:
|
||||
days_map = teachers.setdefault(teacher, {}).setdefault(day, {})
|
||||
slot = days_map.setdefault(
|
||||
str(period), {"time": time, "ч": [], "з": []}
|
||||
)
|
||||
item = _merge_entry(
|
||||
slot[parity],
|
||||
{"subject": subject, "room": room},
|
||||
{"marker": lesson["marker"]},
|
||||
)
|
||||
if group not in item["groups"]:
|
||||
item["groups"].append(group)
|
||||
|
||||
display_teacher = ", ".join(teachers_found) if teachers_found else ""
|
||||
else:
|
||||
display_teacher = ""
|
||||
|
||||
room_m = _ROOM_RE.match(room)
|
||||
if room_m:
|
||||
label = "ауд. " + room_m.group(1)
|
||||
days_map = rooms.setdefault(label, {}).setdefault(day, {})
|
||||
slot = days_map.setdefault(
|
||||
str(period), {"time": time, "ч": [], "з": []}
|
||||
)
|
||||
item = _merge_entry(
|
||||
slot[parity],
|
||||
{"subject": subject, "teacher": display_teacher},
|
||||
{"marker": lesson["marker"]},
|
||||
)
|
||||
if group not in item["groups"]:
|
||||
item["groups"].append(group)
|
||||
|
||||
return teachers, rooms
|
||||
|
||||
|
||||
def sort_rooms(room_names):
|
||||
def key(name):
|
||||
m = re.search(r"(\d+)", name)
|
||||
return (0, int(m.group(1))) if m else (1, name)
|
||||
|
||||
return sorted(room_names, key=key)
|
||||
|
||||
|
||||
def parse_rooms_info(sheets):
|
||||
"""Аудитории: номер (заголовок) -> оснащение/вместимость (2-я строка)."""
|
||||
name = "аудитории"
|
||||
if name not in sheets:
|
||||
return {}
|
||||
rows = sheets[name]["rows"]
|
||||
if len(rows) < 2:
|
||||
return {}
|
||||
header = rows[0]
|
||||
desc = rows[1]
|
||||
info = {}
|
||||
for col in range(6, max(len(header), len(desc))):
|
||||
raw = _read_cell(header, col)
|
||||
m = re.search(r"(\d+)", str(raw))
|
||||
if not m:
|
||||
continue
|
||||
number = int(m.group(1))
|
||||
text = " ".join(
|
||||
line.strip()
|
||||
for line in _read_cell(desc, col).split("\n")
|
||||
if line.strip()
|
||||
)
|
||||
label = f"ауд. {number}"
|
||||
info[label] = {"room": label, "info": text}
|
||||
return info
|
||||
|
||||
|
||||
def _initials_from_parts(first, patronymic):
|
||||
initials = ""
|
||||
if first:
|
||||
initials += first[0].upper() + "."
|
||||
if patronymic:
|
||||
initials += patronymic[0].upper() + "."
|
||||
return initials
|
||||
|
||||
|
||||
def parse_teachers_full(sheets):
|
||||
"""Преподаватели: короткое «Фамилия И.О.» -> полное ФИО (3-я строка-заголовок)."""
|
||||
name = "Преподаватели"
|
||||
if name not in sheets:
|
||||
return {}
|
||||
rows = sheets[name]["rows"]
|
||||
if len(rows) < 2:
|
||||
return {}
|
||||
header = rows[1]
|
||||
result = {}
|
||||
for col in range(6, len(header)):
|
||||
value = _read_cell(header, col)
|
||||
parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()]
|
||||
if not parts:
|
||||
continue
|
||||
surname = parts[0]
|
||||
first = parts[1] if len(parts) > 1 else ""
|
||||
patronymic = parts[2] if len(parts) > 2 else ""
|
||||
short = f"{surname} {_initials_from_parts(first, patronymic)}".strip()
|
||||
full = " ".join(parts)
|
||||
if short and full:
|
||||
result.setdefault(short, full)
|
||||
return result
|
||||
|
||||
|
||||
def build_teacher_schedule(teachers, name):
|
||||
if name not in teachers:
|
||||
return None
|
||||
return {"name": name, "days": teachers[name]}
|
||||
|
||||
|
||||
def build_day_times(schedule_data):
|
||||
day_times = {}
|
||||
for gdata in schedule_data.values():
|
||||
for day, periods in gdata.get("days", {}).items():
|
||||
target = day_times.setdefault(day, {})
|
||||
for period, slot in periods.items():
|
||||
target.setdefault(str(period), slot.get("time", ""))
|
||||
return day_times
|
||||
|
||||
|
||||
def build_room_schedule(rooms, label, day_times=None):
|
||||
if label not in rooms:
|
||||
return None
|
||||
data = rooms[label]
|
||||
out = {"room": label, "days": {}}
|
||||
day_times = day_times or {}
|
||||
all_days = {*data.keys(), *day_times.keys()}
|
||||
for day in sorted(all_days, key=lambda d: DAYS.index(d) if d in DAYS else 99):
|
||||
out["days"][day] = {}
|
||||
times = day_times.get(day, {})
|
||||
periods = {*data.get(day, {}).keys(), *times.keys()}
|
||||
for period in sorted(periods, key=lambda p: int(p)):
|
||||
entry = {"time": times.get(period, data.get(day, {}).get(period, {}).get("time", ""))}
|
||||
occupied = data.get(day, {}).get(period, {})
|
||||
for parity in ("ч", "з"):
|
||||
lessons = occupied.get(parity) or []
|
||||
entry[parity] = lessons if lessons else None
|
||||
out["days"][day][str(period)] = entry
|
||||
return out
|
||||
Reference in New Issue
Block a user