From 5ac3db50d362d364a8f94310bcde834910e0bfd0 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 9 Sep 2026 12:15:05 +0300 Subject: [PATCH] =?UTF-8?q?README:=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=20(prod/dev),=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0,=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=BE=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B0,=20API,=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..16feb20 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# Расписание + +Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица +университета (файл 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}`. + +## Структура кода + +``` +src/ + backend/ + main.py FastAPI: маршруты, create_app(cache), /healthz + schedule/ + xlsx.py чтение xlsx: общие строки, объединённые ячейки, + скрытые столбцы, красный текст, серийные даты + source.py скачивание файла по публичной ссылке Я.Диска + lessons.py разбор ячеек занятий (общий конвейер ролей) + parsing.py лист → расписание, недели, индексы преподавателей/аудиторий + cache.py ScheduleCache: блокировка, TTL, modified, stale-while-error + errors.py ScheduleError + frontend/ + main.py FastAPI: статика + прокси /api/* (таймаут 150 c) + static/ + index.html, app.js, style.css + img/ логотипы (owl.svg, owl-white.svg), favicon.png +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_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 — до корня репозитория); +`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. + +## Замечания + +- Принудительное обновление таблицы (пропуск кэша, `force`) из API намеренно + убрано; планируется вернуть вместе с авторизацией и ролью администратора. +- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании + api его придётся выносить в общее хранилище. +- Строки внутри дня в таблице строго чередуются «числитель / знаменатель». + Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение + «нечётное число строк, недели могли сдвинуться».