Files
schedule/README.md
Vladislav Kopnin cfa6a99639 Админка: секретный путь, смена ссылки, интервал и принудительное обновление
Панель без публичного входа: страница живёт по ADMIN_PATH, логин/пароль —
ADMIN_LOGIN/ADMIN_PASSWORD из .env (пусто = админка выключена и маршруты
/api/admin/* не регистрируются, прод можно не трогать).

Умеет: посмотреть текущую ссылку и состояние кэша, вставить новую ссылку
(проверяется скачиванием и разбором до сохранения, при ошибке откат),
принудительно обновить таблицу вне интервала и без проверки modified,
задать интервал в минутах (1..1440).

Реализация:
- backend/admin.py — cookie-сессия на HMAC (ключ = пароль, 12 часов,
  HttpOnly/SameSite/Secure-за-https), сравнение за постоянное время;
  compare_digest по байтам — кириллица в логине не роняет сервер;
- schedule/settings.py — ссылка и интервал в JSON (SETTINGS_FILE), атомарная
  запись, терпимость к битому/недоступному файлу (тогда живёт в памяти);
- schedule/cache.py — интервал вместо TTL, apply_link с откатом,
  force_reload, state() для панели; кэш без ссылки больше не роняет старт;
- frontend: страница admin.html (вне static, прямой /admin.html закрыт) и
  прокси /api/admin/* с телом и Set-Cookie;
- docker-compose: том schedule_data для настроек, ADMIN_* в окружении;
- deploy/: nginx-конфиг и инструкция для стенда dev.schedule.our-place.ru.

Тесты: 138 (новые — админ-API, сессии, ссылка/интервал, force, хранилище).
2026-09-28 12:06:25 +03:00

265 lines
17 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+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
```
Каждый день занимает блок строк, и строки идут парами: «числитель» /
«знаменатель». У каждой пары строк — своя неделя семестра, а даты в ячейках —
это соседние недели по порядку. Поэтому недели семестра собираются со всего
листа (повторы отбрасываются по паре «чётность + дата»), а текущая чётность
(«Сейчас» на бейдже) определяется по сегодняшней дате.
## Как разбирается ячейка занятия
Ячейка свободного формата, и её содержимое проходит единый конвейер
(`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`).
Как это устроено:
- вход — `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: блокировка, интервал, modified, 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 и поведение кэша (TTL/modified/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` защищает только от «случайного» обнаружения панели; реальная
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
«нечётное число строк, недели могли сдвинуться».