Files
schedule/README.md
Vladislav Kopnin 34b975da69 Перенос в master всего, кроме админки: ТОП-группы, порядок групп, запуск без Docker
Админка осталась отдельной ветвью 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 (удалены админские), все зелёные.
2026-10-06 15:15:44 +03:00

260 lines
16 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 и отображается на фронте.
## Быстрый старт
Нужен только 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+ | оба сервиса |
| Docker + docker compose v2 | только для запуска в контейнерах (опционально) |
**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 тестов, сеть не нужна
```