Compare commits

..

9 Commits

Author SHA1 Message Date
a4798c4a36 Поправка по кэшу: по интервалу снова сверяем modified, всегда качает только кнопка
Как и просили: фоновое обновление по интервалу сначала смотрит на дату
изменения файла и, если таблица не менялась, не скачивает её. Всегда
перечитывать таблицу — только принудительное обновление из админки
(force_reload, кнопка «Обновить сейчас»).

Тесты: по интервалу — без скачивания при неизменном modified; кнопка —
скачивает независимо от modified (test_force_refresh_ignores_ttl_and_modified).
2026-10-06 15:01:07 +03:00
475eb3801e Группы: ТОП-имена и порядок как в таблице
В таблице появились группы с «ТОП» в имени (ИВТ-ТОП-11БО, ИТ-ТОП-12БО):
шаблон требовал цифру сразу после первого дефиса, поэтому такие столбцы
пропускались целиком. Теперь имя группы — буквенные части через дефис,
номер потока и необязательный суффикс, т.е. распознаются и «ИВТ-11БО»,
и «ПИЭ-13МО», и «ИВТ-ТОП-11БО» (похожие заголовки с пробелами — нет).

GET /groups больше не сортирует по алфавиту с приоритетом БО/МО, а отдаёт
группы в порядке столбцов таблицы — как просили, «как в расписании».
Состав групп (появление/переименование/скрытие) подхватывается сам при
следующем обновлении, потому что источник истины — заголовок листа.

Тесты: ТОП-имена + порядок столбцов, отсев похожих не-групповых заголовков.
2026-09-30 09:42:27 +03:00
67a6671d90 Админка: всплывающие уведомления вместо alert()
alert() блокирует страницу и требует клика; заменил на тост внизу экрана:
появляется с анимацией, сам скрывается через 15 секунд (вход — 5 сек),
закрывается крестиком. Цвет полосы зависит от исхода: зелёный — успех,
красный — ошибка, синий — информация (например, «файл не изменился»).

Сообщения тех же действий: вход, сохранение ссылки, сохранение интервала,
принудительное обновление, ошибки запросов.
2026-09-29 15:32:47 +03:00
d177560f7d Видно, изменился ли файл при обновлении (sha256 + отметка в админке)
Просьба с эксплуатации: после «Обновить сейчас» непонятно, подхватились ли
правки таблицы. Теперь кэш считает sha256 скачанного файла и сравнивает с
предыдущим чтением:

- в логе: «Расписание загружено: N групп …; файл изменился/не изменился
  (sha256 ab12cd34)»;
- состояние для админки дополнено полями changed / changed_at / content_hash;
- в панели чипы «Файл при последнем обновлении», «Содержимое менялось»,
  «sha256 файла», а после кнопки — понятное сообщение: «обнаружены изменения»
  либо «файл не изменился (на Диске та же версия)» с подсказкой про задержку
  публикации у Яндекса;
- ответы API отдаются с Cache-Control: no-store, чтобы браузер не показывал
  закэшированные данные после обновления;
- запросы к Яндекс.Диску идут с no-cache заголовками и меткой времени —
  меньше шансов получить закэшированную версию файла.

Тест: test_state_reports_file_changes (первая загрузка, изменение файла,
повторное чтение без изменений).
2026-09-29 15:27:01 +03:00
d4b47c30bf Кэш: по интервалу таблица перечитывается всегда
Поле modified у публичной ссылки Яндекса может «застыть» (кэш на стороне
Диска), и проверка «modified не изменился → не скачиваем» держала старые
данные до неопределённого момента: стенд показывал прежнее расписание, хотя
в таблице правки уже были. Теперь на каждое обновление (и по интервалу, и по
кнопке) таблица скачивается заново; modified только показывается в админке.

Плюс к этому:
- при старте пишем в лог предупреждение, если ссылка в настройках отличается
  от SCHEDULE_LINK из окружения (именно так терялось время в прошлый раз:
  на стенде в настройках была сохранена другая ссылка);
- на странице админки у поля ссылки пояснение, что значение хранится в
  настройках стенда и имеет приоритет над .env.
2026-09-29 13:50:17 +03:00
ab92fb44e1 Доки стенда: порт nginx должен совпадать с FRONTEND_PORT из .env 2026-09-28 13:56:32 +03:00
f69003be75 Фикс сборки: api-образ не копировал admin.py (контейнер падал на импорте)
Dockerfile для stage api копировал только main.py и schedule/, поэтому в
контейнере 'from admin import ...' давал ModuleNotFoundError — api стартовал
и сразу падал, compose останавливался на 'dependency failed to start'.

Добавлен COPY admin.py и тест tests/test_deploy.py: проверяет, что в образ
api попадают все модули backend, в frontend — страница админки и статика,
а compose передаёт ADMIN_* и подключает том настроек.
2026-09-28 13:37:23 +03:00
e007a85f56 Админка: защита от перебора пароля и правка дефолта SETTINGS_FILE
- не больше 10 неудачных входов с одного адреса за 5 минут, дальше 429
  (панель доступна из интернета, простой перебор закрываем);
  успешный вход сбрасывает счётчик;
- .env.example: SETTINGS_FILE пустой — локально настройки живут в памяти,
  в docker compose подставляет /data/settings.json по умолчанию.
2026-09-28 12:07:45 +03:00
cfa6a99639 Админка: секретный путь, смена ссылки, интервал и принудительное обновление
Панель без публичного входа: страница живёт по ADMIN_PATH, логин/пароль —
ADMIN_LOGIN/ADMIN_PASSWORD из .env (пусто = админка выключена и маршруты
/api/admin/* не регистрируются, прод можно не трогать).

Умеет: посмотреть текущую ссылку и состояние кэша, вставить новую ссылку
(проверяется скачиванием и разбором до сохранения, при ошибке откат),
принудительно обновить таблицу вне интервала и без проверки modified,
задать интервал в минутах (1..1440).

Реализация:
- backend/admin.py — cookie-сессия на HMAC (ключ = пароль, 12 часов,
  HttpOnly/SameSite/Secure-за-https), сравнение за постоянное время;
  compare_digest по байтам — кириллица в логине не роняет сервер;
- schedule/settings.py — ссылка и интервал в JSON (SETTINGS_FILE), атомарная
  запись, терпимость к битому/недоступному файлу (тогда живёт в памяти);
- schedule/cache.py — интервал вместо TTL, apply_link с откатом,
  force_reload, state() для панели; кэш без ссылки больше не роняет старт;
- frontend: страница admin.html (вне static, прямой /admin.html закрыт) и
  прокси /api/admin/* с телом и Set-Cookie;
- docker-compose: том schedule_data для настроек, ADMIN_* в окружении;
- deploy/: nginx-конфиг и инструкция для стенда dev.schedule.our-place.ru.

Тесты: 138 (новые — админ-API, сессии, ссылка/интервал, force, хранилище).
2026-09-28 12:06:25 +03:00
23 changed files with 560 additions and 1506 deletions

View File

@@ -1,30 +1,25 @@
# --- расписание -------------------------------------------------------------- # --- расписание -------------------------------------------------------------
# Публичная ссылка на Яндекс.Диск с таблицей расписания (обязательна) # Публичная ссылка на Яндекс.Диск (обязательна)
SCHEDULE_LINK= SCHEDULE_LINK=
# Интервал обновления в минутах (переживает перезапуск: правится в админке)
# Как часто перечитывать таблицу, минут
SCHEDULE_INTERVAL_MINUTES=15 SCHEDULE_INTERVAL_MINUTES=15
# Устаревшее: интервал в секундах (используется, только если не задан предыдущий) # Пауза между повторными попытками при сбое источника, секунд
SCHEDULE_CACHE_TTL=900
# Пауза между повторными попытками, если источник недоступен, секунд
SCHEDULE_RETRY_AFTER=60 SCHEDULE_RETRY_AFTER=60
# Устаревшее: интервал в секундах (используется, только если не задан SCHEDULE_INTERVAL_MINUTES)
SCHEDULE_CACHE_TTL=900
# --- порты (только для docker) ------------------------------------------------- # --- админка (пусто = выключена) ---------------------------------------------
# Оба порта публикуются на 127.0.0.1 (жёстко задано в docker-compose.yml): # Секретный путь страницы админки, например /manage-7f3a91
# снаружи сервисы отдаёт nginx, напрямую в интернет они не смотрят.
API_PORT=8000
FRONTEND_PORT=8080
# --- админка (необязательно) ----------------------------------------------------
# Пока эти переменные пустые, админка выключена и наружу ничего не торчит.
# Секретный путь страницы админки: придумайте длинный, например /panel-8f3a91x7
ADMIN_PATH= ADMIN_PATH=
# Логин и пароль администратора # Логин и пароль администратора
ADMIN_LOGIN= ADMIN_LOGIN=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Сколько неудачных попыток входа допускается и на сколько минут блокировать после них # Где хранить настройки (ссылка/интервал).
ADMIN_MAX_ATTEMPTS=3 # В docker по умолчанию /data/settings.json (том schedule_data);
ADMIN_LOCKOUT_MINUTES=15 # локально можно оставить пустым — тогда настройки живут в памяти.
# Где хранить настройки админки (ссылка, интервал, история входов).
# Пусто = data/settings.json в корне проекта; в docker задаётся автоматически.
SETTINGS_FILE= SETTINGS_FILE=
# --- порты и адреса ----------------------------------------------------------
API_PORT=8000
FRONTEND_PORT=8080
SCHEDULE_API=http://api:8000

6
.gitignore vendored
View File

@@ -6,9 +6,3 @@ __pycache__/
/.idea/ /.idea/
**/*.env **/*.env
.env .env
# локальные данные инструментов
.kilo/
# состояние админки (ссылка, интервал, история входов)
/data/

View File

@@ -1,68 +1,32 @@
PY := $(CURDIR)/.venv/bin/python .PHONY: build up down logs restart deploy ps dev test
PIP := $(CURDIR)/.venv/bin/pip
.PHONY: help venv run dev run-api run-front test build up down restart logs ps deploy nginx-print nginx-install build: ## Собрать образы микросервисов
help: ## Показать этот список команд
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
# --- локальный запуск (без Docker) -------------------------------------------
venv: ## Создать .venv и установить зависимости
@test -x $(PY) || python3 -m venv .venv
@test -x $(PIP) || { echo "Не удалось создать .venv — см. README, раздел «Если установка не идёт» (на Debian/Ubuntu нужен пакет python3-venv)"; exit 1; }
@$(PIP) install -q --upgrade pip
@$(PIP) install -q -r requirements-dev.txt
@echo "Зависимости готовы (.venv). Дальше: make run"
run: venv ## Запустить API и сайт локально, без Docker
@echo "API: http://127.0.0.1:8000/status"
@echo "Сайт: http://127.0.0.1:8080 (открыть в браузере)"
@echo "Остановить: Ctrl+C"
@trap 'kill 0' EXIT; \
(cd src/backend && $(PY) -m uvicorn main:app --reload --port 8000) & \
(cd src/frontend && $(PY) -m uvicorn main:app --reload --port 8080) & \
wait
dev: run ## То же, что run
run-api: venv ## Запустить только API (http://127.0.0.1:8000)
cd src/backend && $(PY) -m uvicorn main:app --reload --port 8000
run-front: venv ## Запустить только сайт (http://127.0.0.1:8080)
cd src/frontend && $(PY) -m uvicorn main:app --reload --port 8080
test: venv ## Прогнать тесты
$(PY) -m pytest tests -q
# --- Docker (продакшен) ------------------------------------------------------
build: ## Собрать образы
docker compose build docker compose build
up: ## Запустить сервисы в фоне up: ## Запустить все микросервисы одной командой
docker compose up -d docker compose up -d
down: ## Остановить и удалить контейнеры down: ## Остановить и удалить контейнеры
docker compose down docker compose down
restart: ## Перезапустить сервисы restart: ## Перезапустить микросервисы
docker compose restart docker compose restart
logs: ## Логи сервисов (Ctrl+C для выхода) logs: ## Логи всех микросервисов
docker compose logs -f docker compose logs -f
ps: ## Статус сервисов ps: ## Статус микросервисов
docker compose ps docker compose ps
deploy: ## Собрать и запустить (docker) deploy: ## Собрать и запустить (деплой на сервере)
./deploy.sh ./deploy.sh
# --- nginx (на сервере) ------------------------------------------------------ test: ## Прогнать тесты (локальный venv)
$(CURDIR)/.venv/bin/python -m pytest tests -q
# DOMAIN=sched.example.ru make nginx-print / nginx-install dev: ## Запустить локально без Docker (бэкенд + фронт)
nginx-print: ## Показать конфиг nginx (DOMAIN=домен), ничего не меняя @echo "Запуск бэкенда на :8000 и фронта на :8080..."
deploy/nginx-site.sh --print --domain $(DOMAIN) @trap 'kill 0' EXIT; \
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \
nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (DOMAIN=домен, с sudo) cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8080 & \
sudo deploy/nginx-site.sh --domain $(DOMAIN) wait

559
README.md
View File

@@ -1,19 +1,8 @@
# Расписание # Расписание
Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица
Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних университета (файл xlsx), который скачивается, разбирается, превращается в JSON
библиотек и отдаётся как JSON API и как сайт, который можно «установить» на API и отображается на фронте. Деплой: `schedule.our-place.ru`.
главный экран телефона (PWA).
Умеет:
- расписание по группам, преподавателям и аудиториям с разделением
числитель/знаменатель и бейджем текущей недели;
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
- совпадающие недели показываются одним списком, различающиеся — двумя
колонками, пустая — «Окно»;
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
- PWA: манифест, service worker, иконки — «на главный экран».
## Архитектура ## Архитектура
@@ -21,372 +10,20 @@
Яндекс.Диск (xlsx) Яндекс.Диск (xlsx)
│ скачивание по публичной ссылке │ скачивание по публичной ссылке
▼ ▼
api — FastAPI, порт 8000 api (FastAPI), порт 8000, доступ только с localhost
• качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree) • скачивает и разбирает xlsx без сторонних библиотек
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error • кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
• один воркер, поэтому кэш общий • один воркер, поэтому кэш общий
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети) ▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
│ │ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка)
frontend — FastAPI, порт из .env (по умолчанию 8080) frontend (FastAPI: статика + прокси), прод :8090, dev :8080
• отдаёт index.html / app.js / style.css / иконки • отдаёт index.html / app.js / style.css / картинки
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда) • /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
▲ ▲
nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта> nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
``` ```
Роли: ## Как устроено расписание в таблице
- **docker** изолирует оба сервиса, перезапускает их при падении
(`restart: unless-stopped`) и следит за здоровьем (healthcheck на
`/healthz`); frontend стартует, только когда api стал healthy.
- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в
`docker-compose.yml`): из интернета контейнеры напрямую недоступны.
- **nginx** — единственная дверь из интернета: принимает https и проксирует на
фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик).
## Установка на сервер
Скачайте zip с проектом, распакуйте и запустите одну команду:
```bash
unzip schedule.zip && cd schedule
bash install.sh
```
Установка требует root — скрипт сам перезапустится через sudo. До запуска DNS
домена уже должен указывать на сервер, а порты 80/443 — быть открытыми:
сертификат Let's Encrypt выпускается прямо в процессе установки.
Скрипт спросит:
| Вопрос | Что ответить |
|---|---|
| Как ставить: Docker или систему | Docker — рекомендуется (разница ниже) |
| Публичная ссылка на таблицу | ссылка Я.Диска, единственная обязательная настройка |
| Домен сайта | например `sched.example.ru`; пусто = без nginx, сайт на 127.0.0.1 |
| Порт api и порт сайта | по умолчанию 8000 и 8080 |
| Пользователь служб (только для «в систему») | от кого работают сервисы |
| Почта (только если задан домен) | для уведомлений Let's Encrypt |
Ответы можно передать и флагами: `--mode`, `--link`, `--domain`, `--api-port`,
`--front-port`, `--user`, `--email`. `install.sh --dry-run` показывает план
установки, ничего не меняя.
### Docker или система — что выбрать
**Docker** — рекомендуется: сервисы изолированы, автоматически перезапускаются,
обновляются одной командой, не зависят от Python в системе. Установщик поставит
Docker (если нет), запишет `.env`, соберёт образы, запустит контейнеры и
дождётся их готовности.
**Система (без Docker)** — если Docker не нужен: установщик проверит Python
3.11+, создаст `.venv`, поставит зависимости и зарегистрирует две службы
systemd — `schedule-api` и `schedule-frontend` (автозапуск после ребута,
перезапуск при падении).
В обоих случаях, если домен задан, установщик дополнительно поставит nginx и
certbot, выпустит сертификат Let's Encrypt (webroot — продление автоматическое,
сайт не останавливается) и настроит конфиг: http → https, прокси на порт
фронта. Снаружи виден только nginx.
### Ручная установка (без скрипта)
Установщик делает ровно следующее — при желании те же шаги выполняются руками.
**Docker:**
```bash
cp .env.example .env && nano .env # SCHEDULE_LINK (+ порты при желании)
sudo ./deploy.sh # собрать и запустить
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
```
**Система:**
```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
```
Две службы 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
```
**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, поставит зависимости, поднимет оба сервиса
```
Открыть <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 — то же самое руками:
```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`). Если файл недоступен для записи, изменения живут в памяти до
перезапуска, а панель предупредит об этом.
## Структура кода
```
install.sh интерактивная установка на сервер: Docker или systemd + nginx
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 (используется install.sh)
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 не попадают.
## Как устроена таблица-источник
Лист «Бакалавры и магистры» — это сетка: Лист «Бакалавры и магистры» — это сетка:
@@ -395,7 +32,9 @@ tests/
столбцы 2–3 даты «знаменателя» столбцы 2–3 даты «знаменателя»
столбец 4 день недели (указан один раз на блок дня) столбец 4 день недели (указан один раз на блок дня)
столбец 5 номер пары и время (ячейка объединена на две строки пары) столбец 5 номер пары и время (ячейка объединена на две строки пары)
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются) столбцы 6+ группы — по одному столбцу на группу, порядок столбцов = порядок
групп на сайте (скрытые столбцы пропускаются; имена вида
«ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО»)
``` ```
Каждый день занимает блок строк, и строки идут парами: «числитель» / Каждый день занимает блок строк, и строки идут парами: «числитель» /
@@ -430,10 +69,7 @@ tests/
В одной ячейке может быть несколько занятий: они разделяются пустой строкой, В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
новым маркером «к.в.» или началом нового предмета сразу после строки новым маркером «к.в.» или началом нового предмета сразу после строки
аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с аудитории/преподавателя.
целой строки) от предмета оторвался только преподаватель и/или аудитория, этот
кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе
в расписании аудитории оставались бы одни группы.
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
`lesson = {marker, subject, teacher, room, note}`. `lesson = {marker, subject, teacher, room, note}`.
@@ -452,13 +88,91 @@ tests/
- Приложение можно «установить» на главный экран (манифест + service - Приложение можно «установить» на главный экран (манифест + service
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
## Админка
Отдельного входа на сайте нет: панель открывается по секретному пути из
`ADMIN_PATH` (например `/manage-7f3a91`), логин и пароль — `ADMIN_LOGIN` /
`ADMIN_PASSWORD`. Если эти переменные пусты, админка полностью выключена и
маршруты `/api/admin/*` не регистрируются (прод можно не трогать).
Что умеет администратор:
- **посмотреть текущую ссылку** и состояние кэша (обновлено, изменена,
ошибка, число групп);
- **вставить новую ссылку**: она проверяется скачиванием и разбором таблицы
до сохранения — нерабочая не применится, данные на сайте не пострадают;
- **принудительно обновить расписание** — вне интервала: кнопка перечитывает
таблицу всегда, не глядя на дату изменения файла;
- **задать интервал обновления** в минутах (`1…1440`).
Фоновое обновление по интервалу работает как раньше: сначала сверяется
`modified` файла, и если таблица не менялась — скачивания не происходит.
Принудительное обновление (кнопка) скачивает файл **всегда**, поэтому его и
стоит нажимать сразу после правок в таблице. В панели и в логе при этом видно,
изменилось ли содержимое файла: `sha256`, время последнего изменения и отметка
«изменился / без изменений». Если вы поправили таблицу, а после обновления
написано «без изменений» — значит публичная ссылка Яндекса отдаёт прежнюю
версию файла (бывает с задержкой), стоит повторить обновление через минуту.
Как это устроено:
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie
(`HttpOnly`, `SameSite=Lax`, `Secure` при https), ключ — пароль админа,
срок 12 часов; неудачные попытки логируются;
- все действия — под `/api/admin/*`, без cookie отвечают `401`;
- ссылка и интервал хранятся в `SETTINGS_FILE` (в docker — том
`schedule_data`, `/data/settings.json`), поэтому переживают перезапуск;
если файл недоступен, настройки живут в памяти;
- страница панели отдаётся фронтендом по `ADMIN_PATH` с заголовком
`X-Robots-Tag: noindex`; путь нигде не публикуется и не попадает в `/`.
## Структура кода
```
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, ссылка, интервал, принудительное обновление
schedule/
xlsx.py чтение xlsx: sharedStrings, мержи, скрытые столбцы,
красный текст, серийные даты
source.py загрузка файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py листы → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error
settings.py хранилище настроек админки (ссылка, интервал) в JSON-файле
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/ логотипы (owl.svg, owl-white.svg), иконки приложения
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
deploy/
nginx-dev.conf сайт тестового стенда (dev.schedule.our-place.ru)
README-dev.md как поднять тестовый стенд рядом с продом
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
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)
```
## API ## API
Полный список методов отдаёт сам бэкенд: `GET /`. Полный список методов отдаёт сам бэкенд: `GET /`.
| Метод | Путь | Описание | | Метод | Путь | Описание |
|---|---|---| |---|---|---|
| GET | `/groups` | список групп (сначала БО, потом МО) | | GET | `/groups` | группы в том порядке, в каком идут столбцы в таблице |
| GET | `/schedule/{group}` | расписание группы с разбором на занятия | | GET | `/schedule/{group}` | расписание группы с разбором на занятия |
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день | | GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
| GET | `/today?group={group}` | расписание группы на сегодня | | GET | `/today?group={group}` | расписание группы на сегодня |
@@ -469,10 +183,93 @@ tests/
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день | | GET | `/rooms-day?day={день}` | занятость всех аудиторий за день |
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории | | GET | `/rooms-free?day={день}&period={N}` | свободные аудитории |
| GET | `/week` | недели семестра и текущая чётность | | GET | `/week` | недели семестра и текущая чётность |
| GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) | | GET | `/status` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) |
| GET | `/healthz` | живость процесса (не обращается к таблице) | | GET | `/healthz` | живость процесса (не обращается к таблице) |
Фронт проксирует только то, что использует, под префиксом `/api/...`: Фронт проксирует только то, что использует, под префиксом `/api/...`:
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`, `groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`. `room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде. Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
## Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | интервал обновления, мин (правится в админке) |
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `ADMIN_PATH` | — | api+frontend | секретный путь панели (пусто = админка выключена) |
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | учётные данные администратора |
| `SETTINGS_FILE` | `/data/settings.json` | 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.
### Тестовый стенд
Новые штуки обкатываются на отдельном стенде `dev.schedule.our-place.ru`
(ветка `admin-panel`), прод при этом не меняется: другой каталог
(`/opt/schedule-dev`), другие порты (8001/8091), свой том настроек.
Пошаговая инструкция — `deploy/README-dev.md`, конфиг nginx —
`deploy/nginx-dev.conf`.
## Замечания
- Принудительное обновление и смена ссылки живут только в админке
(`/api/admin/*` под cookie-сессией): публичного `force` в API нет.
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
api его придётся выносить в общее хранилище (настройки админки уже в файле).
- `ADMIN_PATH` защищает только от «случайного» обнаружения панели; реальная
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
«нечётное число строк, недели могли сдвинуться».

87
deploy/README-dev.md Normal file
View File

@@ -0,0 +1,87 @@
# Тестовый стенд (dev.schedule.our-place.ru)
Отдельный стенд для обкатки админки: свой каталог, свои порты, своя база
настроек. Прод (`/opt/schedule`, `schedule.our-place.ru`) не трогаем — он
продолжает работать как раньше.
Ветка: `admin-panel`.
## 1. Код
```bash
sudo mkdir -p /opt/schedule-dev && sudo chown "$USER" /opt/schedule-dev
git clone -b admin-panel <адрес репозитория> /opt/schedule-dev
```
Адрес — тот же, что у прода: если прод-клон забирает обновления по SSH,
берите SSH-адрес (`ssh://git@git.our-place.ru:2222/Mermutef/schedule.git`),
если по HTTPS — HTTPS-адрес с токеном:
```bash
git clone -b admin-panel https://Mermutef:<токен>@git.our-place.ru/Mermutef/schedule.git /opt/schedule-dev
```
## 2. .env
Скопировать `.env.example` в `/opt/schedule-dev/.env` и заполнить:
```ini
SCHEDULE_LINK=https://disk.yandex.ru/i/...
SCHEDULE_INTERVAL_MINUTES=15
SCHEDULE_RETRY_AFTER=60
ADMIN_PATH=/manage-<секретная-часть>
ADMIN_LOGIN=<логин>
ADMIN_PASSWORD=<пароль>
API_PORT=127.0.0.1:8001
FRONTEND_PORT=127.0.0.1:8091
SCHEDULE_API=http://api:8000
SETTINGS_FILE=/data/settings.json
```
Порты 8001/8091 выбраны, чтобы не пересечься с продом (8000/8090).
`ADMIN_PATH` — секретный путь страницы админки: его нужно знать, чтобы
попасть в панель; на сайте ссылки на него нет.
## 3. Запуск
```bash
cd /opt/schedule-dev
sudo docker compose up -d --build
sudo docker compose ps
```
Настройки админки лежат в томе `schedule-dev_schedule_data` и переживают
пересборку; посмотреть можно так:
```bash
sudo docker run --rm -v schedule-dev_schedule_data:/data alpine cat /data/settings.json
```
## 4. nginx и сертификат
В `deploy/nginx-dev.conf` порт `proxy_pass` должен совпадать с `FRONTEND_PORT`
из `.env` (по умолчанию 8091; если порт меняли — поправьте и в конфиге).
```bash
sudo cp /opt/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/dev.schedule.our-place.ru
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d dev.schedule.our-place.ru
```
## 5. Проверка
```bash
curl -sI https://dev.schedule.our-place.ru/ | head -3 # 200
curl -s -o /dev/null -w '%{http_code}\n' https://dev.schedule.our-place.ru/api/admin/state # 401 (нужен вход)
```
Дальше — открыть `https://dev.schedule.our-place.ru/<ADMIN_PATH>` в браузере,
войти логином/паролем из `.env` и проверить: смена ссылки, интервал,
«Обновить сейчас».
## Обновление стенда
```bash
cd /opt/schedule-dev && git pull && sudo docker compose up -d --build
```

47
deploy/nginx-dev.conf Normal file
View File

@@ -0,0 +1,47 @@
# Тестовый стенд dev.schedule.our-place.ru
#
# Ставится рядом с прод-сайтом schedule.our-place.ru (тот не трогаем):
# отдельный каталог /opt/schedule-dev, отдельные порты, отдельная база настроек.
#
# sudo cp ~/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
# sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/
# sudo certbot --nginx -d dev.schedule.our-place.ru
# sudo nginx -t && sudo systemctl reload nginx
server {
listen 80;
listen [::]:80;
server_name dev.schedule.our-place.ru;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name dev.schedule.our-place.ru;
ssl_certificate /etc/letsencrypt/live/dev.schedule.our-place.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.schedule.our-place.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 8m;
location / {
# порт должен совпадать с FRONTEND_PORT из .env стенда
proxy_pass http://127.0.0.1:8091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# принудительное обновление расписания из админки — до ~2 минут
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env bash
# Генерирует и включает конфиг nginx для сайта расписания.
#
# SSL обеспечивает сертификат родительского домена (обычно wildcard *.example.ru):
# отдельный certbot для поддомена не нужен — достаточно имени сертификата.
# Список имён: sudo ls /etc/letsencrypt/live/
#
# Имя сертификата берётся из --cert, иначе — родительский домен от --domain
# (sched.example.ru -> example.ru). Порт фронта берётся из --port, иначе из
# FRONTEND_PORT файла .env в корне проекта, иначе 8080.
#
# Примеры:
# deploy/nginx-site.sh --print --domain sched.example.ru # показать конфиг
# sudo deploy/nginx-site.sh --domain sched.example.ru # поставить и перечитать nginx
# sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru
set -euo pipefail
usage() {
grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2
}
die() { echo "Ошибка: $*" >&2; exit 2; }
DOMAIN=""
CERT_NAME=""
FRONTEND_PORT=""
PRINT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="$2"; shift 2 ;;
--cert) CERT_NAME="$2"; shift 2 ;;
--port) FRONTEND_PORT="$2"; shift 2 ;;
--print) PRINT=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Неизвестный аргумент: $1 (см. --help)" >&2; exit 2 ;;
esac
done
[[ -n $DOMAIN ]] || die "Укажите домен: --domain sched.example.ru (см. --help)"
[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || die "Кривое имя домена: $DOMAIN"
if [[ -z $CERT_NAME ]]; then
CERT_NAME="${DOMAIN#*.}" # sched.example.ru -> example.ru (wildcard родителя)
fi
[[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; }
if [[ -z $FRONTEND_PORT ]]; then
# FRONTEND_PORT из .env; значение вида «127.0.0.1:8090» трактуем как «порт после двоеточия»
FRONTEND_PORT=$(
grep -E '^FRONTEND_PORT=' .env 2>/dev/null | tail -1 | cut -d= -f2- \
| tr -d ' "' | awk -F: '{print $NF}'
) || FRONTEND_PORT=""
fi
FRONTEND_PORT=${FRONTEND_PORT:-8080}
[[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; }
read -r -d '' CONFIG <<'TEMPLATE' || true
# Сгенерировано deploy/nginx-site.sh — правки вносить в скрипт, не сюда.
server {
listen 80;
listen [::]:80;
server_name __DOMAIN__;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name __DOMAIN__;
# сертификат родительского домена (обычно wildcard), обновляет certbot родителя
ssl_certificate /etc/letsencrypt/live/__CERT__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__CERT__/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 8m;
location / {
# порт FRONTEND_PORT из .env стенда
proxy_pass http://127.0.0.1:__PORT__;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# холодный кэш: бэкенд качает таблицу, до ~2 минут
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
TEMPLATE
CONFIG=${CONFIG//__DOMAIN__/$DOMAIN}
CONFIG=${CONFIG//__CERT__/$CERT_NAME}
CONFIG=${CONFIG//__PORT__/$FRONTEND_PORT}
if [[ $PRINT == 1 ]]; then
printf '%s\n' "$CONFIG"
exit 0
fi
if [[ $EUID -ne 0 ]]; then
echo "Установка требует root: sudo deploy/nginx-site.sh (посмотреть конфиг — deploy/nginx-site.sh --print)" >&2
exit 1
fi
DEST="/etc/nginx/sites-available/${DOMAIN}"
printf '%s\n' "$CONFIG" > "$DEST"
ln -sf "$DEST" "/etc/nginx/sites-enabled/${DOMAIN}"
if nginx -t 2>&1; then
systemctl reload nginx
echo "Готово: https://${DOMAIN} -> 127.0.0.1:${FRONTEND_PORT} (сертификат ${CERT_NAME})"
else
echo "nginx -t не прошёл: конфиг записан в ${DEST} и включён, но nginx НЕ перечитан. Разберитесь и выполните: sudo nginx -t && sudo systemctl reload nginx" >&2
exit 1
fi

View File

@@ -5,22 +5,19 @@ services:
target: api target: api
environment: environment:
SCHEDULE_LINK: ${SCHEDULE_LINK} SCHEDULE_LINK: ${SCHEDULE_LINK}
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60} SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
# админка: пусто = выключена (страница и /api/admin/* не появятся) # админка: секретный путь панели и учётные данные (пусто = выключена)
ADMIN_PATH: ${ADMIN_PATH:-} ADMIN_PATH: ${ADMIN_PATH:-}
ADMIN_LOGIN: ${ADMIN_LOGIN:-} ADMIN_LOGIN: ${ADMIN_LOGIN:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_MAX_ATTEMPTS: ${ADMIN_MAX_ATTEMPTS:-3} SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
ADMIN_LOCKOUT_MINUTES: ${ADMIN_LOCKOUT_MINUTES:-15} # настройки админки переживают пересборку благодаря тому /data
# настройки админки (ссылка, интервал, история входов) живут в томе
SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json} SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json}
volumes: volumes:
- schedule_data:/data - schedule_data:/data
ports: ports:
# api нужен только фронтенду внутри docker-сети; наружу — 127.0.0.1 для отладки - "${API_PORT:-8000}:8000"
- "127.0.0.1:${API_PORT:-8000}:8000"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"]
@@ -34,13 +31,10 @@ services:
context: . context: .
target: frontend target: frontend
environment: environment:
# внутри compose api доступен по имени сервиса; менять не нужно
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
# страница админки отдаётся фронтендом по этому пути
ADMIN_PATH: ${ADMIN_PATH:-} ADMIN_PATH: ${ADMIN_PATH:-}
ports: ports:
# фронт обслуживает nginx; наружу напрямую не смотрит - "${FRONTEND_PORT:-8080}:8080"
- "127.0.0.1:${FRONTEND_PORT:-8080}:8080"
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
api: api:

View File

@@ -1,324 +0,0 @@
#!/usr/bin/env bash
# Интерактивная установка «Расписания» на чистый сервер.
#
# Запуск из распакованного каталога проекта (нужен root — скрипт сам
# перезапустится через sudo):
#
# bash install.sh
#
# Скрипт спросит: как ставить (Docker или прямо в систему), публичную ссылку
# на таблицу и домен сайта. Затем поставит всё нужное, запустит сервисы и
# настроит nginx с сертификатом Let's Encrypt.
#
# Для автоматизации ответы можно передать аргументами:
# --mode docker|system способ установки
# --link URL публичная ссылка Яндекс.Диска с таблицей
# --domain DOMAIN домен сайта (пусто = без nginx, сайт на 127.0.0.1)
# --api-port N порт api (по умолчанию 8000)
# --front-port N порт сайта (по умолчанию 8080)
# --user NAME пользователь служб (режим «в систему»)
# --email ADDR почта для уведомлений Let's Encrypt
# --dry-run показать план установки, ничего не меняя
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
err() { printf '\033[31m==>\033[0m %s\n' "$*" >&2; }
die() { err "$*"; exit 1; }
usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2; }
MODE="" LINK="" DOMAIN="" DOMAIN_SET=0 API_PORT="" FRONT_PORT="" RUN_USER="" EMAIL="" DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="$2"; shift 2 ;;
--link) LINK="$2"; shift 2 ;;
--domain) DOMAIN="$2"; DOMAIN_SET=1; shift 2 ;;
--api-port) API_PORT="$2"; shift 2 ;;
--front-port) FRONT_PORT="$2"; shift 2 ;;
--user) RUN_USER="$2"; shift 2 ;;
--email) EMAIL="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Неизвестный аргумент: $1 (см. --help)" ;;
esac
done
# ask "Вопрос" [по_умолчанию] — ответ в $REPLY
ask() {
local def="${2:-}" input
if ! read -rp "$1${def:+ [$def]}: " input; then
die "Нужен интерактивный ввод — запустите в терминале или передайте все параметры флагами (см. --help)"
fi
REPLY="${input:-$def}"
}
# --- вопросы -------------------------------------------------------------------
if [[ -z $MODE ]]; then
log "Как ставить?"
log " 1) Docker — изолированные контейнеры, автоперезапуск, обновление одной командой (рекомендуется)"
log " 2) Система — без Docker: venv + службы systemd"
while true; do
ask "Способ установки (docker/system)" "docker"
REPLY=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | xargs)
if [[ $REPLY == 1 ]]; then
REPLY=docker
elif [[ $REPLY == 2 ]]; then
REPLY=system
fi
[[ $REPLY =~ ^(docker|system)$ ]] && break || err "Ответьте docker или system"
done
MODE=$REPLY
fi
[[ $MODE =~ ^(docker|system)$ ]] || die "--mode: только docker или system"
if [[ -z $LINK ]]; then
while true; do
ask "Публичная ссылка на таблицу (Яндекс.Диск)"
REPLY=$(echo "$REPLY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"')
[[ -n $REPLY ]] && break || err "Без ссылки приложение не сможет скачать расписание"
done
LINK=$REPLY
fi
if [[ $DOMAIN_SET == 0 && -z $DOMAIN ]]; then
ask "Домен сайта, например sched.example.ru (пусто = без nginx, сайт только на 127.0.0.1)"
DOMAIN=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
fi
if [[ -n $DOMAIN && ! $DOMAIN =~ ^[a-z0-9.-]+$ ]]; then die "Кривое имя домена: $DOMAIN"; fi
if [[ -z $API_PORT ]]; then
ask "Порт api (нужен только для диагностики)" "8000"
API_PORT=$REPLY
fi
if [[ -z $FRONT_PORT ]]; then
ask "Порт сайта" "8080"
FRONT_PORT=$REPLY
fi
[[ $API_PORT =~ ^[0-9]+$ && $FRONT_PORT =~ ^[0-9]+$ ]] || die "Порты должны быть числами"
if [[ $MODE == system ]]; then
if [[ -z $RUN_USER ]]; then
ask "Пользователь, от которого будут работать службы" "${SUDO_USER:-$(id -un)}"
RUN_USER=$REPLY
fi
id "$RUN_USER" >/dev/null 2>&1 || die "Пользователь $RUN_USER не существует"
fi
if [[ -n $DOMAIN && -z $EMAIL ]]; then
ask "Почта для уведомлений Let's Encrypt" "admin@$DOMAIN"
EMAIL=$REPLY
fi
if [[ $DRY_RUN == 1 ]]; then
log "План установки (dry-run, ничего не выполнено):"
echo " режим: $MODE"
echo " таблица: $LINK"
echo " домен: ${DOMAIN:-(не задан — nginx и https пропускаются, сайт на 127.0.0.1)}"
echo " порты: api $API_PORT, сайт $FRONT_PORT"
if [[ $MODE == system ]]; then
echo " службы от: $RUN_USER"
fi
if [[ -n $DOMAIN ]]; then
echo " сертификат: Let's Encrypt для $DOMAIN (почта: $EMAIL)"
fi
exit 0
fi
# --- права ----------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
if [[ ${INSTALL_NO_SUDO:-0} == 1 ]]; then
err "Запущено без root (INSTALL_NO_SUDO=1) — часть шагов может не пройти"
else
log "Нужны права root — перезапускаюсь через sudo"
exec sudo bash "$0" "$@"
fi
fi
# --- помощники ------------------------------------------------------------------
pkg_install() {
local missing=() p
for p in "$@"; do command -v "$p" >/dev/null 2>&1 || missing+=("$p"); done
if [[ ${#missing[@]} -eq 0 ]]; then
return 0
fi
log "Ставлю пакеты: ${missing[*]}"
if command -v apt-get >/dev/null 2>&1; then
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y "${missing[@]}"
else
die "Не нашёл apt-get/dnf — поставьте вручную: ${missing[*]}"
fi
}
wait_health() {
local url="$1" name="$2" i
printf '%s' "==> Жду $name ($url)"
for i in $(seq 1 60); do
if curl -sf -m 2 "$url" >/dev/null 2>&1; then echo " — готово"; return 0; fi
printf '.'; sleep 1
done
echo
die "$name не отвечает на $url — смотрите логи (docker logs / journalctl)"
}
write_env() {
cd "$ROOT"
if [[ -f .env && ! -f .env.install-backup ]]; then
cp .env .env.install-backup
fi
cat > .env <<EOF
# Сгенерировано install.sh
SCHEDULE_LINK=$LINK
SCHEDULE_INTERVAL_MINUTES=15
SCHEDULE_RETRY_AFTER=60
API_PORT=$API_PORT
FRONTEND_PORT=$FRONT_PORT
# Админка (необязательно). Заполните path/логин/пароль, чтобы включить панель:
# страница будет доступна только по https://<домен><ADMIN_PATH>
ADMIN_PATH=
ADMIN_LOGIN=
ADMIN_PASSWORD=
ADMIN_MAX_ATTEMPTS=3
ADMIN_LOCKOUT_MINUTES=15
EOF
log ".env записан (прежний, если был, сохранён в .env.install-backup)"
}
install_docker() {
if command -v docker >/dev/null 2>&1; then
log "Docker уже установлен"
else
log "Ставлю Docker..."
curl -fsSL https://get.docker.com | sh
fi
if [[ $EUID -eq 0 ]]; then
systemctl enable --now docker
fi
}
setup_nginx() {
pkg_install nginx certbot curl
if [[ ! -d /etc/nginx/sites-available ]]; then
die "Нужен nginx с поддержкой sites-available (Debian/Ubuntu); на других системах настройте конфиг по README"
fi
log "Временный http-конфиг $DOMAIN — для выпуска сертификата"
cat > "/etc/nginx/sites-available/$DOMAIN" <<'NGINX'
# Сгенерировано install.sh: временный конфиг для certbot, потом заменяется
server {
listen 80;
listen [::]:80;
server_name __DOMAIN__;
root /var/www/html;
location /.well-known/acme-challenge/ { }
}
NGINX
sed -i "s/__DOMAIN__/$DOMAIN/" "/etc/nginx/sites-available/$DOMAIN"
ln -sf "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN"
nginx -t && systemctl reload nginx
log "Выпускаю сертификат Let's Encrypt для $DOMAIN (webroot, без остановки nginx)"
certbot certonly --webroot -w /var/www/html -d "$DOMAIN" \
--non-interactive --agree-tos -m "$EMAIL"
log "Финальный конфиг сайта: https + прокси на 127.0.0.1:$FRONT_PORT"
"$ROOT/deploy/nginx-site.sh" --domain "$DOMAIN" --cert "$DOMAIN" --port "$FRONT_PORT"
}
# --- установка ------------------------------------------------------------------
if [[ $MODE == docker ]]; then
install_docker
write_env
log "Собираю и запускаю контейнеры (первая сборка — несколько минут)..."
(cd "$ROOT" && docker compose up -d --build)
wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend"
else
log "Проверяю Python..."
pkg_install python3 python3-pip
if command -v apt-get >/dev/null 2>&1; then
apt-get install -y python3-venv # на Debian/Ubuntu venv без этого пакета не работает
fi
python3 - <<'PY' || die "Нужен Python 3.11 или новее (см. README, «Зависимости»)"
import sys
sys.exit(0 if sys.version_info >= (3, 11) else 1)
PY
write_env
log "Создаю виртуальное окружение и ставлю зависимости..."
[[ -d "$ROOT/.venv" ]] || python3 -m venv "$ROOT/.venv"
"$ROOT/.venv/bin/pip" install -q --upgrade pip
"$ROOT/.venv/bin/pip" install -q -r "$ROOT/requirements.txt"
log "Настраиваю службы systemd..."
cat > /etc/systemd/system/schedule-api.service <<EOF
[Unit]
Description=Schedule API (FastAPI)
After=network-online.target
Wants=network-online.target
[Service]
User=$RUN_USER
WorkingDirectory=$ROOT/src/backend
ExecStart=$ROOT/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port $API_PORT
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/schedule-frontend.service <<EOF
[Unit]
Description=Schedule frontend (FastAPI)
After=network-online.target schedule-api.service
Wants=network-online.target schedule-api.service
[Service]
User=$RUN_USER
WorkingDirectory=$ROOT/src/frontend
Environment=SCHEDULE_API=http://127.0.0.1:$API_PORT
ExecStart=$ROOT/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port $FRONT_PORT
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
chown -R "$RUN_USER" "$ROOT"
systemctl daemon-reload
systemctl enable --now schedule-api.service schedule-frontend.service
wait_health "http://127.0.0.1:$API_PORT/healthz" "api"
wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend"
fi
if [[ -n $DOMAIN ]]; then
setup_nginx
else
log "Домен не задан — nginx и https пропущены. Сайт работает на http://127.0.0.1:$FRONT_PORT"
fi
# --- итог -----------------------------------------------------------------------
log "Установка завершена."
if [[ -n $DOMAIN ]]; then
echo " Сайт: https://$DOMAIN"
else
echo " Сайт: http://127.0.0.1:$FRONT_PORT (с самого сервера)"
fi
echo " Таблица: $LINK"
echo " Настройки: $ROOT/.env"
if [[ $MODE == docker ]]; then
echo " Логи: sudo docker logs api / frontend"
echo " Обновление: cd $ROOT && sudo docker compose up -d --build"
echo " Напоминание: настройте ротацию логов Docker (/etc/docker/daemon.json), см. README"
else
echo " Службы: sudo systemctl status schedule-api schedule-frontend"
echo " Логи: journalctl -u schedule-api -u schedule-frontend"
echo " Обновление: заменить файлы проекта, затем sudo systemctl restart schedule-api schedule-frontend"
fi

View File

@@ -1,2 +1,3 @@
-r requirements.txt
pytest==9.1.1 pytest==9.1.1
httpx2==2.12.0 httpx2==2.12.0

View File

@@ -1,18 +1,15 @@
"""Админка расписания. """Админка расписания.
Отдельного входа на сайте нет: панель открывается по секретному пути из Отдельного входа на сайте нет: панель открывается по секретному пути из
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После
пусты, админка выключена и маршруты /api/admin/* не регистрируются. входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние
между запросами не хранится.
После нескольких неудачных попыток вход блокируется на время из
ADMIN_LOCKOUT_MINUTES (по умолчанию: 3 попытки и 15 минут). Каждая попытка
запоминается (откуда, когда, успешна ли) — это видно в панели и пишется в лог.
Что умеет администратор: Что умеет администратор:
GET /api/admin/state — состояние кэша, ссылка, интервал, история входов; GET /api/admin/state — текущая ссылка, интервал, состояние кэша;
POST /api/admin/link — проверить и поставить новую ссылку на таблицу; POST /api/admin/link — проверить и поставить новую ссылку;
POST /api/admin/interval — интервал обновления в минутах; POST /api/admin/interval — интервал обновления в минутах;
POST /api/admin/refresh — принудительно перечитать таблицу сейчас. POST /api/admin/refresh — принудительно обновить расписание сейчас.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,32 +22,62 @@ import logging
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel from pydantic import BaseModel
from schedule import ScheduleCache, ScheduleError from schedule import ScheduleCache, ScheduleError
from schedule.settings import SettingsStore
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
COOKIE_NAME = "schedule_admin" COOKIE_NAME = "schedule_admin"
SESSION_TTL_SECONDS = 12 * 3600 SESSION_TTL_SECONDS = 12 * 3600
DEFAULT_MAX_ATTEMPTS = 3 # защита от перебора пароля: не больше N неудач с одного адреса за окно
DEFAULT_LOCKOUT_MINUTES = 15 LOGIN_MAX_FAILURES = 10
LOGIN_WINDOW_SECONDS = 300.0
class LoginThrottle:
"""Простейший счётчик неудачных попыток входа по IP."""
def __init__(
self,
max_failures: int = LOGIN_MAX_FAILURES,
window: float = LOGIN_WINDOW_SECONDS,
) -> None:
self._max = max_failures
self._window = window
self._lock = threading.Lock()
self._failures: dict[str, list[float]] = {}
def _recent(self, key: str, now: float) -> list[float]:
return [ts for ts in self._failures.get(key, []) if now - ts < self._window]
def blocked(self, key: str) -> bool:
now = time.time()
with self._lock:
recent = self._recent(key, now)
self._failures[key] = recent
return len(recent) >= self._max
def register_failure(self, key: str) -> None:
now = time.time()
with self._lock:
self._failures[key] = [*self._recent(key, now), now]
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
@dataclass @dataclass
class AdminConfig: class AdminConfig:
"""Настройки админки: секретный путь, учётные данные и защита от перебора.""" """Настройки админки: секретный путь и учётные данные из .env."""
path: str = "" path: str = ""
login: str = "" login: str = ""
password: str = "" password: str = ""
max_attempts: int = DEFAULT_MAX_ATTEMPTS
lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@@ -71,7 +98,9 @@ def _same(left: str, right: str) -> bool:
def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str: def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str:
payload = json.dumps({"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")).encode() payload = json.dumps(
{"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")
).encode()
sig = hmac.new(password.encode(), payload, hashlib.sha256).digest() sig = hmac.new(password.encode(), payload, hashlib.sha256).digest()
return f"{_b64e(payload)}.{_b64e(sig)}" return f"{_b64e(payload)}.{_b64e(sig)}"
@@ -88,46 +117,6 @@ def verify_token(token: str, password: str) -> bool:
return False return False
class LoginThrottle:
"""Счётчик неудачных попыток: N промахов — пауза на lockout_minutes."""
def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS, lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES) -> None:
self.max_attempts = max(1, int(max_attempts))
self.lockout_seconds = max(1, int(lockout_minutes)) * 60
self._lock = threading.Lock()
self._failures: dict[str, int] = {}
self._blocked_until: dict[str, float] = {}
def locked_until(self, key: str) -> float | None:
with self._lock:
until = self._blocked_until.get(key)
if until is None:
return None
if until <= time.time():
self._blocked_until.pop(key, None)
self._failures.pop(key, None)
return None
return until
def attempts_left(self, key: str) -> int:
with self._lock:
return max(0, self.max_attempts - self._failures.get(key, 0))
def register_failure(self, key: str) -> None:
with self._lock:
failures = self._failures.get(key, 0) + 1
if failures >= self.max_attempts:
self._blocked_until[key] = time.time() + self.lockout_seconds
self._failures[key] = 0
else:
self._failures[key] = failures
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
self._blocked_until.pop(key, None)
class LoginBody(BaseModel): class LoginBody(BaseModel):
login: str login: str
password: str password: str
@@ -141,30 +130,9 @@ class IntervalBody(BaseModel):
interval_minutes: int interval_minutes: int
def _client_ip(request: Request) -> str: def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
"""Реальный адрес клиента: за nginx он приходит в X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip() or "неизвестно"
return request.client.host if request.client else "неизвестно"
def _human_dt(iso: str | None) -> str:
if not iso:
return "—"
try:
return datetime.fromisoformat(iso).strftime("%d.%m.%Y %H:%M:%S")
except ValueError:
return iso
def create_admin_router(
cache: ScheduleCache,
settings: SettingsStore | None,
config: AdminConfig,
) -> APIRouter:
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
throttle = LoginThrottle(config.max_attempts, config.lockout_minutes) throttle = LoginThrottle()
def require_auth(request: Request) -> None: def require_auth(request: Request) -> None:
token = request.cookies.get(COOKIE_NAME, "") token = request.cookies.get(COOKIE_NAME, "")
@@ -175,55 +143,24 @@ def create_admin_router(
proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme
return proto.split(",")[0].strip() == "https" return proto.split(",")[0].strip() == "https"
def _remember(ip: str, ok: bool) -> None: def _client_key(request: Request) -> str:
if settings is not None: return request.client.host if request.client else "unknown"
settings.record_login(ip, ok)
@router.post("/login") @router.post("/login")
def login(body: LoginBody, request: Request, response: Response) -> dict: def login(body: LoginBody, request: Request, response: Response) -> dict:
ip = _client_ip(request) key = _client_key(request)
until = throttle.locked_until(ip) if throttle.blocked(key):
if until is not None: log.warning("Вход в админку: слишком много неудачных попыток с %s", key)
minutes = max(1, round((until - time.time()) / 60))
log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes)
raise HTTPException( raise HTTPException(
status_code=429, status_code=429, detail="Слишком много попыток входа, попробуйте позже"
detail=f"Слишком много неудачных попыток. Вход заблокирован ещё на ~{minutes} мин.",
) )
ok_login = _same(body.login.strip(), config.login) ok_login = _same(body.login.strip(), config.login)
ok_password = _same(body.password, config.password) ok_password = _same(body.password, config.password)
if not (ok_login and ok_password): if not (ok_login and ok_password):
throttle.register_failure(ip) throttle.register_failure(key)
_remember(ip, False) log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40])
left = throttle.attempts_left(ip) raise HTTPException(status_code=401, detail="Неверный логин или пароль")
if throttle.locked_until(ip) is not None: throttle.reset(key)
log.warning(
"Неудачная попытка входа (%s): исчерпаны попытки, пауза %d мин",
ip,
config.lockout_minutes,
)
raise HTTPException(
status_code=429,
detail=f"Неверный логин или пароль. Попытки исчерпаны — вход заблокирован на {config.lockout_minutes} мин.",
)
log.warning("Неудачная попытка входа (%s), осталось попыток: %d", ip, left)
raise HTTPException(
status_code=401,
detail=f"Неверный логин или пароль. Осталось попыток: {left}.",
)
throttle.reset(ip)
previous = settings.last_attempt if settings is not None else None
_remember(ip, True)
if previous:
log.info(
"Успешный вход в админку (%s). Предыдущая попытка: %s, %s, %s",
ip,
_human_dt(previous.get("at")),
previous.get("ip"),
"успешная" if previous.get("ok") else "неудачная",
)
else:
log.info("Успешный вход в админку (%s)", ip)
response.set_cookie( response.set_cookie(
COOKIE_NAME, COOKIE_NAME,
make_token(config.login, config.password), make_token(config.login, config.password),
@@ -233,6 +170,7 @@ def create_admin_router(
secure=_secure(request), secure=_secure(request),
path="/", path="/",
) )
log.info("Вход в админку выполнен")
return {"ok": True} return {"ok": True}
@router.post("/logout") @router.post("/logout")
@@ -241,17 +179,8 @@ def create_admin_router(
return {"ok": True} return {"ok": True}
@router.get("/state", dependencies=[Depends(require_auth)]) @router.get("/state", dependencies=[Depends(require_auth)])
def state(request: Request) -> dict: def state() -> dict:
ip = _client_ip(request) return cache.state()
until = throttle.locked_until(ip)
payload = cache.state()
payload["throttle"] = {
"max_attempts": config.max_attempts,
"lockout_minutes": config.lockout_minutes,
"attempts_left": throttle.attempts_left(ip),
"locked_until": datetime.fromtimestamp(until).astimezone().isoformat() if until else None,
}
return payload
@router.post("/refresh", dependencies=[Depends(require_auth)]) @router.post("/refresh", dependencies=[Depends(require_auth)])
def refresh() -> dict: def refresh() -> dict:

View File

@@ -1,9 +1,4 @@
"""API-приложение расписания (FastAPI). """API-приложение расписания (FastAPI)."""
Кроме публичных данных здесь живёт админка (backend/admin.py): она включается
только если заданы ADMIN_LOGIN и ADMIN_PASSWORD, и открывается по секретному
пути ADMIN_PATH. Все параметры — переменные окружения, см. .env.example.
"""
from __future__ import annotations from __future__ import annotations
@@ -27,7 +22,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
def _find_dotenv() -> Path | None: def _find_dotenv() -> Path | None:
"""Ищем .env вверх от каталога модуля (общий .env в корне проекта).""" """Ищем .env вверх от каталога модуля (локальная разработка)."""
path = Path(__file__).resolve().parent path = Path(__file__).resolve().parent
for _ in range(5): for _ in range(5):
env = path / ".env" env = path / ".env"
@@ -54,25 +49,12 @@ def _normalize_admin_path(raw: str | None) -> str:
return path.rstrip("/") or "/" return path.rstrip("/") or "/"
def _int_env(name: str, default: int) -> int:
raw = (os.getenv(name) or "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
log.warning("%s=%r не число, беру %d", name, raw, default)
return default
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip() SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60")) RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip() SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip()
ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH")) ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH"))
ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip() ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip()
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or "" ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or ""
ADMIN_MAX_ATTEMPTS = _int_env("ADMIN_MAX_ATTEMPTS", 3)
ADMIN_LOCKOUT_MINUTES = _int_env("ADMIN_LOCKOUT_MINUTES", 15)
def default_settings() -> SettingsStore: def default_settings() -> SettingsStore:
@@ -108,17 +90,10 @@ def _day_arg(day: str) -> str:
def create_app( def create_app(
cache: ScheduleCache | None = None, cache: ScheduleCache | None = None,
admin: AdminConfig | None = None, admin: AdminConfig | None = None,
settings: SettingsStore | None = None,
) -> FastAPI: ) -> FastAPI:
if cache is None: cache = cache or default_cache()
settings = settings or default_settings()
cache = ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
admin = admin if admin is not None else AdminConfig( admin = admin if admin is not None else AdminConfig(
path=ADMIN_PATH, path=ADMIN_PATH, login=ADMIN_LOGIN, password=ADMIN_PASSWORD
login=ADMIN_LOGIN,
password=ADMIN_PASSWORD,
max_attempts=ADMIN_MAX_ATTEMPTS,
lockout_minutes=ADMIN_LOCKOUT_MINUTES,
) )
@asynccontextmanager @asynccontextmanager
@@ -135,13 +110,13 @@ def create_app(
app.state.admin = admin app.state.admin = admin
if admin.enabled: if admin.enabled:
app.include_router(create_admin_router(cache, settings, admin)) app.include_router(create_admin_router(cache, admin))
if admin.path: if admin.path:
log.info("Админка включена (путь задан в ADMIN_PATH)") log.info("Админка включена (секретный путь задан)")
else: else:
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна") log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
else: else:
log.info("Админка выключена (не заданы ADMIN_LOGIN/ADMIN_PASSWORD)") log.info("Админка выключена: не заданы ADMIN_LOGIN/ADMIN_PASSWORD")
def _data() -> tuple[dict, list[dict]]: def _data() -> tuple[dict, list[dict]]:
try: try:
@@ -173,7 +148,7 @@ def create_app(
return { return {
"title": "Расписание", "title": "Расписание",
"endpoints": { "endpoints": {
"/groups": "Список групп (в порядке столбцов таблицы)", "/groups": "Список групп",
"/schedule/{group}": "Расписание группы", "/schedule/{group}": "Расписание группы",
"/schedule/{group}/{day}": "Расписание группы на день", "/schedule/{group}/{day}": "Расписание группы на день",
"/today?group={group}": "Расписание группы на сегодня", "/today?group={group}": "Расписание группы на сегодня",

View File

@@ -9,7 +9,8 @@
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
(вместо скачивания на каждый запрос); (вместо скачивания на каждый запрос);
- настройки администратора (ссылка, интервал) живут в SettingsStore и - настройки администратора (ссылка, интервал) живут в SettingsStore и
применяются на лету: смена ссылки проверяется до сохранения. применяются на лету: смена ссылки проверяется до сохранения, принудительное
обновление идёт вне расписания.
""" """
from __future__ import annotations from __future__ import annotations
@@ -100,24 +101,22 @@ class ScheduleCache:
return self._day_times return self._day_times
def status(self) -> dict: def status(self) -> dict:
"""Состояние кэша (публичный /status): без ссылки и без админских данных."""
with self._lock: with self._lock:
return self._status() return {
"cache_ttl_seconds": self._ttl,
"interval_minutes": self.interval_minutes,
"last_updated": self._iso(self._loaded_at),
"table_modified": self._modified,
"error": self._error,
}
def state(self) -> dict: def state(self) -> dict:
"""Состояние для админки: то же плюс текущая ссылка и настройки.""" """Полное состояние для админки."""
with self._lock: with self._lock:
state = self._status() return self._state()
state["link"] = self._link
if self._settings is not None:
state["settings_file"] = str(self._settings.path) if self._settings.path else None
state["settings_save_error"] = self._settings.save_error
state["last_attempt"] = self._settings.last_attempt
state["last_success"] = self._settings.last_success
return state
def warmup(self) -> None: def warmup(self) -> None:
"""Первая загрузка данных на старте приложения (ошибка не фатальна).""" """Первый загруз данных на старте приложения (ошибка не фатальна)."""
try: try:
self.get() self.get()
except ScheduleError as exc: except ScheduleError as exc:
@@ -126,7 +125,7 @@ class ScheduleCache:
# --- действия администратора ------------------------------------------------ # --- действия администратора ------------------------------------------------
def force_reload(self) -> dict: def force_reload(self) -> dict:
"""Принудительно обновить расписание: скачиваем таблицу в любом случае.""" """Принудительно обновить расписание вне интервала (кнопка в админке)."""
with self._lock: with self._lock:
now = time.time() now = time.time()
modified = fetch_table_modified(self._link) if self._link else None modified = fetch_table_modified(self._link) if self._link else None
@@ -137,7 +136,7 @@ class ScheduleCache:
self._last_failed = now self._last_failed = now
log.warning("Принудительное обновление не удалось: %s", exc) log.warning("Принудительное обновление не удалось: %s", exc)
raise raise
return self.state() return self._state()
def apply_link(self, link: str) -> dict: def apply_link(self, link: str) -> dict:
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё.""" """Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
@@ -162,11 +161,11 @@ class ScheduleCache:
raise ScheduleError(f"Ссылка не подошла: {exc}") from exc raise ScheduleError(f"Ссылка не подошла: {exc}") from exc
if self._settings is not None: if self._settings is not None:
self._settings.set_link(link) self._settings.set_link(link)
return self.state() return self._state()
def set_interval_minutes(self, minutes: int | str) -> int: def set_interval_minutes(self, minutes: int | str) -> int:
if self._settings is None: if self._settings is None:
raise ScheduleError("Настройки не подключены: интервал задаётся в .env") raise ScheduleError("Настройки не подключены: интервал задаётся через .env")
value = self._settings.set_interval_minutes(minutes) value = self._settings.set_interval_minutes(minutes)
with self._lock: with self._lock:
self._ttl = value * 60 self._ttl = value * 60
@@ -181,15 +180,16 @@ class ScheduleCache:
return None return None
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat() return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
def _status(self) -> dict: def _state(self) -> dict:
return { return {
"cache_ttl_seconds": self._ttl, "link": self._link,
"interval_minutes": max(1, round(self._ttl / 60)), "interval_minutes": max(1, round(self._ttl / 60)),
"last_updated": self._iso(self._loaded_at), "last_updated": self._iso(self._loaded_at),
"table_modified": self._modified, "table_modified": self._modified,
"error": self._error, "error": self._error,
"groups": len(self._data) if self._data else 0, "groups": len(self._data) if self._data else 0,
"has_data": self._data is not None, "has_data": self._data is not None,
# при последнем чтении файла: изменилось ли его содержимое
"changed": self._changed, "changed": self._changed,
"changed_at": self._iso(self._changed_at), "changed_at": self._iso(self._changed_at),
"content_hash": (self._content_hash or "")[:12], "content_hash": (self._content_hash or "")[:12],
@@ -205,9 +205,9 @@ class ScheduleCache:
) )
if fresh: if fresh:
return return
self._refresh(now) self._reload(now)
def _refresh(self, now: float) -> None: def _reload(self, now: float) -> None:
# служим старые данные, пока не истекло время повторной попытки # служим старые данные, пока не истекло время повторной попытки
if ( if (
self._data is not None self._data is not None
@@ -249,7 +249,6 @@ class ScheduleCache:
digest = hashlib.sha256(xlsx).hexdigest() digest = hashlib.sha256(xlsx).hexdigest()
first_load = self._content_hash is None first_load = self._content_hash is None
changed = not first_load and digest != self._content_hash changed = not first_load and digest != self._content_hash
sheets = parse_workbook(xlsx) sheets = parse_workbook(xlsx)
data = parsing.parse_schedule(sheets) data = parsing.parse_schedule(sheets)
weeks = parsing.extract_week_ranges(sheets) weeks = parsing.extract_week_ranges(sheets)

View File

@@ -495,40 +495,6 @@ def _normalize_marker_of(line: str) -> Optional[str]:
return _normalize_marker(num or None) return _normalize_marker(num or None)
def _merge_detached_fragments(lessons: list[Lesson]) -> list[Lesson]:
"""Достраивает занятие из оторвавшихся пустой строкой преподавателя/аудитории.
Пустая строка внутри ячейки (или красная заметка, снятая с целой строки)
отрывает хвост от предмета: «Предмет\nЛарина Ю.А.\n\n ауд.210» разбирается
как занятие без аудитории и «ауд. 210» без предмета — в индексе аудиторий
от такого занятия остаётся только список групп. Кусок без предмета цепляем
к предыдущему занятию, но только если у того этих полей ещё нет, иначе
это похоже на отдельное занятие параллельной подгруппы.
"""
merged: list[Lesson] = []
for lesson in lessons:
prev = merged[-1] if merged else None
if (
prev is not None
and prev.subject
and not lesson.subject
and (lesson.teacher or lesson.room)
and (not lesson.marker or lesson.marker == prev.marker)
and (not lesson.teacher or not prev.teacher)
and (not lesson.room or not prev.room)
):
notes = [n for n in (prev.note + "\n" + lesson.note).split("\n") if n]
merged[-1] = replace(
prev,
teacher=lesson.teacher or prev.teacher,
room=lesson.room or prev.room,
note="\n".join(dict.fromkeys(notes)),
)
else:
merged.append(lesson)
return merged
def parse_lesson_cell(cell_text: str) -> list[Lesson]: def parse_lesson_cell(cell_text: str) -> list[Lesson]:
"""Текст ячейки -> список занятий. """Текст ячейки -> список занятий.
@@ -559,8 +525,6 @@ def parse_lesson_cell(cell_text: str) -> list[Lesson]:
lesson = replace(lesson, marker=last_marker) lesson = replace(lesson, marker=last_marker)
lessons.append(lesson) lessons.append(lesson)
lessons = _merge_detached_fragments(lessons)
if not cell_notes: if not cell_notes:
return lessons return lessons
if not lessons: if not lessons:

View File

@@ -1,10 +1,8 @@
"""Настройки, которые правит администратор. """Настройки расписания, которые правит администратор.
Хранятся в JSON-файле (`SETTINGS_FILE`, по умолчанию `data/settings.json` в Хранятся в JSON-файле (если задан путь) — так ссылка и интервал переживают
корне проекта), поэтому переживают перезапуск службы: ссылка на таблицу, перезапуск контейнера. Если путь не задан, настройки живут только в памяти
интервал обновления и история входов в админку. Если файл недоступен для (поведение прежних версий, прод можно не трогать).
записи — настройки живут в памяти, а сервис продолжает работать (об этом
пишется предупреждение в лог и в состояние админки).
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,8 +11,7 @@ import json
import logging import logging
import os import os
import threading import threading
from dataclasses import dataclass from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
from .errors import ScheduleError from .errors import ScheduleError
@@ -25,40 +22,14 @@ DEFAULT_INTERVAL_MINUTES = 15
MIN_INTERVAL_MINUTES = 1 MIN_INTERVAL_MINUTES = 1
MAX_INTERVAL_MINUTES = 24 * 60 MAX_INTERVAL_MINUTES = 24 * 60
def _default_settings_path() -> Path:
"""`<корень проекта>/data/settings.json`.
В репозитории это три уровня вверх от пакета, а в docker-образе файлы лежат
плоско (/app/...), поэтому ищем корень по маркеру — requirements.txt.
"""
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "requirements.txt").is_file():
return parent / "data" / "settings.json"
return here.parent / "data" / "settings.json"
DEFAULT_SETTINGS_PATH = _default_settings_path()
@dataclass @dataclass
class Settings: class Settings:
link: str = "" link: str = ""
interval_minutes: int = DEFAULT_INTERVAL_MINUTES interval_minutes: int = DEFAULT_INTERVAL_MINUTES
last_attempt: dict | None = None # {"at": iso, "ip": str, "ok": bool}
last_success: dict | None = None # {"at": iso, "ip": str}
def as_dict(self) -> dict: def as_dict(self) -> dict:
return { return asdict(self)
"link": self.link,
"interval_minutes": self.interval_minutes,
"last_attempt": self.last_attempt,
"last_success": self.last_success,
}
def clamp_interval(minutes: int) -> int:
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int: def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
@@ -68,19 +39,18 @@ def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
try: try:
return clamp_interval(int(float(raw_minutes))) return clamp_interval(int(float(raw_minutes)))
except ValueError: except ValueError:
log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру по умолчанию", raw_minutes) log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру значение по умолчанию", raw_minutes)
raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip() raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip()
if raw_ttl: if raw_ttl:
try: try:
return clamp_interval(round(float(raw_ttl) / 60) or 1) return clamp_interval(round(float(raw_ttl) / 60) or 1)
except ValueError: except ValueError:
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру по умолчанию", raw_ttl) log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl)
return default return default
def settings_path_from_env() -> Path | None: def clamp_interval(minutes: int) -> int:
raw = (os.getenv("SETTINGS_FILE") or "").strip() return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
return Path(raw) if raw else DEFAULT_SETTINGS_PATH
class SettingsStore: class SettingsStore:
@@ -94,7 +64,6 @@ class SettingsStore:
) -> None: ) -> None:
self._path = Path(path) if path else None self._path = Path(path) if path else None
self._lock = threading.RLock() self._lock = threading.RLock()
self._save_error: str | None = None
self._settings = Settings( self._settings = Settings(
link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes) link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes)
) )
@@ -102,10 +71,6 @@ class SettingsStore:
# --- чтение ----------------------------------------------------------------- # --- чтение -----------------------------------------------------------------
@property
def path(self) -> Path | None:
return self._path
@property @property
def link(self) -> str: def link(self) -> str:
with self._lock: with self._lock:
@@ -116,22 +81,6 @@ class SettingsStore:
with self._lock: with self._lock:
return self._settings.interval_minutes return self._settings.interval_minutes
@property
def last_attempt(self) -> dict | None:
with self._lock:
return dict(self._settings.last_attempt) if self._settings.last_attempt else None
@property
def last_success(self) -> dict | None:
with self._lock:
return dict(self._settings.last_success) if self._settings.last_success else None
@property
def save_error(self) -> str | None:
"""Текст ошибки, если настройки не удаётся сохранить на диск."""
with self._lock:
return self._save_error
def as_dict(self) -> dict: def as_dict(self) -> dict:
with self._lock: with self._lock:
return self._settings.as_dict() return self._settings.as_dict()
@@ -161,17 +110,6 @@ class SettingsStore:
self._save() self._save()
return value return value
def record_login(self, ip: str, ok: bool) -> dict:
"""Запомнить попытку входа: откуда, когда и чем закончилась."""
now = datetime.now().astimezone().replace(microsecond=0)
attempt = {"at": now.isoformat(), "ip": ip or "неизвестно", "ok": bool(ok)}
with self._lock:
self._settings.last_attempt = attempt
if ok:
self._settings.last_success = {"at": attempt["at"], "ip": attempt["ip"]}
self._save()
return attempt
# --- файл ------------------------------------------------------------------- # --- файл -------------------------------------------------------------------
def _load(self) -> None: def _load(self) -> None:
@@ -180,7 +118,7 @@ class SettingsStore:
try: try:
raw = json.loads(self._path.read_text(encoding="utf-8")) raw = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
log.warning("Не читается файл настроек %s: %s (беру значения из окружения)", self._path, exc) log.warning("Не читается файл настроек %s: %s (остаюсь на значениях из окружения)", self._path, exc)
return return
if not isinstance(raw, dict): if not isinstance(raw, dict):
log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path) log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path)
@@ -193,10 +131,6 @@ class SettingsStore:
self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"])) self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"]))
except (TypeError, ValueError): except (TypeError, ValueError):
log.warning("interval_minutes в %s не число, игнорирую", self._path) log.warning("interval_minutes в %s не число, игнорирую", self._path)
for key in ("last_attempt", "last_success"):
value = raw.get(key)
if isinstance(value, dict) and value.get("at"):
setattr(self._settings, key, value)
log.info( log.info(
"Настройки из %s: интервал %s мин, ссылка %s", "Настройки из %s: интервал %s мин, ссылка %s",
self._path, self._path,
@@ -215,7 +149,5 @@ class SettingsStore:
encoding="utf-8", encoding="utf-8",
) )
tmp.replace(self._path) tmp.replace(self._path)
self._save_error = None
except OSError as exc: except OSError as exc:
self._save_error = str(exc)
log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc) log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc)

View File

@@ -4,11 +4,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>Админка расписания</title> <title>Админ кабинет</title>
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg"> <link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<style> <style>
.admin-wrap { max-width: 760px; margin: 0 auto; padding: 26px 18px 60px; } .admin-wrap { max-width: 720px; margin: 0 auto; padding: 26px 18px 60px; }
.admin-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; } .admin-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.admin-head img { width: 40px; height: 40px; } .admin-head img { width: 40px; height: 40px; }
.admin-title { font-size: 22px; font-weight: 700; color: var(--text); } .admin-title { font-size: 22px; font-weight: 700; color: var(--text); }
@@ -40,33 +40,49 @@
.admin-chip .k { font-size: 12px; color: var(--muted); } .admin-chip .k { font-size: 12px; color: var(--muted); }
.admin-chip .v { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; overflow-wrap: anywhere; } .admin-chip .v { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; overflow-wrap: anywhere; }
.admin-error { color: var(--accent-red-bright); font-size: 13px; margin-top: 10px; word-break: break-word; overflow-wrap: anywhere; } .admin-error { color: var(--accent-red-bright); font-size: 13px; margin-top: 10px; word-break: break-word; overflow-wrap: anywhere; }
.admin-warn { color: #b8860b; font-size: 13px; margin-top: 10px; word-break: break-word; }
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; } .admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
.hidden { display: none !important; } .hidden { display: none !important; }
.bad { color: var(--accent-red-bright); } abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
.good { color: #2f9e5f; }
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */ /* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
#toast-wrap { #toast-wrap {
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); position: fixed;
z-index: 9999; display: flex; flex-direction: column; gap: 8px; left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
width: min(560px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
} }
.toast { .toast {
display: flex; align-items: flex-start; gap: 10px; display: flex;
background: var(--surface); border: 1px solid var(--border-2); align-items: flex-start;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border-2);
border-left: 4px solid var(--accent-blue); border-left: 4px solid var(--accent-blue);
box-shadow: 0 8px 24px var(--shadow); box-shadow: 0 8px 24px var(--shadow);
padding: 12px 14px; font-size: 14px; color: var(--text); padding: 12px 14px;
white-space: pre-line; animation: toast-in .18s ease-out; font-size: 14px;
color: var(--text);
white-space: pre-line;
animation: toast-in .18s ease-out;
} }
.toast.ok { border-left-color: #2f9e5f; } .toast.ok { border-left-color: #2f9e5f; }
.toast.error { border-left-color: var(--accent-red-bright); } .toast.error { border-left-color: var(--accent-red-bright); }
.toast.hide { opacity: 0; transition: opacity .3s ease; } .toast.hide { opacity: 0; transition: opacity .3s ease; }
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; } .toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast .toast-close { .toast .toast-close {
flex: 0 0 auto; border: none; background: none; color: var(--muted); flex: 0 0 auto;
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px; border: none;
background: none;
color: var(--muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
} }
.toast .toast-close:hover { color: var(--text); } .toast .toast-close:hover { color: var(--text); }
@keyframes toast-in { @keyframes toast-in {
@@ -79,9 +95,8 @@
<div class="admin-wrap"> <div class="admin-wrap">
<div class="admin-head"> <div class="admin-head">
<img src="/img/owl.svg" alt=""> <img src="/img/owl.svg" alt="">
<div class="admin-title">Админка расписания</div> <div class="admin-title">Админ кабинет</div>
</div> </div>
<div class="admin-sub">Управление источником данных и обновлением. Страница доступна только по секретной ссылке.</div>
<section id="login-card" class="admin-card hidden"> <section id="login-card" class="admin-card hidden">
<h2>Вход</h2> <h2>Вход</h2>
@@ -100,13 +115,6 @@
<h2>Состояние</h2> <h2>Состояние</h2>
<div class="admin-status" id="status-chips"></div> <div class="admin-status" id="status-chips"></div>
<div id="state-error" class="admin-error hidden"></div> <div id="state-error" class="admin-error hidden"></div>
<div id="state-warn" class="admin-warn hidden"></div>
</div>
<div class="admin-card">
<h2>Входы в админку</h2>
<div class="admin-status" id="auth-chips"></div>
<div class="admin-hint">Каждая попытка входа запоминается: откуда, когда и чем закончилась. После нескольких неудач вход временно блокируется.</div>
</div> </div>
<div class="admin-card"> <div class="admin-card">
@@ -119,7 +127,7 @@
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button> <button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
</div> </div>
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними. <div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
Значение хранится в файле настроек и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div> Это значение хранится в настройках стенда и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div>
</div> </div>
<div class="admin-card"> <div class="admin-card">
@@ -132,7 +140,8 @@
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button> <button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button> <button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
</div> </div>
<div class="admin-hint">По интервалу расписание перечитывается, только если на Диске изменилась дата файла. Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</div> <div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.
Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.</div>
</div> </div>
<div class="admin-card"> <div class="admin-card">
@@ -162,6 +171,7 @@
close.setAttribute("aria-label", "Закрыть"); close.setAttribute("aria-label", "Закрыть");
el.append(text, close); el.append(text, close);
wrap.appendChild(el); wrap.appendChild(el);
const hide = () => { const hide = () => {
clearTimeout(timer); clearTimeout(timer);
el.classList.add("hide"); el.classList.add("hide");
@@ -198,71 +208,33 @@
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" }); return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
} }
function chip(key, value, cls) { function chip(key, value) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "admin-chip"; div.className = "admin-chip";
div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`; div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`;
const v = div.querySelector(".v"); div.querySelector(".v").textContent = value;
v.textContent = value;
if (cls) v.classList.add(cls);
return div; return div;
} }
function loginText(info) {
if (!info) return "нет данных";
// у успешного входа поля ok нет — там итог и так известен
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
return `${fmt(info.at)} · ${info.ip}${status}`;
}
function renderState(state) { function renderState(state) {
const chips = $("status-chips"); const chips = $("status-chips");
chips.innerHTML = ""; chips.innerHTML = "";
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`)); chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
chips.appendChild(chip("Обновлено", fmt(state.last_updated))); chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
chips.appendChild(chip("Таблица изменена", fmt(state.table_modified)));
chips.appendChild(chip( chips.appendChild(chip(
"Файл при последнем обновлении", "Файл при последнем обновлении",
state.changed ? "изменился" : "без изменений", state.changed ? "изменился ✓" : "без изменений (та же версия)",
state.changed ? "good" : null,
)); ));
chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified))); chips.appendChild(chip("Содержимое менялось", fmt(state.changed_at)));
chips.appendChild(chip("Групп в расписании", String(state.groups)));
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash)); if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
chips.appendChild(chip("Групп в расписании", String(state.groups)));
chips.appendChild(chip("Текущая ссылка", state.link || "не задана")); chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
if (state.settings_file) chips.appendChild(chip("Файл настроек", state.settings_file));
const auth = $("auth-chips");
auth.innerHTML = "";
const attempt = state.last_attempt;
auth.appendChild(chip(
"Последняя попытка входа",
loginText(attempt),
attempt ? (attempt.ok ? "good" : "bad") : null,
));
auth.appendChild(chip("Последний успешный вход", loginText(state.last_success)));
const throttle = state.throttle || {};
if (throttle.locked_until) {
auth.appendChild(chip("Вход заблокирован до", fmt(throttle.locked_until), "bad"));
} else if (throttle.max_attempts) {
auth.appendChild(chip(
"Осталось попыток",
`${throttle.attempts_left} из ${throttle.max_attempts}`,
));
}
$("link").value = state.link || ""; $("link").value = state.link || "";
$("interval").value = state.interval_minutes; $("interval").value = state.interval_minutes;
const err = $("state-error"); const err = $("state-error");
err.textContent = state.error ? "Ошибка загрузки: " + state.error : ""; err.textContent = state.error ? "Ошибка загрузки: " + state.error : "";
err.classList.toggle("hidden", !state.error); err.classList.toggle("hidden", !state.error);
const warn = $("state-warn");
warn.textContent = state.settings_save_error
? "Настройки не сохраняются на диск: " + state.settings_save_error +
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env."
: "";
warn.classList.toggle("hidden", !state.settings_save_error);
} }
function showLogin(show) { function showLogin(show) {
@@ -281,10 +253,7 @@
async function loadState() { async function loadState() {
const r = await api("/state"); const r = await api("/state");
if (r.status === 401) { showLogin(true); return; } if (r.status === 401) { showLogin(true); return; }
if (!r.ok) { if (!r.ok) { toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error"); return; }
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
showLogin(false); showLogin(false);
renderState(r.data); renderState(r.data);
} }

View File

@@ -1,7 +1,7 @@
"""Фронтенд расписания: статики, прокси к API и страница админки. """Фронтенд расписания: статики, прокси к API бэкенда и страница админки.
Страница админки отдаётся по секретному пути из ADMIN_PATH; сам API админки — Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py):
/api/admin/* (проксируется в backend). Если ADMIN_PATH не задан, страницы нет. сам путь не публикуется, входа с сайта нет.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,32 +21,14 @@ from fastapi.staticfiles import StaticFiles
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
load_dotenv(Path(__file__).parent / ".env")
def _find_dotenv() -> Path | None:
"""Ищем .env вверх от каталога модуля: один общий .env в корне проекта."""
path = Path(__file__).resolve().parent
for _ in range(5):
env = path / ".env"
if env.is_file():
return env
if path.parent == path:
break
path = path.parent
return None
_dotenv_path = _find_dotenv()
if _dotenv_path:
load_dotenv(_dotenv_path)
# Адрес бэкенда: в docker это http://api:8000 (задаётся в docker-compose),
# локально — http://127.0.0.1:8000.
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше. # Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150")) PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/") ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/")
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
# страница админки вне static: прямой доступ по /admin.html закрыт # страница админки лежит ВНЕ static: прямой доступ по /admin.html запрещён
ADMIN_PAGE = Path(__file__).parent / "admin.html" ADMIN_PAGE = Path(__file__).parent / "admin.html"
app = FastAPI(title="Расписание — фронт") app = FastAPI(title="Расписание — фронт")
@@ -69,7 +51,7 @@ def _proxy(path: str, params: dict) -> object:
except Exception: except Exception:
detail = resp.text detail = resp.text
raise HTTPException(status_code=resp.status_code, detail=detail) raise HTTPException(status_code=resp.status_code, detail=detail)
# данные не кэшируем: браузер всегда показывает актуальное расписание # данные не кэшируем: после «Обновить сейчас» браузер должен видеть свежее
return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"}) return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
@@ -162,18 +144,12 @@ async def api_admin(rest: str, request: Request):
cookie = request.headers.get("cookie") cookie = request.headers.get("cookie")
if cookie: if cookie:
headers["Cookie"] = cookie headers["Cookie"] = cookie
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
headers["X-Forwarded-For"] = forwarded
forwarded_proto = request.headers.get("x-forwarded-proto")
if forwarded_proto:
headers["X-Forwarded-Proto"] = forwarded_proto
return await run_in_threadpool( return await run_in_threadpool(
_proxy_admin, request.method, f"api/admin/{rest}", body, headers _proxy_admin, request.method, f"api/admin/{rest}", body, headers
) )
# --- статики и админка -------------------------------------------------------- # --- статики -----------------------------------------------------------------
@app.get("/") @app.get("/")

View File

@@ -1,12 +1,13 @@
"""Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/force.""" """Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление."""
import json import json
import time
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import main import main
from admin import AdminConfig, make_token, verify_token from admin import LOGIN_MAX_FAILURES, AdminConfig, make_token, verify_token
from fixtures import fixture_xlsx, make_cache from fixtures import fixture_xlsx, make_cache
from schedule import ScheduleError from schedule import ScheduleError
from schedule.settings import SettingsStore from schedule.settings import SettingsStore
@@ -28,8 +29,8 @@ def cache(monkeypatch, store):
@pytest.fixture() @pytest.fixture()
def client(cache, store): def client(cache):
app = main.create_app(cache, admin=ADMIN, settings=store) app = main.create_app(cache, admin=ADMIN)
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
@@ -74,7 +75,6 @@ def test_login_and_state(client, cache):
assert state["interval_minutes"] == 15 assert state["interval_minutes"] == 15
assert state["groups"] == 2 assert state["groups"] == 2
assert state["error"] is None assert state["error"] is None
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts
def test_logout(client): def test_logout(client):
@@ -84,6 +84,14 @@ def test_logout(client):
assert client.get("/api/admin/state").status_code == 401 assert client.get("/api/admin/state").status_code == 401
def test_login_throttled_after_failures(client):
for _ in range(LOGIN_MAX_FAILURES):
assert do_login(client, password="неверный").status_code == 401
# дальше — отказ, даже с верным паролем, пока не истекло окно
assert do_login(client, password="неверный").status_code == 429
assert do_login(client, password="s3cret").status_code == 429
def test_token_helpers(): def test_token_helpers():
token = make_token("admin", "s3cret", ttl=60) token = make_token("admin", "s3cret", ttl=60)
assert verify_token(token, "s3cret") assert verify_token(token, "s3cret")
@@ -93,51 +101,7 @@ def test_token_helpers():
assert not verify_token(make_token("admin", "s3cret", ttl=-5), "s3cret") assert not verify_token(make_token("admin", "s3cret", ttl=-5), "s3cret")
# --- блокировка после неудачных попыток ------------------------------------------ # --- интервал ------------------------------------------------------------------
def test_lockout_after_configured_attempts(client):
"""Разрешённые попытки тратятся, следующая попытка включает паузу."""
for _ in range(ADMIN.max_attempts - 1):
resp = do_login(client, password="неверный")
assert resp.status_code == 401
assert "Осталось попыток" in resp.json()["detail"]
exhausted = do_login(client, password="неверный")
assert exhausted.status_code == 429
assert "заблокирован" in exhausted.json()["detail"].lower()
# пауза действует и для верного пароля
assert do_login(client, password="s3cret").status_code == 429
def test_successful_login_resets_failures(client):
assert do_login(client, password="неверный").status_code == 401
assert do_login(client).status_code == 200
# счётчик сброшен: ещё две неудачи не блокируют
assert do_login(client, password="неверный").status_code == 401
assert do_login(client, password="неверный").status_code == 401
assert do_login(client).status_code == 200
# --- история входов ---------------------------------------------------------------
def test_login_history_visible_and_saved(client, store):
assert do_login(client, password="неверный").status_code == 401
assert do_login(client).status_code == 200
state = client.get("/api/admin/state").json()
assert state["last_attempt"]["ok"] is True
assert state["last_attempt"]["ip"] # откуда
assert state["last_attempt"]["at"] # когда
assert state["last_success"]["ip"] == state["last_attempt"]["ip"]
saved = json.loads(store.path.read_text(encoding="utf-8"))
assert saved["last_attempt"]["ok"] is True
assert saved["last_success"]["at"]
# --- интервал --------------------------------------------------------------------
def test_set_interval(client, cache, store): def test_set_interval(client, cache, store):
@@ -148,9 +112,11 @@ def test_set_interval(client, cache, store):
assert cache.interval_minutes == 30 assert cache.interval_minutes == 30
assert cache._ttl == 1800 assert cache._ttl == 1800
assert client.get("/api/admin/state").json()["interval_minutes"] == 30 assert client.get("/api/admin/state").json()["interval_minutes"] == 30
saved = json.loads(store.path.read_text(encoding="utf-8")) # сохранилось на диск
saved = json.loads((store._path).read_text(encoding="utf-8"))
assert saved["interval_minutes"] == 30 assert saved["interval_minutes"] == 30
assert SettingsStore(path=store.path).interval_minutes == 30 # новый процесс видит сохранённое значение
assert SettingsStore(path=store._path).interval_minutes == 30
@pytest.mark.parametrize("value, status", [(0, 400), (1441, 400), (-5, 400), ("abc", 422)]) @pytest.mark.parametrize("value, status", [(0, 400), (1441, 400), (-5, 400), ("abc", 422)])
@@ -168,9 +134,10 @@ def test_set_link(client, cache, store):
before = cache.calls["download"] before = cache.calls["download"]
resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "}) resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["link"] == "https://disk.yandex.ru/i/new" state = resp.json()
assert state["link"] == "https://disk.yandex.ru/i/new"
assert cache.calls["download"] == before + 1 # перечитали таблицу assert cache.calls["download"] == before + 1 # перечитали таблицу
assert json.loads(store.path.read_text(encoding="utf-8"))["link"] == "https://disk.yandex.ru/i/new" assert json.loads(store._path.read_text(encoding="utf-8"))["link"] == "https://disk.yandex.ru/i/new"
def test_set_link_rejects_broken(client, cache, store): def test_set_link_rejects_broken(client, cache, store):
@@ -180,10 +147,12 @@ def test_set_link_rejects_broken(client, cache, store):
resp = client.post("/api/admin/link", json={"link": "https://disk.yandex.ru/i/broken"}) resp = client.post("/api/admin/link", json={"link": "https://disk.yandex.ru/i/broken"})
assert resp.status_code == 400 assert resp.status_code == 400
assert "не подошла" in resp.json()["detail"] assert "не подошла" in resp.json()["detail"]
# ссылка и данные не изменились
assert cache.state()["link"] == old_link assert cache.state()["link"] == old_link
assert cache.state()["has_data"] is True assert cache.state()["has_data"] is True
if store.path.exists(): # в файл настроек нерабочая ссылка не попала
assert json.loads(store.path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken" if store._path.exists():
assert json.loads(store._path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
def test_set_empty_link(client, cache): def test_set_empty_link(client, cache):
@@ -198,9 +167,9 @@ def test_set_empty_link(client, cache):
def test_force_refresh_ignores_ttl_and_modified(client, cache): def test_force_refresh_ignores_ttl_and_modified(client, cache):
do_login(client) do_login(client)
client.get("/api/admin/state") client.get("/api/admin/state") # кэш свежий, дальше без принуждения не качали бы
downloads = cache.calls["download"] downloads = cache.calls["download"]
cache._ttl = 3600 # кэш свежий; обычное обновление не сработало бы cache._ttl = 3600
resp = client.post("/api/admin/refresh") resp = client.post("/api/admin/refresh")
assert resp.status_code == 200 assert resp.status_code == 200
assert cache.calls["download"] == downloads + 1 assert cache.calls["download"] == downloads + 1
@@ -213,14 +182,15 @@ def test_force_refresh_reports_error(client, cache):
resp = client.post("/api/admin/refresh") resp = client.post("/api/admin/refresh")
assert resp.status_code == 502 assert resp.status_code == 502
assert cache.state()["error"] is not None assert cache.state()["error"] is not None
assert cache.state()["has_data"] is True # старые данные не потеряли # старые данные не потеряли
assert cache.state()["has_data"] is True
# --- выключенная админка ----------------------------------------------------------- # --- выключенная админка -----------------------------------------------------------
def test_admin_disabled_without_credentials(cache, store): def test_admin_disabled_without_credentials(cache):
app = main.create_app(cache, admin=AdminConfig(), settings=store) app = main.create_app(cache, admin=AdminConfig())
with TestClient(app) as c: with TestClient(app) as c:
assert c.get("/api/admin/state").status_code == 404 assert c.get("/api/admin/state").status_code == 404
assert c.post("/api/admin/login", json={"login": "a", "password": "b"}).status_code == 404 assert c.post("/api/admin/login", json={"login": "a", "password": "b"}).status_code == 404

View File

@@ -171,7 +171,11 @@ def test_cache_no_repeated_download(client, cache):
def test_modified_unchanged_no_download(cache): def test_modified_unchanged_no_download(cache):
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем.""" """По интервалу сверяем modified: если таблица не менялась — не скачиваем.
Принудительное обновление из админки при этом качает файл всегда
(см. test_force_refresh_ignores_ttl_and_modified).
"""
cache._ttl = 0 cache._ttl = 0
cache.get() cache.get()
cache.get() cache.get()
@@ -179,7 +183,7 @@ def test_modified_unchanged_no_download(cache):
assert cache.calls["modified"] >= 2 assert cache.calls["modified"] >= 2
def test_status_reports_file_changes(monkeypatch): def test_state_reports_file_changes(monkeypatch):
"""Кэш показывает, изменилось ли содержимое файла при последнем чтении.""" """Кэш показывает, изменилось ли содержимое файла при последнем чтении."""
import schedule.cache as cache_mod import schedule.cache as cache_mod
from fixtures import fixture_sheets from fixtures import fixture_sheets
@@ -190,30 +194,25 @@ def test_status_reports_file_changes(monkeypatch):
sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}" sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}"
return build_workbook(sheets) return build_workbook(sheets)
files = {"data": xlsx_with_room("215"), "modified": "m1"} files = {"data": xlsx_with_room("215")}
monkeypatch.setattr( monkeypatch.setattr(cache_mod, "fetch_table_modified", lambda url, timeout=20.0: "m1")
cache_mod, "fetch_table_modified", lambda url, timeout=20.0: files["modified"]
)
monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"]) monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"])
cache = ScheduleCache("test-link", ttl_seconds=0, retry_after=60) cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60)
cache.get() cache.get()
first = cache.status() first = cache.state()
assert first["content_hash"] assert first["content_hash"]
assert first["changed"] is False # первая загрузка — не «изменение» assert first["changed"] is False # первая загрузка — не «изменение»
assert first["changed_at"] is not None assert first["changed_at"] is not None
files["data"] = xlsx_with_room("216") files["data"] = xlsx_with_room("216")
files["modified"] = "m2" cache.force_reload()
cache.get() # ttl=0 и новый modified — перечитываем таблицу changed = cache.state()
changed = cache.status()
assert changed["changed"] is True assert changed["changed"] is True
assert changed["content_hash"] != first["content_hash"] assert changed["content_hash"] != first["content_hash"]
# новая ревизия файла с тем же содержимым → скачали, но изменений нет cache.force_reload()
files["modified"] = "m3" assert cache.state()["changed"] is False # тот же файл — изменений нет
cache.get()
assert cache.status()["changed"] is False
def test_cache_stale_while_error(cache): def test_cache_stale_while_error(cache):

View File

@@ -1,8 +1,7 @@
"""Проверки сборки образов: все нужные файлы должны копироваться. """Проверки сборки образов: все нужные файлы должны копироваться.
Ошибка «в Dockerfile забыли COPY какого-то модуля» роняет контейнер на импорте, Ошибка «в Dockerfile забыли COPY admin.py» роняет контейнер на импорте, а
а тесты из исходников её не видят (они запускаются из рабочей копии). Ловим локальные тесты её не видят (они запускаются из исходников). Ловим статически.
статически, без docker.
""" """
from pathlib import Path from pathlib import Path
@@ -34,18 +33,15 @@ def test_frontend_image_copies_page_and_static():
assert path in stage, f"{path} не копируется в образ frontend" assert path in stage, f"{path} не копируется в образ frontend"
def test_compose_passes_required_env(): def test_compose_passes_admin_settings():
compose = COMPOSE.read_text(encoding="utf-8") compose = COMPOSE.read_text(encoding="utf-8")
for var in ( for var in (
"SCHEDULE_LINK",
"SCHEDULE_INTERVAL_MINUTES",
"SCHEDULE_API",
"ADMIN_PATH", "ADMIN_PATH",
"ADMIN_LOGIN", "ADMIN_LOGIN",
"ADMIN_PASSWORD", "ADMIN_PASSWORD",
"ADMIN_MAX_ATTEMPTS", "SCHEDULE_INTERVAL_MINUTES",
"ADMIN_LOCKOUT_MINUTES",
"SETTINGS_FILE", "SETTINGS_FILE",
"SCHEDULE_API",
): ):
assert var in compose, f"{var} не передаётся контейнерам" assert var in compose, f"{var} не передаётся контейнерам"
assert "schedule_data:/data" in compose, "том для настроек админки не подключён" assert "schedule_data:/data" in compose, "том для настроек админки не подключён"

View File

@@ -14,7 +14,7 @@ def lessons(text):
def one(text): def one(text):
ls = parse_lesson_cell(text) ls = parse_lesson_cell(text)
assert len(ls) == 1, f"ожидалось 1 занятие, got: {ls}" assert len(ls) == 1, f"ожидалося 1 занятие, got: {ls}"
return ls[0].to_dict() return ls[0].to_dict()
@@ -295,53 +295,6 @@ def test_lesson_with_red_between_lessons():
assert ls[1]["subject"] == "Язык UML и CASE-системы" assert ls[1]["subject"] == "Язык UML и CASE-системы"
# --- оторвавшиеся пустой строкой преподаватель/аудитория ---------------------------
def test_room_detached_by_blank_line():
# ПИЭ-31БО, вторник: «ауд.210» оторвана пустой строкой от предмета
result = one("Программная инженерия\nЛарина Ю.А.\n\n ауд.210")
assert result["subject"] == "Программная инженерия"
assert result["teacher"] == "Ларина Ю.А."
assert result["room"] == "ауд. 210"
def test_teacher_and_room_detached_by_red_note():
# ИВТ-13БО, четверг: красная заметка снята со своей строки, осталась пустая
raw = f"Практикум по матанализу\n{R}пара для тех кто не ТОП{Cc}\nШабаршина Г.В.\nауд. 219"
result = one(raw)
assert result["subject"] == "Практикум по матанализу"
assert result["teacher"] == "Шабаршина Г.В."
assert result["room"] == "ауд. 219"
assert result["note"] == "пара для тех кто не ТОП"
def test_detached_room_inherits_marker():
ls = lessons("к.в. - 2\nФизика\nИванов И.И.\n\nауд. 301")
assert len(ls) == 1
assert ls[0]["marker"] == "к.в. - 2"
assert ls[0]["room"] == "ауд. 301"
def test_detached_room_kept_if_prev_has_room():
ls = lessons("Физика\nСмирнов А.В.\nауд. 221\n\nауд. 222")
assert len(ls) == 2
assert ls[0]["room"] == "ауд. 221"
def test_detached_teacher_kept_if_prev_has_teacher():
ls = lessons("Физика\nСмирнов А.В.\nауд. 221\n\nПетров П.П.\nауд. 222")
assert len(ls) == 2
assert ls[0]["teacher"] == "Смирнов А.В."
def test_lesson_after_detached_room_starts_fresh():
ls = lessons("Физика\nСмирнов А.В.\n\nауд. 221\n\nХимия\nПетров П.П.\nауд. 222")
assert len(ls) == 2
assert ls[0]["room"] == "ауд. 221"
assert ls[1]["subject"] == "Химия"
# --- пустые и экзотические ---------------------------------------------------------- # --- пустые и экзотические ----------------------------------------------------------

View File

@@ -145,18 +145,6 @@ def test_teacher_room_index(data):
assert room_301["2"]["ч"][0]["groups"] == ["ИВТ-11БО"] assert room_301["2"]["ч"][0]["groups"] == ["ИВТ-11БО"]
def test_room_index_detached_room():
"""Аудитория, оторванная пустой строкой, попадает в индекс с предметом и преподавателем."""
sheets = fixture_sheets()
sheets[parsing.MAIN_SHEET].rows[6][7] = "Химия\nПетров П.П.\n\n ауд. 201"
data = parsing.parse_schedule(make_sheets(sheets))
_, rooms = parsing.build_teacher_room_index(data)
entry = rooms["ауд. 201"]["вторник"]["1"]["ч"][0]
assert entry["subject"] == "Химия"
assert entry["teacher"] == "Петров П.П."
assert entry["groups"] == ["ИТ-12БО"]
def test_day_times(data): def test_day_times(data):
times = parsing.build_day_times(data) times = parsing.build_day_times(data)
assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"} assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"}

View File

@@ -1,4 +1,4 @@
"""Тесты хранилища настроек админки: ссылка, интервал, история входов.""" """Тесты хранилища настроек администратора (ссылка, интервал)."""
import json import json
@@ -17,8 +17,6 @@ def test_defaults():
store = SettingsStore() store = SettingsStore()
assert store.link == "" assert store.link == ""
assert store.interval_minutes == DEFAULT_INTERVAL_MINUTES assert store.interval_minutes == DEFAULT_INTERVAL_MINUTES
assert store.last_attempt is None
assert store.last_success is None
def test_persist_and_reload(tmp_path): def test_persist_and_reload(tmp_path):
@@ -64,46 +62,20 @@ def test_corrupt_file_is_tolerated(tmp_path):
assert store.interval_minutes == 7 assert store.interval_minutes == 7
def test_login_history_saved(tmp_path): def test_no_path_keeps_settings_in_memory():
path = tmp_path / "settings.json" store = SettingsStore(link="l", interval_minutes=3)
store = SettingsStore(link="l", path=path) assert store.set_interval_minutes(10) == 10
assert store.interval_minutes == 10
store.record_login("10.0.0.1", ok=False) assert store.set_link("l2") == "l2"
assert store.last_attempt["ip"] == "10.0.0.1" assert store.link == "l2"
assert store.last_attempt["ok"] is False
assert store.last_attempt["at"]
assert store.last_success is None # успешных ещё не было
store.record_login("10.0.0.2", ok=True)
assert store.last_success["ip"] == "10.0.0.2"
# история переживает перезапуск: читаем тем же путём
again = SettingsStore(path=path)
assert again.last_attempt["ip"] == "10.0.0.2"
assert again.last_attempt["ok"] is True
assert again.last_success["ip"] == "10.0.0.2"
def test_unwritable_path_marks_save_error(tmp_path): def test_unwritable_path_does_not_crash(tmp_path):
blocker = tmp_path / "blocker" blocker = tmp_path / "blocker"
blocker.write_text("x", encoding="utf-8") blocker.write_text("x", encoding="utf-8")
store = SettingsStore(link="l", interval_minutes=3, path=blocker / "settings.json") store = SettingsStore(link="l", interval_minutes=3, path=blocker / "settings.json")
assert store.set_interval_minutes(10) == 10 # в памяти меняется assert store.set_interval_minutes(10) == 10
assert store.interval_minutes == 10 assert store.interval_minutes == 10
assert store.save_error # и видно, что на диск не сохранилось
def test_default_settings_path_is_inside_project():
"""Путь по умолчанию вычисляется от корня проекта, а не от глубины файла.
В docker-образе файлы лежат плоско (/app/...), поэтому глубину не считаем.
"""
from schedule.settings import DEFAULT_SETTINGS_PATH
assert DEFAULT_SETTINGS_PATH.name == "settings.json"
assert DEFAULT_SETTINGS_PATH.parent.name == "data"
root = DEFAULT_SETTINGS_PATH.parent.parent
assert (root / "requirements.txt").is_file() # корень найден по маркеру
def test_clamp_interval(): def test_clamp_interval():