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

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

View File

@@ -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