Скорректирован сценарий запуска, исправление бага с предметом

This commit is contained in:
2026-10-07 15:17:37 +03:00
parent 34b975da69
commit d897601d88
7 changed files with 131 additions and 9 deletions

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ __pycache__/
/.idea/
**/*.env
.env
# локальные данные инструментов
.kilo/

View File

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

View File

@@ -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 — <http://127.0.0.1:8000> (например, `/status` и `/groups`);
- сайт — <http://127.0.0.1:8080> (его и открывать в браузере).
Остановить — `Ctrl+C`. Данные тянутся из интернета при первом запросе
(скачивание занимает несколько секунд), дальше обновляются по интервалу.
Остановить — `Ctrl+C`. Данные скачиваются сразу на старте приложения
(скачивание занимает несколько секунд) и дальше обновляются по интервалу.
Полезные команды (`make help` — весь список):
@@ -255,5 +277,5 @@ cp .env.example .env # вписать SCHEDULE_LINK (и порты при
### Тесты
```bash
make test # 114 тестов, сеть не нужна
make test # 121 тест, сеть не нужна
```

View File

@@ -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)"]

View File

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

View File

@@ -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"] == "Химия"
# --- пустые и экзотические ----------------------------------------------------------

View File

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