Админка осталась отдельной ветвью 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 (удалены админские), все зелёные.
396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""Разбор листов рабочей книги в структуру расписания.
|
||
|
||
Основной лист «Бакалавры и магистры» имеет сетку:
|
||
столбец 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
|