Скорректирован сценарий запуска, исправление бага с предметом
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,3 +6,6 @@ __pycache__/
|
||||
/.idea/
|
||||
**/*.env
|
||||
.env
|
||||
|
||||
# локальные данные инструментов
|
||||
.kilo/
|
||||
|
||||
1
Makefile
1
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"
|
||||
|
||||
36
README.md
36
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 — <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 тест, сеть не нужна
|
||||
```
|
||||
|
||||
@@ -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)"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] == "Химия"
|
||||
|
||||
|
||||
# --- пустые и экзотические ----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user