Files
schedule/README.md
Vladislav Kopnin 475eb3801e Группы: ТОП-имена и порядок как в таблице
В таблице появились группы с «ТОП» в имени (ИВТ-ТОП-11БО, ИТ-ТОП-12БО):
шаблон требовал цифру сразу после первого дефиса, поэтому такие столбцы
пропускались целиком. Теперь имя группы — буквенные части через дефис,
номер потока и необязательный суффикс, т.е. распознаются и «ИВТ-11БО»,
и «ПИЭ-13МО», и «ИВТ-ТОП-11БО» (похожие заголовки с пробелами — нет).

GET /groups больше не сортирует по алфавиту с приоритетом БО/МО, а отдаёт
группы в порядке столбцов таблицы — как просили, «как в расписании».
Состав групп (появление/переименование/скрытие) подхватывается сам при
следующем обновлении, потому что источник истины — заголовок листа.

Тесты: ТОП-имена + порядок столбцов, отсев похожих не-групповых заголовков.
2026-09-30 09:42:27 +03:00

274 lines
18 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 и отображается на фронте. Деплой: `schedule.our-place.ru`.
## Архитектура
```
Яндекс.Диск (xlsx)
│ скачивание по публичной ссылке
▼
api (FastAPI), порт 8000, доступ только с localhost
• скачивает и разбирает xlsx без сторонних библиотек
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
• один воркер, поэтому кэш общий
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
│ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка)
frontend (FastAPI: статика + прокси), прод :8090, dev :8080
• отдаёт index.html / app.js / style.css / картинки
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
▲
nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
```
## Как устроено расписание в таблице
Лист «Бакалавры и магистры» — это сетка:
```
столбцы 0–1 даты «числителя»: начало и конец недели (серийные номера Excel)
столбцы 2–3 даты «знаменателя»
столбец 4 день недели (указан один раз на блок дня)
столбец 5 номер пары и время (ячейка объединена на две строки пары)
столбцы 6+ группы — по одному столбцу на группу, порядок столбцов = порядок
групп на сайте (скрытые столбцы пропускаются; имена вида
«ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО»)
```
Каждый день занимает блок строк, и строки идут парами: «числитель» /
«знаменатель». У каждой пары строк — своя неделя семестра, а даты в ячейках —
это соседние недели по порядку. Поэтому недели семестра собираются со всего
листа (повторы отбрасываются по паре «чётность + дата»), а текущая чётность
(«Сейчас» на бейдже) определяется по сегодняшней дате.
## Как разбирается ячейка занятия
Ячейка свободного формата, и её содержимое проходит единый конвейер
(`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`.
- Приложение можно «установить» на главный экран (манифест + service
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
## Админка
Отдельного входа на сайте нет: панель открывается по секретному пути из
`ADMIN_PATH` (например `/manage-7f3a91`), логин и пароль — `ADMIN_LOGIN` /
`ADMIN_PASSWORD`. Если эти переменные пусты, админка полностью выключена и
маршруты `/api/admin/*` не регистрируются (прод можно не трогать).
Что умеет администратор:
- **посмотреть текущую ссылку** и состояние кэша (обновлено, изменена,
ошибка, число групп);
- **вставить новую ссылку**: она проверяется скачиванием и разбором таблицы
до сохранения — нерабочая не применится, данные на сайте не пострадают;
- **принудительно обновить расписание** — вне интервала, не дожидаясь
фонового обновления (интервал и так перечитывает таблицу целиком);
- **задать интервал обновления** в минутах (`1…1440`).
Каждое обновление (и по кнопке, и по интервалу) **всегда перечитывает таблицу**,
а в панели и в логе видно, изменился ли файл на Диске: `sha256`, время
последнего изменения содержимого и отметка «изменился / без изменений».
Если вы поправили таблицу, а после обновления написано «без изменений» — значит
публичная ссылка Яндекса отдаёт прежнюю версию файла (бывает с задержкой),
стоит повторить обновление через минуту.
Как это устроено:
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie
(`HttpOnly`, `SameSite=Lax`, `Secure` при https), ключ — пароль админа,
срок 12 часов; неудачные попытки логируются;
- все действия — под `/api/admin/*`, без cookie отвечают `401`;
- ссылка и интервал хранятся в `SETTINGS_FILE` (в docker — том
`schedule_data`, `/data/settings.json`), поэтому переживают перезапуск;
если файл недоступен, настройки живут в памяти;
- страница панели отдаётся фронтендом по `ADMIN_PATH` с заголовком
`X-Robots-Tag: noindex`; путь нигде не публикуется и не попадает в `/`.
## Структура кода
```
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, ссылка, интервал, принудительное обновление
schedule/
xlsx.py чтение xlsx: sharedStrings, мержи, скрытые столбцы,
красный текст, серийные даты
source.py загрузка файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py листы → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error
settings.py хранилище настроек админки (ссылка, интервал) в JSON-файле
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/ логотипы (owl.svg, owl-white.svg), иконки приложения
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
deploy/
nginx-dev.conf сайт тестового стенда (dev.schedule.our-place.ru)
README-dev.md как поднять тестовый стенд рядом с продом
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
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)
```
## 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` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) |
| GET | `/healthz` | живость процесса (не обращается к таблице) |
Фронт проксирует только то, что использует, под префиксом `/api/...`:
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
## Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | интервал обновления, мин (правится в админке) |
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `ADMIN_PATH` | — | api+frontend | секретный путь панели (пусто = админка выключена) |
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | учётные данные администратора |
| `SETTINGS_FILE` | `/data/settings.json` | api | где хранить ссылку и интервал |
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
| `API_PORT` | `8000` | docker | внешний порт api |
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) |
`.env` ищется вверх от каталога модуля (backend — до корня репозитория);
`load_dotenv` не перезаписывает уже выставленные переменные окружения.
## Запуск
### Продакшен (Docker)
```bash
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
./deploy.sh # = docker compose build && up -d
```
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
### Разработка
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
make dev # api :8000 и фронт :8080, оба с --reload
```
### Тесты
```bash
make test # 104 теста, сеть не нужна
```
## Развёртывание на сервере
Код хранится в Gitea (`git.our-place.ru`, web через nginx, git-ssh на порту
2222) и живёт в `/opt/schedule`:
```bash
# первый запуск
cd /opt/schedule
# .env: SCHEDULE_LINK=... , API_PORT=127.0.0.1:8000, FRONTEND_PORT=127.0.0.1:8090
sudo docker compose up -d --build
# обновление
cd /opt/schedule && git pull && sudo docker compose up -d --build
```
nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт),
сертификат Let's Encrypt. Порты api и frontend слушаются только на loopback.
### Тестовый стенд
Новые штуки обкатываются на отдельном стенде `dev.schedule.our-place.ru`
(ветка `admin-panel`), прод при этом не меняется: другой каталог
(`/opt/schedule-dev`), другие порты (8001/8091), свой том настроек.
Пошаговая инструкция — `deploy/README-dev.md`, конфиг nginx —
`deploy/nginx-dev.conf`.
## Замечания
- Принудительное обновление и смена ссылки живут только в админке
(`/api/admin/*` под cookie-сессией): публичного `force` в API нет.
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
api его придётся выносить в общее хранилище (настройки админки уже в файле).
- `ADMIN_PATH` защищает только от «случайного» обнаружения панели; реальная
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
«нечётное число строк, недели могли сдвинуться».