Vladislav Kopnin 34a46b6832 Админка: кнопки в шапке сайта, красный выход и путь, который нигде не утекает
Интерфейс:
- после входа на главной появляются «Админка» и «Выйти» — в шапке, перед
  переключателем темы и после него соответственно (порядок: Админка, тема,
  Выйти);
- «Выйти» теперь красная и заметно реагирует на наведение (темнеет,
  приподнимается, тень) — и на сайте, и в админке (одна кнопка в шапке,
  из карточки убрана);
- в админке убраны «осталось попыток», путь к файлу настроек и карточка
  «Данные расписания» (дублировала шапку); блокировка показывается отдельной
  плашкой только когда действительно активна.

Безопасность пути:
- путь к панели не отдаётся ни в HTML, ни в JSON (из /api/admin/state убран),
  ни в JS: на главной для вошедшего только флаг, а кнопка «Админка» ведёт на
  служебный /api/admin/goto, который отвечает 302 на секретный путь;
- без сессии /api/admin/goto и /api/admin/state отвечают 401, неизвестные пути
  дают 404 — перебором путь не находится;
- страница панели: noindex/nofollow/noarchive, Referrer-Policy: no-referrer,
  X-Frame-Options: DENY, Cache-Control: no-store;
- robots.txt без пути (только Disallow: /api/), внешние ссылки в панели —
  rel=noopener noreferrer.

Тесты: 161 (состояние без пути, /goto: 401 без входа и 302 с ним).
2026-10-10 12:54:54 +03:00
2026-10-07 17:28:55 +03:00

Расписание

Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (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:

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)

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:

# 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 уже должен быть):

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:

    { "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).

python3 --version        # должен быть 3.11 или новее
cp .env.example .env     # вписать SCHEDULE_LINK
make run                 # создаст .venv, поставит зависимости, поднимет оба сервиса

Открыть http://127.0.0.1:8080 (API — http://127.0.0.1:8000). Остановить — 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 — то же самое руками:

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

Тесты

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 — свежих пакетов там ещё нет. Диагностика и обход:

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 четыре переменные и перезапустить службу.

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 символа (<wbr>), поэтому карточка не вылезает за экран даже в старых браузерах, где нет overflow-wrap.
  • На вкладке «Аудитории» есть фильтры: тип (в карточке — формулировка из таблицы, в фильтре близкие варианты объединены: «комп.класс» включает «комп.класс Linux» и «компьютерный класс», «кабинет английского» — «английский»; есть пункт «без типа»), вместимость («не меньше 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 (тип фразой из таблицы: «комп.класс», «кабинет английского», «лекционная»…) и type_group (тот же тип, но близкие формулировки сведены к одной — «комп.класс», «комп.класс Linux» и «компьютерный класс» дают группу «комп.класс»), 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}) доступно только на бэкенде.

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%