# Расписание Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица университета (файл xlsx), который скачивается, разбирается, превращается в JSON API и отображается на фронте. ## Быстрый старт Нужен только Python 3.11+ и публичная ссылка на таблицу. ```bash cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска make run # создаст .venv, поставит зависимости и запустит оба сервиса ``` Открыть . Остановить — `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 символа (``), поэтому карточка не вылезает за экран даже в старых браузерах, где нет `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 — (например, `/status` и `/groups`); - сайт — (его и открывать в браузере). Остановить — `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 тестов, сеть не нужна ```