Админка осталась отдельной ветвью admin-panel; в master её нет (ни кода, ни страниц, ни переменных окружения, ни тома настроек). Что перенесено: - parsing: имена групп вида «ИВТ-ТОП-11БО» распознаются, порядок групп = порядок столбцов таблицы (похожие заголовки с пробелами группой не считаются); - main: GET /groups отдаёт группы в порядке столбцов; интервал обновления из SCHEDULE_INTERVAL_MINUTES (со старым SCHEDULE_CACHE_TTL как fallback); - cache: по интервалу сверяем modified — если файл не менялся, не скачиваем; при загрузке считаем sha256 и пишем в лог и /status, изменился ли файл; - source: запросы к Яндекс.Диску с no-cache и меткой времени; - frontend: ответы API с Cache-Control: no-store, общий .env из корня проекта (и backend, и frontend), страницы/прокси админки нет; - Dockerfile/compose/.env.example: без ADMIN_* и без тома настроек; в api-образ попадают все модули backend (проверяется тестом test_deploy); - Makefile: make help/venv/run/run-api/run-front/test + docker-цели — «make run» поднимает всё локально без Docker; - README: быстрый старт, полный список зависимостей, запуск локально и в Docker; - deploy/: пример nginx и инструкция «второй стенд рядом с основным». Тесты: 114 (удалены админские), все зелёные.
290 lines
18 KiB
Markdown
290 lines
18 KiB
Markdown
# Расписание
|
||
|
||
Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица
|
||
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
|
||
API и отображается на фронте. Деплой: `schedule.our-place.ru`.
|
||
|
||
## Быстрый старт (без Docker)
|
||
|
||
Нужен только Python 3.11+ и публичная ссылка на таблицу.
|
||
|
||
```bash
|
||
cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска
|
||
make run # создаст .venv, поставит зависимости и запустит оба сервиса
|
||
```
|
||
|
||
Открыть <http://127.0.0.1:8080>. Остановить — `Ctrl+C`.
|
||
Подробнее — разделы «Зависимости» и «Запуск» ниже.
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
Яндекс.Диск (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`.
|
||
|
||
## Структура кода
|
||
|
||
```
|
||
src/
|
||
backend/
|
||
main.py FastAPI: маршруты, create_app(cache), /healthz
|
||
schedule/
|
||
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
|
||
скрытые столбцы, красный текст, серийные даты
|
||
source.py скачивание файла по публичной ссылке Я.Диска
|
||
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
||
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
||
cache.py ScheduleCache: блокировка, интервал, stale-while-error
|
||
errors.py ScheduleError
|
||
frontend/
|
||
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
||
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)
|
||
tools/
|
||
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
||
deploy/
|
||
nginx-dev.conf пример сайта nginx для отдельного стенда
|
||
README-dev.md как поднять второй стенд рядом с основным
|
||
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_deploy.py проверка, что образы копируют все нужные файлы
|
||
```
|
||
|
||
## 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 | пауза между попытками при сбое источника |
|
||
| `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, и frontend находят общий
|
||
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
|
||
переменные окружения.
|
||
|
||
## Зависимости
|
||
|
||
Проект состоит из двух сервисов на Python (FastAPI): `api` и `frontend`.
|
||
Всё ставится одной командой `make venv` (или вручную, см. ниже).
|
||
|
||
**Системные требования**
|
||
|
||
| Что | Зачем |
|
||
|---|---|
|
||
| Python 3.11+ (проверено на 3.12 и 3.14) | оба сервиса |
|
||
| Docker + docker compose v2 | только для запуска в контейнерах (опционально) |
|
||
| `rsvg-convert` (пакет `librsvg2-bin`) | только для пересборки иконок `tools/make_icons.py` |
|
||
|
||
**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 | тесты (`make test`) — только dev |
|
||
| httpx2 | 2.12.0 | HTTP-клиент для `TestClient` в тестах — только dev |
|
||
|
||
Ничего, кроме этого, не нужно: xlsx разбирается стандартной библиотекой
|
||
(`zipfile` + `xml.etree`), базы данных нет, кэш — в памяти процесса.
|
||
|
||
## Запуск
|
||
|
||
### Локально, без Docker (обычный компьютер)
|
||
|
||
```bash
|
||
git clone <адрес репозитория> && cd Schedule
|
||
cp .env.example .env # открыть .env и вписать SCHEDULE_LINK
|
||
make run # = make venv + запуск api и сайта
|
||
```
|
||
|
||
`make run` создаёт виртуальное окружение `.venv`, ставит зависимости и
|
||
поднимает два сервиса с авто-перезагрузкой:
|
||
|
||
- API — <http://127.0.0.1:8000> (например, `/status` и `/groups`);
|
||
- сайт — <http://127.0.0.1:8080> (его и открывать в браузере).
|
||
|
||
Остановить — `Ctrl+C`. Данные тянутся из интернета при первом запросе
|
||
(скачивание занимает несколько секунд), дальше обновляются по интервалу.
|
||
|
||
Полезные команды (`make help` — весь список):
|
||
|
||
| Команда | Что делает |
|
||
|---|---|
|
||
| `make venv` | создать `.venv` и установить зависимости |
|
||
| `make run` (или `make dev`) | запустить API и сайт |
|
||
| `make run-api` / `make run-front` | запустить только один из сервисов |
|
||
| `make test` | прогнать тесты |
|
||
|
||
Если хочется без 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
|
||
```
|
||
|
||
### В 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
|
||
make test # 114 тестов, сеть не нужна
|
||
```
|
||
|
||
## Развёртывание на сервере
|
||
|
||
Код хранится в 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.
|
||
|
||
## Замечания
|
||
|
||
- Публичного принудительного обновления в API нет: расписание обновляется по
|
||
интервалу (`SCHEDULE_INTERVAL_MINUTES`), а если таблица не менялась,
|
||
скачивание не повторяется (сверяется `modified`).
|
||
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
|
||
api его придётся выносить в общее хранилище.
|
||
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
||
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
|
||
«нечётное число строк, недели могли сдвинуться».
|