Files
schedule/src/backend/schedule/parsing.py
Vladislav Kopnin 34b975da69 Перенос в master всего, кроме админки: ТОП-группы, порядок групп, запуск без Docker
Админка осталась отдельной ветвью admin-panel; в master её нет (ни кода, ни
страниц, ни переменных окружения, ни тома настроек).

Что перенесено:
- parsing: имена групп вида «ИВТ-ТОП-11БО» распознаются, порядок групп = порядок
  столбцов таблицы (похожие заголовки с пробелами группой не считаются);
- main: GET /groups отдаёт группы в порядке столбцов; интервал обновления из
  SCHEDULE_INTERVAL_MINUTES (со старым SCHEDULE_CACHE_TTL как fallback);
- cache: по интервалу сверяем modified — если файл не менялся, не скачиваем;
  при загрузке считаем sha256 и пишем в лог и /status, изменился ли файл;
- source: запросы к Яндекс.Диску с no-cache и меткой времени;
- frontend: ответы API с Cache-Control: no-store, общий .env из корня проекта
  (и backend, и frontend), страницы/прокси админки нет;
- Dockerfile/compose/.env.example: без ADMIN_* и без тома настроек; в api-образ
  попадают все модули backend (проверяется тестом test_deploy);
- Makefile: make help/venv/run/run-api/run-front/test + docker-цели — «make run»
  поднимает всё локально без Docker;
- README: быстрый старт, полный список зависимостей, запуск локально и в Docker;
- deploy/: пример nginx и инструкция «второй стенд рядом с основным».

Тесты: 114 (удалены админские), все зелёные.
2026-10-06 15:15:44 +03:00

396 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Разбор листов рабочей книги в структуру расписания.
Основной лист «Бакалавры и магистры» имеет сетку:
столбец 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
# Имя группы: буквенные части через дефис, номер потока и необязательный
# суффикс: «ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО», «ИТ-ТОП-12БО».
_GROUP_RE = re.compile(r"^[А-ЯЁ]+(?:-[А-ЯЁ]+)*-\d{1,2}[А-ЯЁ]{0,3}$")
_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 not name or name in seen or len(name) > 24:
continue
if _GROUP_RE.match(name):
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