Files
schedule/README.md
Vladislav Kopnin fa51f4caee Аудитории: близкие типы объединены в фильтре, добавлен пункт «без типа»
В карточке остаётся формулировка из таблицы («комп.класс Linux»), а в фильтре
близкие варианты сведены к одной группе: «комп.класс» (комп.класс, комп.класс
Linux, компьютерный класс), «кабинет английского» (английский, кабинет
английского), «аудитория кафедры», «лекционная», «лекционно-практическая»,
«аудитория для практических». Добавлен пункт «без типа» — 4 аудитории, где в
таблице тип не указан.

Бэкенд отдаёт в /rooms-day и /rooms-free дополнительное поле type_group
(room_type_group), фронт фильтрует по нему. Тесты: 160.
2026-10-10 12:33:05 +03:00

468 lines
30 KiB
Markdown
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.

# Расписание
Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на
Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних
библиотек и отдаётся как JSON API и как сайт, который можно «установить» на
главный экран телефона (PWA).
Умеет:
- расписание по группам, преподавателям и аудиториям с разделением
числитель/знаменатель и бейджем текущей недели;
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
- совпадающие недели показываются одним списком, различающиеся — двумя
колонками, пустая — «Окно»;
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
- PWA: манифест, service worker, иконки — «на главный экран».
## Архитектура
```
Яндекс.Диск (xlsx)
│ скачивание по публичной ссылке
▼
api — FastAPI, порт 8000
• качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree)
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
• один воркер, поэтому кэш общий
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
│
frontend — FastAPI, порт из .env (по умолчанию 8080)
• отдаёт index.html / app.js / style.css / иконки
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
▲
nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта>
```
Роли:
- **docker** изолирует оба сервиса, перезапускает их при падении
(`restart: unless-stopped`) и следит за здоровьем (healthcheck на
`/healthz`); frontend стартует, только когда api стал healthy.
- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в
`docker-compose.yml`): из интернета контейнеры напрямую недоступны.
- **nginx** — единственная дверь из интернета: принимает https и проксирует на
фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик).
## Установка на сервер
### Docker (deploy.sh)
Скачайте zip с проектом, распакуйте и заполните `.env`:
```bash
unzip schedule.zip && cd schedule
cp .env.example .env && nano .env # SCHEDULE_LINK обязателен; ADMIN_* — по желанию
sudo ./deploy.sh # собрать образы и запустить
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
```
`deploy.sh` проверяет, что Docker и `.env` на месте, собирает образы и поднимает
контейнеры. Обновление — тем же скриптом: `git pull && sudo ./deploy.sh`.
Почему Docker: сервисы изолированы, автоматически перезапускаются, не зависят
от Python в системе и обновляются одной командой.
### Без Docker (systemd)
```bash
sudo apt install -y python3 python3-venv python3-pip # Python 3.11+
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env && nano .env # SCHEDULE_LINK (+ ADMIN_*, если нужна админка)
```
Две службы systemd (`/etc/systemd/system/`), затем
`sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend`:
```ini
# schedule-api.service
[Unit]
Description=Schedule API (FastAPI)
After=network-online.target
Wants=network-online.target
[Service]
User=www-data
WorkingDirectory=/путь/к/проекту/src/backend
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
# schedule-frontend.service
[Unit]
Description=Schedule frontend (FastAPI)
After=network-online.target schedule-api.service
Wants=network-online.target schedule-api.service
[Service]
User=www-data
WorkingDirectory=/путь/к/проекту/src/frontend
Environment=SCHEDULE_API=http://127.0.0.1:8000
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
Если службы работают не от владельца каталога проекта, выдайте пользователю
службы права на запись в `data/` (там админка хранит ссылку, интервал и историю
входов) или задайте свой путь в `SETTINGS_FILE`.
**nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть):
```bash
sudo apt install -y nginx certbot
# временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html
sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru
sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080
curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово
```
`deploy/nginx-site.sh` генерирует финальный конфиг (http → https, прокси на
`FRONTEND_PORT` из `.env`, таймауты под холодный кэш), включает его, проверяет
`nginx -t` и перечитывает nginx. Посмотреть конфиг, ничего не меняя:
`deploy/nginx-site.sh --print --domain sched.example.ru`.
### Обслуживание
- **Обновление**: Docker — `cd <каталог проекта> && sudo ./deploy.sh`;
система — заменить файлы проекта и
`sudo systemctl restart schedule-api schedule-frontend`.
- **Диагностика**: Docker — `sudo docker compose ps` и
`sudo docker logs api --tail 50`; система — `systemctl status schedule-api`
и `journalctl -u schedule-api -u schedule-frontend`. Состояние кэша —
`/api/status` (смотреть поле `error`).
- **Ротация логов контейнеров** — настроить сразу: без лимита логи растут
вечно и однажды забивают диск. `/etc/docker/daemon.json`:
```json
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
```
затем `sudo systemctl restart docker`.
- **Диск**: следить `df -h /`; раз в месяц
`sudo docker system prune -af && sudo journalctl --vacuum-size=200M`.
## Локальная разработка
Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker).
```bash
python3 --version # должен быть 3.11 или новее
cp .env.example .env # вписать SCHEDULE_LINK
make run # создаст .venv, поставит зависимости, поднимет оба сервиса
```
Открыть <http://127.0.0.1:8080> (API — <http://127.0.0.1:8000>). Остановить —
`Ctrl+C`; отвязать от терминала — `nohup make run &`.
Данные скачиваются сразу на старте (несколько секунд) и дальше обновляются по
интервалу.
Полезные команды (`make help` — весь список):
| Команда | Что делает |
|---|---|
| `make venv` | создать `.venv` и установить зависимости |
| `make run` (или `make dev`) | запустить API и сайт |
| `make run-api` / `make run-front` | запустить только один из сервисов |
| `make test` | прогнать тесты |
| `make nginx-print` / `make nginx-install` | показать / установить конфиг nginx |
Без make — то же самое руками:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend
.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend
```
### Тесты
```bash
make test # 121 тест, сеть не нужна
```
## Зависимости
**Системные требования**
| Что | Зачем |
|---|---|
| Python 3.11+ | локальная разработка и режим «в систему» |
| Docker + docker compose v2 | режим Docker (опционально) |
**Python-пакеты** — закреплены в `requirements.txt` (рабочие) и
`requirements-dev.txt` (тесты):
| Пакет | Версия | Назначение |
|---|---|---|
| fastapi | 0.141.1 | веб-фреймворк обоих сервисов |
| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы |
| python-dotenv | 1.2.3 | чтение `.env` |
| requests | 2.34.2 | скачивание файла с Я.Диска и прокси к API |
| pytest | 9.1.1 | тесты — только dev |
| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` — только dev |
Больше ничего не нужно: xlsx разбирается стандартной библиотекой, базы данных
нет, кэш — в памяти процесса. httpx2 в рабочем коде не используется и нужен
только тестам; модуль импортируется как `httpx2` — httpx перешёл под опеку
Pydantic и был переименован в августе 2026, `import httpx` больше не работает.
### Если установка не идёт
- Проверьте `python3 --version`: нужен 3.11 или новее. Если под `python3`
скрывается старая версия, создайте окружение нужной явно:
`python3.12 -m venv .venv`.
- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте
пакет venv: `sudo apt install python3-venv` (для нестандартной версии —
например, `python3.12-venv`).
### Если pip не находит пакеты: зеркала
Если pip настроен на корпоративное/национальное зеркало, оно может отставать
от PyPI — свежих пакетов там ещё нет. Диагностика и обход:
```bash
pip index versions httpx2 # есть ли пакет на текущем индексе
pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало
pip config set global.index-url <зеркало> # закрепить зеркало навсегда
```
Зеркала, проверенные 07.10.2026 (пакеты `httpx2`/`httpcore2` на месте;
URL подставлять как есть в `-i` / `index-url`):
| Зеркало | URL |
|---|---|
| PyPI, официальный | `https://pypi.org/simple` |
| TUNA (Tsinghua) | `https://pypi.tuna.tsinghua.edu.cn/simple` |
| Huawei Cloud | `https://mirrors.huaweicloud.com/repository/pypi/simple` |
| BFSU | `https://mirrors.bfsu.edu.cn/pypi/web/simple` |
| SJTU | `https://mirror.sjtu.edu.cn/pypi/web/simple` |
| USTC | `https://mirrors.ustc.edu.cn/pypi/simple` |
| SUSTech | `https://mirrors.sustech.edu.cn/pypi/simple` |
Примечания: Alibaba Cloud (`https://mirrors.aliyun.com/pypi/simple`) обычно
тоже свежий, но из части сетей недоступен; Яндекс-зеркало (`mirror.yandex.ru`)
PyPI не раздаёт вовсе. Зеркала отстают по-разному, поэтому для свежих пакетов
надёжнее разово ставить с официального индекса.
## Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут |
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
| `ADMIN_PATH` | — | api+frontend | секретный путь админки; пусто = админка выключена |
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | логин и пароль администратора |
| `ADMIN_MAX_ATTEMPTS` | `3` | api | сколько неудачных попыток входа допускается |
| `ADMIN_LOCKOUT_MINUTES` | `15` | api | на сколько минут блокировать вход после них |
| `SETTINGS_FILE` | `data/settings.json` | api | где хранить ссылку, интервал и историю входов |
| `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) |
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) |
`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
переменные окружения.
## Админка
Необязательная страница для управления источником данных. Отдельного входа на
сайте нет: панель открывается по секретному пути `ADMIN_PATH`, а пока
`ADMIN_LOGIN`/`ADMIN_PASSWORD` пусты, админка выключена и наружу ничего не
торчит (маршруты `/api/admin/*` не регистрируются).
Включить: заполнить в `.env` четыре переменные и перезапустить службу.
```ini
ADMIN_PATH=/panel-8f3a91x7 # придумайте длинный и неочевидный
ADMIN_LOGIN=admin
ADMIN_PASSWORD=<длинный пароль>
```
Что умеет:
- **посмотреть состояние**: сколько групп, когда обновлялось, дата файла на
Диске, изменился ли файл при последнем чтении (`sha256`), есть ли ошибки;
- **вставить новую ссылку** на таблицу: она проверяется скачиванием и разбором
до сохранения — нерабочая не применится, данные на сайте не пострадают;
- **задать интервал обновления** в минутах (`1…1440`);
- **обновить сейчас**: скачивает таблицу принудительно, не дожидаясь интервала
(по интервалу файл перечитывается, только если на Диске изменилась дата);
- **видеть входы**: последняя попытка (откуда, когда, успешна ли) и последний
успешный вход — и в панели, и в логе службы.
Защита входа: после `ADMIN_MAX_ATTEMPTS` неудач (по умолчанию 3) вход с этого
адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия —
подписанная cookie на 12 часов.
Настройки (ссылка, интервал, история входов) хранятся в `SETTINGS_FILE`
(по умолчанию `data/settings.json` в корне проекта; в docker — в томе
`schedule_data`). Если файл недоступен для записи, изменения живут в памяти до
перезапуска, а панель предупредит об этом.
## Структура кода
```
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force
schedule/
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
скрытые столбцы, красный текст, серийные даты
source.py скачивание файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error, force
settings.py хранилище настроек админки (ссылка, интервал, история входов)
errors.py ScheduleError
frontend/
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
admin.html страница админки (вне static: прямой доступ закрыт)
static/
index.html, app.js, style.css
manifest.json PWA: имя, иконки, standalone-режим
sw.js минимальный service worker (без кэша) для установки приложения
img/ логотипы и иконки приложения
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
deploy/
nginx-site.sh генерация и включение конфига nginx (ручная настройка)
tests/
conftest.py общие фикстуры
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
fixtures.py общая тестовая таблица
test_lessons.py все форматы ячеек
test_xlsx.py чтение книги
test_parsing.py разбор листов, недели, индексы
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
test_admin.py админка: вход, блокировка, история, ссылка/интервал/force
test_settings.py хранилище настроек админки
test_deploy.py проверка, что образы копируют все нужные файлы
```
Данные админки (`data/settings.json`) — рабочие, в git не попадают.
## Как устроена таблица-источник
Лист «Бакалавры и магистры» — это сетка:
```
столбцы 0–1 даты «числителя»: начало и конец недели (серийные номера Excel)
столбцы 2–3 даты «знаменателя»
столбец 4 день недели (указан один раз на блок дня)
столбец 5 номер пары и время (ячейка объединена на две строки пары)
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
```
Каждый день занимает блок строк, и строки идут парами: «числитель» /
«знаменатель». У каждой пары строк — своя неделя семестра, а даты в ячейках —
это соседние недели по порядку. Поэтому недели семестра собираются со всего
листа (повторы отбрасываются по паре «чётность + дата»), а текущая чётность
(«Сейчас» на бейдже) определяется по сегодняшней дате.
## Как разбирается ячейка занятия
Ячейка свободного формата, и её содержимое проходит единый конвейер
(`schedule/lessons.py`) без особых веток на число строк:
1. Красные фрагменты (в xlsx помечаются как `⟪...⟫` ещё при чтении строк)
выносятся в поле `note` и из остального текста убираются, даже если внутри
них спрятано другое занятие; обрамляющие скобки у таких пометок снимаются.
2. Маркер курса по выбору «к.в. [- N]» может быть отдельной строкой или
префиксом предмета. Если в ячейке несколько курсов, а маркер один, его
получают все курсы этой ячейки.
3. Каждая строка относится к одной из ролей:
- **аудитория** — `ауд. N`, `каб. N`, URL, `онлайн`, `зал…` (включая
корпус: «юридический корпус, 3 этаж ауд. 22»);
- **преподаватель** — фамилия с инициалами (`Смирнов А.В.`), полное ФИО,
организация (`ПравоТех`, `НПО Криста`); преподавателей может быть
несколько (через `,` или `/`); имя допускается в одной строке
с предметом и (или) аудиторией;
- **предмет** — всё, что стоит до преподавателя/аудитории (название может
переноситься на несколько строк);
- **заметка** — время («начало в 14:05»), даты («с 8.09.26»),
«ссылка в эл. университете» и прочие примечания.
4. Результат приводится к единому виду: время `ЧЧ:ММ-ЧЧ:ММ`, аудитория `ауд. N`.
В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
новым маркером «к.в.» или началом нового предмета сразу после строки
аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с
целой строки) от предмета оторвался только преподаватель и/или аудитория, этот
кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе
в расписании аудитории оставались бы одни группы.
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
`lesson = {marker, subject, teacher, room, note}`.
## Как отображается
- Если занятия числителя и знаменателя совпадают, пара показывается одним
списком без пометки недели (одинаково для групп, преподавателей и
аудиторий); иначе — две колонки «числитель»/«знаменатель», пустая
заменяется на «Окно».
- Подпись недели появляется только там, где недели действительно
различаются; бейдж «Сейчас» подсвечивает текущую неделю.
- Длинные ссылки в карточке переносятся по служебным символам и каждые
24 символа (`<wbr>`), поэтому карточка не вылезает за экран даже в старых
браузерах, где нет `overflow-wrap`.
- На вкладке «Аудитории» есть фильтры: **тип** (в карточке — формулировка из
таблицы, в фильтре близкие варианты объединены: «комп.класс» включает
«комп.класс Linux» и «компьютерный класс», «кабинет английского» —
«английский»; есть пункт «без типа»),
**вместимость** («не меньше N мест») и **оснащение** чекбоксами с выбором
условия «все выбранные (И)» или «любое из выбранных (ИЛИ)». Выбранное
запоминается в браузере, под списком видно «Показано N из M» и пояснения:
сколько аудиторий скрыто из-за отсутствия данных о вместимости и какие
доступны только по отдельным дням.
- Там же переключатель «Занятость за день» / **«Свободные на паре»**: во
втором режиме выбирается пара и показываются только свободные аудитории —
вместе с фильтрами получается «свободная аудитория на 30 человек с
проектором в 3-ю пару».
- Приложение можно «установить» на главный экран (манифест + service
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
## API
Полный список методов отдаёт сам бэкенд: `GET /`.
| Метод | Путь | Описание |
|---|---|---|
| GET | `/groups` | список групп (сначала БО, потом МО) |
| GET | `/schedule/{group}` | расписание группы с разбором на занятия |
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
| GET | `/today?group={group}` | расписание группы на сегодня |
| GET | `/teachers` | список преподавателей |
| GET | `/teacher/{name}` | расписание преподавателя (+ `full_name`) |
| GET | `/rooms` | список аудиторий (занятые ∪ лист «аудитории») |
| GET | `/room/{label}` | занятость аудитории |
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день (+ атрибуты) |
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории (+ атрибуты) |
| GET | `/week` | недели семестра и текущая чётность |
| GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) |
| GET | `/healthz` | живость процесса (не обращается к таблице) |
В ответах `rooms-day` и `rooms-free` по каждой аудитории, кроме `room` и `info`
(описание как в таблице), идут разобранные атрибуты: `type` (тип фразой из
таблицы: «комп.класс», «кабинет английского», «лекционная»…) и `type_group`
(тот же тип, но близкие формулировки сведены к одной — «комп.класс»,
«комп.класс Linux» и «компьютерный класс» дают группу «комп.класс»),
`capacity`
(мест; для компьютерных классов — рабочие места; `null`, если в таблице нет
данных), `equipment` (список: «ПК», «Проектор», «Интерактивная панель»,
«Телевизор», «Маркер», «Меловая доска») и `available_days` (дни, если в
описании указано «только четверг, пятница»). Аудитории с ограничением по дням
не попадают в `/rooms-free` для остальных дней.
Фронт проксирует только то, что использует, под префиксом `/api/...`:
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.