# Расписание Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних библиотек и отдаётся как JSON API и как сайт, который можно «установить» на главный экран телефона (PWA). Умеет: - расписание по группам, преподавателям и аудиториям с разделением числитель/знаменатель и бейджем текущей недели; - занятость всех аудиторий по дням и поиск свободных на конкретную пару; - совпадающие недели показываются одним списком, различающиеся — двумя колонками, пустая — «Окно»; - примечания из таблицы (красный текст) — отдельной строкой в карточке; - PWA: манифест, service worker, иконки — «на главный экран». ## Архитектура ``` Яндекс.Диск (xlsx) │ скачивание по публичной ссылке ▼ api — FastAPI, порт 8000 • качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree) • кэш в памяти: TTL + проверка даты изменения файла + stale-while-error • один воркер, поэтому кэш общий ▲ SCHEDULE_API=http://api:8000 (внутри docker-сети) │ frontend — FastAPI, порт из .env (по умолчанию 8080) • отдаёт index.html / app.js / style.css / иконки • /api/* — тонкий прокси к api (браузер не знает адрес бэкенда) ▲ nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта> ``` Роли: - **docker** изолирует оба сервиса, перезапускает их при падении (`restart: unless-stopped`) и следит за здоровьем (healthcheck на `/healthz`); frontend стартует, только когда api стал healthy. - Оба порта публикуются **только на 127.0.0.1** (жёстко задано в `docker-compose.yml`): из интернета контейнеры напрямую недоступны. - **nginx** — единственная дверь из интернета: принимает https и проксирует на фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик). ## Установка на сервер ### Docker (deploy.sh) Скачайте zip с проектом, распакуйте и заполните `.env`: ```bash unzip schedule.zip && cd schedule cp .env.example .env && nano .env # SCHEDULE_LINK обязателен; ADMIN_* — по желанию sudo ./deploy.sh # собрать образы и запустить curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает ``` `deploy.sh` проверяет, что Docker и `.env` на месте, собирает образы и поднимает контейнеры. Обновление — тем же скриптом: `git pull && sudo ./deploy.sh`. Почему Docker: сервисы изолированы, автоматически перезапускаются, не зависят от Python в системе и обновляются одной командой. ### Без Docker (systemd) ```bash sudo apt install -y python3 python3-venv python3-pip # Python 3.11+ python3 -m venv .venv .venv/bin/pip install -r requirements.txt cp .env.example .env && nano .env # SCHEDULE_LINK (+ ADMIN_*, если нужна админка) ``` Две службы systemd (`/etc/systemd/system/`), затем `sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend`: ```ini # schedule-api.service [Unit] Description=Schedule API (FastAPI) After=network-online.target Wants=network-online.target [Service] User=www-data WorkingDirectory=/путь/к/проекту/src/backend ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000 Restart=always RestartSec=3 [Install] WantedBy=multi-user.target # schedule-frontend.service [Unit] Description=Schedule frontend (FastAPI) After=network-online.target schedule-api.service Wants=network-online.target schedule-api.service [Service] User=www-data WorkingDirectory=/путь/к/проекту/src/frontend Environment=SCHEDULE_API=http://127.0.0.1:8000 ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080 Restart=always RestartSec=3 [Install] WantedBy=multi-user.target ``` Если службы работают не от владельца каталога проекта, выдайте пользователю службы права на запись в `data/` (там админка хранит ссылку, интервал и историю входов) или задайте свой путь в `SETTINGS_FILE`. **nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть): ```bash sudo apt install -y nginx certbot # временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080 curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово ``` `deploy/nginx-site.sh` генерирует финальный конфиг (http → https, прокси на `FRONTEND_PORT` из `.env`, таймауты под холодный кэш), включает его, проверяет `nginx -t` и перечитывает nginx. Посмотреть конфиг, ничего не меняя: `deploy/nginx-site.sh --print --domain sched.example.ru`. ### Обслуживание - **Обновление**: Docker — `cd <каталог проекта> && sudo ./deploy.sh`; система — заменить файлы проекта и `sudo systemctl restart schedule-api schedule-frontend`. - **Диагностика**: Docker — `sudo docker compose ps` и `sudo docker logs api --tail 50`; система — `systemctl status schedule-api` и `journalctl -u schedule-api -u schedule-frontend`. Состояние кэша — `/api/status` (смотреть поле `error`). - **Ротация логов контейнеров** — настроить сразу: без лимита логи растут вечно и однажды забивают диск. `/etc/docker/daemon.json`: ```json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } ``` затем `sudo systemctl restart docker`. - **Диск**: следить `df -h /`; раз в месяц `sudo docker system prune -af && sudo journalctl --vacuum-size=200M`. ## Локальная разработка Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker). ```bash python3 --version # должен быть 3.11 или новее cp .env.example .env # вписать SCHEDULE_LINK make run # создаст .venv, поставит зависимости, поднимет оба сервиса ``` Открыть (API — ). Остановить — `Ctrl+C`; отвязать от терминала — `nohup make run &`. Данные скачиваются сразу на старте (несколько секунд) и дальше обновляются по интервалу. Полезные команды (`make help` — весь список): | Команда | Что делает | |---|---| | `make venv` | создать `.venv` и установить зависимости | | `make run` (или `make dev`) | запустить API и сайт | | `make run-api` / `make run-front` | запустить только один из сервисов | | `make test` | прогнать тесты | | `make nginx-print` / `make nginx-install` | показать / установить конфиг nginx | Без 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 ``` ### Тесты ```bash make test # 121 тест, сеть не нужна ``` ## Зависимости **Системные требования** | Что | Зачем | |---|---| | Python 3.11+ | локальная разработка и режим «в систему» | | Docker + docker compose v2 | режим Docker (опционально) | **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 | тесты — только dev | | httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` — только dev | Больше ничего не нужно: xlsx разбирается стандартной библиотекой, базы данных нет, кэш — в памяти процесса. httpx2 в рабочем коде не используется и нужен только тестам; модуль импортируется как `httpx2` — httpx перешёл под опеку Pydantic и был переименован в августе 2026, `import httpx` больше не работает. ### Если установка не идёт - Проверьте `python3 --version`: нужен 3.11 или новее. Если под `python3` скрывается старая версия, создайте окружение нужной явно: `python3.12 -m venv .venv`. - Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте пакет venv: `sudo apt install python3-venv` (для нестандартной версии — например, `python3.12-venv`). ### Если pip не находит пакеты: зеркала Если pip настроен на корпоративное/национальное зеркало, оно может отставать от PyPI — свежих пакетов там ещё нет. Диагностика и обход: ```bash pip index versions httpx2 # есть ли пакет на текущем индексе pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало pip config set global.index-url <зеркало> # закрепить зеркало навсегда ``` Зеркала, проверенные 07.10.2026 (пакеты `httpx2`/`httpcore2` на месте; URL подставлять как есть в `-i` / `index-url`): | Зеркало | URL | |---|---| | PyPI, официальный | `https://pypi.org/simple` | | TUNA (Tsinghua) | `https://pypi.tuna.tsinghua.edu.cn/simple` | | Huawei Cloud | `https://mirrors.huaweicloud.com/repository/pypi/simple` | | BFSU | `https://mirrors.bfsu.edu.cn/pypi/web/simple` | | SJTU | `https://mirror.sjtu.edu.cn/pypi/web/simple` | | USTC | `https://mirrors.ustc.edu.cn/pypi/simple` | | SUSTech | `https://mirrors.sustech.edu.cn/pypi/simple` | Примечания: Alibaba Cloud (`https://mirrors.aliyun.com/pypi/simple`) обычно тоже свежий, но из части сетей недоступен; Яндекс-зеркало (`mirror.yandex.ru`) PyPI не раздаёт вовсе. Зеркала отстают по-разному, поэтому для свежих пакетов надёжнее разово ставить с официального индекса. ## Переменные окружения | Переменная | По умолчанию | Где | Описание | |---|---|---|---| | `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 | таймаут прокси к бэкенду, сек | | `ADMIN_PATH` | — | api+frontend | секретный путь админки; пусто = админка выключена | | `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | логин и пароль администратора | | `ADMIN_MAX_ATTEMPTS` | `3` | api | сколько неудачных попыток входа допускается | | `ADMIN_LOCKOUT_MINUTES` | `15` | api | на сколько минут блокировать вход после них | | `SETTINGS_FILE` | `data/settings.json` | api | где хранить ссылку, интервал и историю входов | | `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) | | `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) | `.env` ищется вверх от каталога модуля: и backend, и frontend находят общий `.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные переменные окружения. ## Админка Необязательная страница для управления источником данных. Отдельного входа на сайте нет: панель открывается по секретному пути `ADMIN_PATH`, а пока `ADMIN_LOGIN`/`ADMIN_PASSWORD` пусты, админка выключена и наружу ничего не торчит (маршруты `/api/admin/*` не регистрируются). Включить: заполнить в `.env` четыре переменные и перезапустить службу. ```ini ADMIN_PATH=/panel-8f3a91x7 # придумайте длинный и неочевидный ADMIN_LOGIN=admin ADMIN_PASSWORD=<длинный пароль> ``` Что умеет: - **посмотреть состояние**: сколько групп, когда обновлялось, дата файла на Диске, изменился ли файл при последнем чтении (`sha256`), есть ли ошибки; - **вставить новую ссылку** на таблицу: она проверяется скачиванием и разбором до сохранения — нерабочая не применится, данные на сайте не пострадают; - **задать интервал обновления** в минутах (`1…1440`); - **обновить сейчас**: скачивает таблицу принудительно, не дожидаясь интервала (по интервалу файл перечитывается, только если на Диске изменилась дата); - **видеть входы**: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы. Защита входа: после `ADMIN_MAX_ATTEMPTS` неудач (по умолчанию 3) вход с этого адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия — подписанная cookie на 12 часов. Настройки (ссылка, интервал, история входов) хранятся в `SETTINGS_FILE` (по умолчанию `data/settings.json` в корне проекта; в docker — в томе `schedule_data`). Если файл недоступен для записи, изменения живут в памяти до перезапуска, а панель предупредит об этом. ## Структура кода ``` src/ backend/ main.py FastAPI: маршруты, create_app(cache), /healthz admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force schedule/ xlsx.py чтение xlsx: общие строки, объединённые ячейки, скрытые столбцы, красный текст, серийные даты source.py скачивание файла по публичной ссылке Я.Диска lessons.py разбор ячеек занятий (общий конвейер ролей) parsing.py лист → расписание, недели, индексы преподавателей/аудиторий cache.py ScheduleCache: блокировка, интервал, stale-while-error, force settings.py хранилище настроек админки (ссылка, интервал, история входов) errors.py ScheduleError frontend/ main.py FastAPI: статика + прокси /api/* (таймаут 150 c) admin.html страница админки (вне static: прямой доступ закрыт) static/ index.html, app.js, style.css manifest.json PWA: имя, иконки, standalone-режим sw.js минимальный service worker (без кэша) для установки приложения img/ логотипы и иконки приложения tools/ make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) deploy/ nginx-site.sh генерация и включение конфига nginx (ручная настройка) 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_admin.py админка: вход, блокировка, история, ссылка/интервал/force test_settings.py хранилище настроек админки test_deploy.py проверка, что образы копируют все нужные файлы ``` Данные админки (`data/settings.json`) — рабочие, в git не попадают. ## Как устроена таблица-источник Лист «Бакалавры и магистры» — это сетка: ``` столбцы 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`. - На вкладке «Аудитории» есть фильтры: **тип** (фразой, как в таблице), **вместимость** («не меньше N мест») и **оснащение** чекбоксами с выбором условия «все выбранные (И)» или «любое из выбранных (ИЛИ)». Выбранное запоминается в браузере, под списком видно «Показано N из M» и пояснения: сколько аудиторий скрыто из-за отсутствия данных о вместимости и какие доступны только по отдельным дням. - Там же переключатель «Занятость за день» / **«Свободные на паре»**: во втором режиме выбирается пара и показываются только свободные аудитории — вместе с фильтрами получается «свободная аудитория на 30 человек с проектором в 3-ю пару». - Приложение можно «установить» на главный экран (манифест + service worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. ## 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` | состояние кэша (`last_updated`, `table_modified`, `error`, …) | | GET | `/healthz` | живость процесса (не обращается к таблице) | В ответах `rooms-day` и `rooms-free` по каждой аудитории, кроме `room` и `info` (описание как в таблице), идут разобранные атрибуты: `type` (тип фразой из таблицы: «комп.класс», «кабинет английского», «лекционная»…), `capacity` (мест; для компьютерных классов — рабочие места; `null`, если в таблице нет данных), `equipment` (список: «ПК», «Проектор», «Интерактивная панель», «Телевизор», «Маркер», «Меловая доска») и `available_days` (дни, если в описании указано «только четверг, пятница»). Аудитории с ограничением по дням не попадают в `/rooms-free` для остальных дней. Фронт проксирует только то, что использует, под префиксом `/api/...`: `groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`, `room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`. Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.