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