diff --git a/.gitignore b/.gitignore index 07a7fc7..3ed4e25 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ /.idea/ **/*.env .env + +# локальные данные инструментов +.kilo/ diff --git a/Makefile b/Makefile index fc1b02c..78c4ab2 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ help: ## Показать этот список команд venv: ## Создать .venv и установить зависимости @test -x $(PY) || python3 -m venv .venv + @test -x $(PIP) || { echo "Не удалось создать .venv — см. README, раздел «Если установка не идёт» (на Debian/Ubuntu нужен пакет python3-venv)"; exit 1; } @$(PIP) install -q --upgrade pip @$(PIP) install -q -r requirements-dev.txt @echo "Зависимости готовы (.venv). Дальше: make run" diff --git a/README.md b/README.md index f821383..3eb4fcc 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ API и отображается на фронте. ## Быстрый старт -Нужен только Python 3.11+ и публичная ссылка на таблицу. +Нужны Python 3.11+, bash (Linux, macOS или WSL на Windows) и публичная ссылка +на таблицу. ```bash +python3 --version # должен быть 3.11 или новее cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска make run # создаст .venv, поставит зависимости и запустит оба сервиса ``` @@ -22,7 +24,7 @@ make run # создаст .venv, поставит зависим Яндекс.Диск (xlsx) │ скачивание по публичной ссылке ▼ -api (FastAPI), порт 8000, доступ только с localhost +api (FastAPI), порт 8000, наружу — только 127.0.0.1 (в docker так публикуется порт) • скачивает и разбирает xlsx без сторонних библиотек • кэш в памяти: TTL + проверка даты изменения файла + stale-while-error • один воркер, поэтому кэш общий @@ -79,7 +81,10 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 В одной ячейке может быть несколько занятий: они разделяются пустой строкой, новым маркером «к.в.» или началом нового предмета сразу после строки -аудитории/преподавателя. +аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с +целой строки) от предмета оторвался только преподаватель и/или аудитория, этот +кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе +в расписании аудитории оставались бы одни группы. Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где `lesson = {marker, subject, teacher, room, note}`. @@ -200,11 +205,28 @@ tests/ | python-dotenv | 1.2.3 | чтение `.env` | | requests | 2.34.2 | скачивание файла с Яндекс.Диска и прокси к API | | pytest | 9.1.1 | тесты (`make test`) — только dev | -| httpx2 | 2.12.0 | HTTP-клиент для `TestClient` в тестах — только dev | +| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` в тестах — только dev | Ничего, кроме этого, не нужно: xlsx разбирается стандартной библиотекой (`zipfile` + `xml.etree`), базы данных нет, кэш — в памяти процесса. +**Если установка не идёт** + +- Проверьте `python3 --version`: нужен 3.11 или новее. Если в системе под + `python3` скрывается старая версия (например, 3.6 на старых CentOS), создайте + окружение нужной версией явно: `python3.12 -m venv .venv`. +- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте + пакет venv: `sudo apt install python3-venv` (для нестандартной версии — + например, `python3.12-venv`). +- `httpx2` и `httpcore2` — новые пакеты (август 2026, httpx перешёл под + опеку Pydantic и был переименован). Если pip берёт пакеты из корпоративного + зеркала или старого индекса, этих пакетов там может ещё не быть; проверьте + установку напрямую: `pip install -r requirements-dev.txt -i https://pypi.org/simple`. + В коде и тестах модуль импортируется как `httpx2`; `import httpx` больше + не работает. В рабочем коде httpx2 не используется — нужен только тестам. +- Makefile написан под bash: Linux, macOS или WSL. На Windows без WSL + используйте Docker-вариант из раздела «В Docker». + ## Запуск ### Без Docker @@ -221,8 +243,8 @@ make run # = make venv + запуск api и сайта - API — (например, `/status` и `/groups`); - сайт — (его и открывать в браузере). -Остановить — `Ctrl+C`. Данные тянутся из интернета при первом запросе -(скачивание занимает несколько секунд), дальше обновляются по интервалу. +Остановить — `Ctrl+C`. Данные скачиваются сразу на старте приложения +(скачивание занимает несколько секунд) и дальше обновляются по интервалу. Полезные команды (`make help` — весь список): @@ -255,5 +277,5 @@ cp .env.example .env # вписать SCHEDULE_LINK (и порты при ### Тесты ```bash -make test # 114 тестов, сеть не нужна +make test # 121 тест, сеть не нужна ``` diff --git a/docker-compose.yml b/docker-compose.yml index c32123c..9b14e8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,8 @@ services: SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60} ports: - - "${API_PORT:-8000}:8000" + # api нужен только фронтенду внутри docker-сети; наружу — 127.0.0.1 для отладки + - "127.0.0.1:${API_PORT:-8000}:8000" restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"] diff --git a/src/backend/schedule/lessons.py b/src/backend/schedule/lessons.py index 4fb6b7b..60a8707 100644 --- a/src/backend/schedule/lessons.py +++ b/src/backend/schedule/lessons.py @@ -495,6 +495,40 @@ def _normalize_marker_of(line: str) -> Optional[str]: return _normalize_marker(num or None) +def _merge_detached_fragments(lessons: list[Lesson]) -> list[Lesson]: + """Достраивает занятие из оторвавшихся пустой строкой преподавателя/аудитории. + + Пустая строка внутри ячейки (или красная заметка, снятая с целой строки) + отрывает хвост от предмета: «Предмет\nЛарина Ю.А.\n\n ауд.210» разбирается + как занятие без аудитории и «ауд. 210» без предмета — в индексе аудиторий + от такого занятия остаётся только список групп. Кусок без предмета цепляем + к предыдущему занятию, но только если у того этих полей ещё нет, иначе + это похоже на отдельное занятие параллельной подгруппы. + """ + merged: list[Lesson] = [] + for lesson in lessons: + prev = merged[-1] if merged else None + if ( + prev is not None + and prev.subject + and not lesson.subject + and (lesson.teacher or lesson.room) + and (not lesson.marker or lesson.marker == prev.marker) + and (not lesson.teacher or not prev.teacher) + and (not lesson.room or not prev.room) + ): + notes = [n for n in (prev.note + "\n" + lesson.note).split("\n") if n] + merged[-1] = replace( + prev, + teacher=lesson.teacher or prev.teacher, + room=lesson.room or prev.room, + note="\n".join(dict.fromkeys(notes)), + ) + else: + merged.append(lesson) + return merged + + def parse_lesson_cell(cell_text: str) -> list[Lesson]: """Текст ячейки -> список занятий. @@ -525,6 +559,8 @@ def parse_lesson_cell(cell_text: str) -> list[Lesson]: lesson = replace(lesson, marker=last_marker) lessons.append(lesson) + lessons = _merge_detached_fragments(lessons) + if not cell_notes: return lessons if not lessons: diff --git a/tests/test_lessons.py b/tests/test_lessons.py index a7b7cf1..9f60af1 100644 --- a/tests/test_lessons.py +++ b/tests/test_lessons.py @@ -14,7 +14,7 @@ def lessons(text): def one(text): ls = parse_lesson_cell(text) - assert len(ls) == 1, f"ожидалося 1 занятие, got: {ls}" + assert len(ls) == 1, f"ожидалось 1 занятие, got: {ls}" return ls[0].to_dict() @@ -295,6 +295,53 @@ def test_lesson_with_red_between_lessons(): assert ls[1]["subject"] == "Язык UML и CASE-системы" +# --- оторвавшиеся пустой строкой преподаватель/аудитория --------------------------- + + +def test_room_detached_by_blank_line(): + # ПИЭ-31БО, вторник: «ауд.210» оторвана пустой строкой от предмета + result = one("Программная инженерия\nЛарина Ю.А.\n\n ауд.210") + assert result["subject"] == "Программная инженерия" + assert result["teacher"] == "Ларина Ю.А." + assert result["room"] == "ауд. 210" + + +def test_teacher_and_room_detached_by_red_note(): + # ИВТ-13БО, четверг: красная заметка снята со своей строки, осталась пустая + raw = f"Практикум по матанализу\n{R}пара для тех кто не ТОП{Cc}\nШабаршина Г.В.\nауд. 219" + result = one(raw) + assert result["subject"] == "Практикум по матанализу" + assert result["teacher"] == "Шабаршина Г.В." + assert result["room"] == "ауд. 219" + assert result["note"] == "пара для тех кто не ТОП" + + +def test_detached_room_inherits_marker(): + ls = lessons("к.в. - 2\nФизика\nИванов И.И.\n\nауд. 301") + assert len(ls) == 1 + assert ls[0]["marker"] == "к.в. - 2" + assert ls[0]["room"] == "ауд. 301" + + +def test_detached_room_kept_if_prev_has_room(): + ls = lessons("Физика\nСмирнов А.В.\nауд. 221\n\nауд. 222") + assert len(ls) == 2 + assert ls[0]["room"] == "ауд. 221" + + +def test_detached_teacher_kept_if_prev_has_teacher(): + ls = lessons("Физика\nСмирнов А.В.\nауд. 221\n\nПетров П.П.\nауд. 222") + assert len(ls) == 2 + assert ls[0]["teacher"] == "Смирнов А.В." + + +def test_lesson_after_detached_room_starts_fresh(): + ls = lessons("Физика\nСмирнов А.В.\n\nауд. 221\n\nХимия\nПетров П.П.\nауд. 222") + assert len(ls) == 2 + assert ls[0]["room"] == "ауд. 221" + assert ls[1]["subject"] == "Химия" + + # --- пустые и экзотические ---------------------------------------------------------- diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 5cc3d2d..b126265 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -145,6 +145,18 @@ def test_teacher_room_index(data): assert room_301["2"]["ч"][0]["groups"] == ["ИВТ-11БО"] +def test_room_index_detached_room(): + """Аудитория, оторванная пустой строкой, попадает в индекс с предметом и преподавателем.""" + sheets = fixture_sheets() + sheets[parsing.MAIN_SHEET].rows[6][7] = "Химия\nПетров П.П.\n\n ауд. 201" + data = parsing.parse_schedule(make_sheets(sheets)) + _, rooms = parsing.build_teacher_room_index(data) + entry = rooms["ауд. 201"]["вторник"]["1"]["ч"][0] + assert entry["subject"] == "Химия" + assert entry["teacher"] == "Петров П.П." + assert entry["groups"] == ["ИТ-12БО"] + + def test_day_times(data): times = parsing.build_day_times(data) assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"}