Vladislav Kopnin 688bb44ff1 Перенос в 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 14:59:51 +03:00

Расписание

Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица университета (файл xlsx), который скачивается, разбирается, превращается в JSON API и отображается на фронте. Деплой: schedule.our-place.ru.

Быстрый старт (без Docker)

Нужен только Python 3.11+ и публичная ссылка на таблицу.

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 (обычный компьютер)

git clone <адрес репозитория> && cd Schedule
cp .env.example .env        # открыть .env и вписать SCHEDULE_LINK
make run                    # = make venv + запуск api и сайта

make run создаёт виртуальное окружение .venv, ставит зависимости и поднимает два сервиса с авто-перезагрузкой:

Остановить — Ctrl+C. Данные тянутся из интернета при первом запросе (скачивание занимает несколько секунд), дальше обновляются по интервалу.

Полезные команды (make help — весь список):

Команда Что делает
make venv создать .venv и установить зависимости
make run (или make dev) запустить API и сайт
make run-api / make run-front запустить только один из сервисов
make test прогнать тесты

Если хочется без make — то же самое руками:

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 (продакшен)

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.

Тесты

make test                   # 114 тестов, сеть не нужна

Развёртывание на сервере

Код хранится в Gitea (git.our-place.ru, web через nginx, git-ssh на порту 2222) и живёт в /opt/schedule:

# первый запуск
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 его придётся выносить в общее хранилище.
  • Строки внутри дня в таблице строго чередуются «числитель / знаменатель». Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение «нечётное число строк, недели могли сдвинуться».
Description
No description provided
Readme 892 KiB
Languages
Python 69.4%
JavaScript 12.3%
HTML 8.8%
CSS 6.3%
Shell 1.9%
Other 1.3%