Compare commits
7 Commits
admin-pane
...
3d5bfe3023
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d5bfe3023 | |||
| b49ef93619 | |||
| 5b3c9dc00a | |||
| 962681630f | |||
| cc194b8eee | |||
| d897601d88 | |||
| 34b975da69 |
41
.env.example
41
.env.example
@@ -1,25 +1,30 @@
|
|||||||
# --- расписание -------------------------------------------------------------
|
# --- расписание --------------------------------------------------------------
|
||||||
# Публичная ссылка на Яндекс.Диск (обязательна)
|
# Публичная ссылка на Яндекс.Диск с таблицей расписания (обязательна)
|
||||||
SCHEDULE_LINK=
|
SCHEDULE_LINK=
|
||||||
# Интервал обновления в минутах (переживает перезапуск: правится в админке)
|
|
||||||
SCHEDULE_INTERVAL_MINUTES=15
|
|
||||||
# Пауза между повторными попытками при сбое источника, секунд
|
|
||||||
SCHEDULE_RETRY_AFTER=60
|
|
||||||
# Устаревшее: интервал в секундах (используется, только если не задан SCHEDULE_INTERVAL_MINUTES)
|
|
||||||
SCHEDULE_CACHE_TTL=900
|
|
||||||
|
|
||||||
# --- админка (пусто = выключена) ---------------------------------------------
|
# Как часто перечитывать таблицу, минут
|
||||||
# Секретный путь страницы админки, например /manage-7f3a91
|
SCHEDULE_INTERVAL_MINUTES=15
|
||||||
|
# Устаревшее: интервал в секундах (используется, только если не задан предыдущий)
|
||||||
|
SCHEDULE_CACHE_TTL=900
|
||||||
|
# Пауза между повторными попытками, если источник недоступен, секунд
|
||||||
|
SCHEDULE_RETRY_AFTER=60
|
||||||
|
|
||||||
|
# --- порты (только для docker) -------------------------------------------------
|
||||||
|
# Оба порта публикуются на 127.0.0.1 (жёстко задано в docker-compose.yml):
|
||||||
|
# снаружи сервисы отдаёт nginx, напрямую в интернет они не смотрят.
|
||||||
|
API_PORT=8000
|
||||||
|
FRONTEND_PORT=8080
|
||||||
|
|
||||||
|
# --- админка (необязательно) ----------------------------------------------------
|
||||||
|
# Пока эти переменные пустые, админка выключена и наружу ничего не торчит.
|
||||||
|
# Секретный путь страницы админки: придумайте длинный, например /panel-8f3a91x7
|
||||||
ADMIN_PATH=
|
ADMIN_PATH=
|
||||||
# Логин и пароль администратора
|
# Логин и пароль администратора
|
||||||
ADMIN_LOGIN=
|
ADMIN_LOGIN=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
# Где хранить настройки (ссылка/интервал).
|
# Сколько неудачных попыток входа допускается и на сколько минут блокировать после них
|
||||||
# В docker по умолчанию /data/settings.json (том schedule_data);
|
ADMIN_MAX_ATTEMPTS=3
|
||||||
# локально можно оставить пустым — тогда настройки живут в памяти.
|
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
6
.gitignore
vendored
@@ -6,3 +6,9 @@ __pycache__/
|
|||||||
/.idea/
|
/.idea/
|
||||||
**/*.env
|
**/*.env
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# локальные данные инструментов
|
||||||
|
.kilo/
|
||||||
|
|
||||||
|
# состояние админки (ссылка, интервал, история входов)
|
||||||
|
/data/
|
||||||
|
|||||||
68
Makefile
68
Makefile
@@ -1,32 +1,68 @@
|
|||||||
.PHONY: build up down logs restart deploy ps dev test
|
PY := $(CURDIR)/.venv/bin/python
|
||||||
|
PIP := $(CURDIR)/.venv/bin/pip
|
||||||
|
|
||||||
build: ## Собрать образы микросервисов
|
.PHONY: help venv run dev run-api run-front test build up down restart logs ps deploy nginx-print nginx-install
|
||||||
|
|
||||||
|
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: ## Логи всех микросервисов
|
logs: ## Логи сервисов (Ctrl+C для выхода)
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
ps: ## Статус микросервисов
|
ps: ## Статус сервисов
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
deploy: ## Собрать и запустить (деплой на сервере)
|
deploy: ## Собрать и запустить (docker)
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
|
|
||||||
test: ## Прогнать тесты (локальный venv)
|
# --- nginx (на сервере) ------------------------------------------------------
|
||||||
$(CURDIR)/.venv/bin/python -m pytest tests -q
|
|
||||||
|
|
||||||
dev: ## Запустить локально без Docker (бэкенд + фронт)
|
# DOMAIN=sched.example.ru make nginx-print / nginx-install
|
||||||
@echo "Запуск бэкенда на :8000 и фронта на :8080..."
|
nginx-print: ## Показать конфиг nginx (DOMAIN=домен), ничего не меняя
|
||||||
@trap 'kill 0' EXIT; \
|
deploy/nginx-site.sh --print --domain $(DOMAIN)
|
||||||
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \
|
|
||||||
cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8080 & \
|
nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (DOMAIN=домен, с sudo)
|
||||||
wait
|
sudo deploy/nginx-site.sh --domain $(DOMAIN)
|
||||||
|
|||||||
523
README.md
523
README.md
@@ -1,8 +1,19 @@
|
|||||||
# Расписание
|
# Расписание
|
||||||
|
|
||||||
Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица
|
Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на
|
||||||
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
|
Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних
|
||||||
API и отображается на фронте. Деплой: `schedule.our-place.ru`.
|
библиотек и отдаётся как JSON API и как сайт, который можно «установить» на
|
||||||
|
главный экран телефона (PWA).
|
||||||
|
|
||||||
|
Умеет:
|
||||||
|
|
||||||
|
- расписание по группам, преподавателям и аудиториям с разделением
|
||||||
|
числитель/знаменатель и бейджем текущей недели;
|
||||||
|
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
|
||||||
|
- совпадающие недели показываются одним списком, различающиеся — двумя
|
||||||
|
колонками, пустая — «Окно»;
|
||||||
|
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
|
||||||
|
- PWA: манифест, service worker, иконки — «на главный экран».
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
@@ -10,20 +21,336 @@ API и отображается на фронте. Деплой: `schedule.our-p
|
|||||||
Яндекс.Диск (xlsx)
|
Яндекс.Диск (xlsx)
|
||||||
│ скачивание по публичной ссылке
|
│ скачивание по публичной ссылке
|
||||||
▼
|
▼
|
||||||
api (FastAPI), порт 8000, доступ только с localhost
|
api — FastAPI, порт 8000
|
||||||
• скачивает и разбирает xlsx без сторонних библиотек
|
• качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree)
|
||||||
• кэш в памяти: 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: статика + прокси), прод :8090, dev :8080
|
frontend — FastAPI, порт из .env (по умолчанию 8080)
|
||||||
• отдаёт index.html / app.js / style.css / картинки
|
• отдаёт index.html / app.js / style.css / иконки
|
||||||
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
|
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
|
||||||
▲
|
▲
|
||||||
nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Как устроено расписание в таблице
|
Роли:
|
||||||
|
|
||||||
|
- **docker** изолирует оба сервиса, перезапускает их при падении
|
||||||
|
(`restart: unless-stopped`) и следит за здоровьем (healthcheck на
|
||||||
|
`/healthz`); frontend стартует, только когда api стал healthy.
|
||||||
|
- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в
|
||||||
|
`docker-compose.yml`): из интернета контейнеры напрямую недоступны.
|
||||||
|
- **nginx** — единственная дверь из интернета: принимает https и проксирует на
|
||||||
|
фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик).
|
||||||
|
|
||||||
|
## Установка на сервер
|
||||||
|
|
||||||
|
### Docker (deploy.sh)
|
||||||
|
|
||||||
|
Скачайте zip с проектом, распакуйте и заполните `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip schedule.zip && cd schedule
|
||||||
|
cp .env.example .env && nano .env # SCHEDULE_LINK обязателен; ADMIN_* — по желанию
|
||||||
|
sudo ./deploy.sh # собрать образы и запустить
|
||||||
|
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.sh` проверяет, что Docker и `.env` на месте, собирает образы и поднимает
|
||||||
|
контейнеры. Обновление — тем же скриптом: `git pull && sudo ./deploy.sh`.
|
||||||
|
Почему Docker: сервисы изолированы, автоматически перезапускаются, не зависят
|
||||||
|
от Python в системе и обновляются одной командой.
|
||||||
|
|
||||||
|
### Без Docker (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3 python3-venv python3-pip # Python 3.11+
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
cp .env.example .env && nano .env # SCHEDULE_LINK (+ ADMIN_*, если нужна админка)
|
||||||
|
```
|
||||||
|
|
||||||
|
Две службы systemd (`/etc/systemd/system/`), затем
|
||||||
|
`sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# schedule-api.service
|
||||||
|
[Unit]
|
||||||
|
Description=Schedule API (FastAPI)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/путь/к/проекту/src/backend
|
||||||
|
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
# schedule-frontend.service
|
||||||
|
[Unit]
|
||||||
|
Description=Schedule frontend (FastAPI)
|
||||||
|
After=network-online.target schedule-api.service
|
||||||
|
Wants=network-online.target schedule-api.service
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/путь/к/проекту/src/frontend
|
||||||
|
Environment=SCHEDULE_API=http://127.0.0.1:8000
|
||||||
|
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Если службы работают не от владельца каталога проекта, выдайте пользователю
|
||||||
|
службы права на запись в `data/` (там админка хранит ссылку, интервал и историю
|
||||||
|
входов) или задайте свой путь в `SETTINGS_FILE`.
|
||||||
|
|
||||||
|
**nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y nginx certbot
|
||||||
|
# временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html
|
||||||
|
sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru
|
||||||
|
sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080
|
||||||
|
curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/nginx-site.sh` генерирует финальный конфиг (http → https, прокси на
|
||||||
|
`FRONTEND_PORT` из `.env`, таймауты под холодный кэш), включает его, проверяет
|
||||||
|
`nginx -t` и перечитывает nginx. Посмотреть конфиг, ничего не меняя:
|
||||||
|
`deploy/nginx-site.sh --print --domain sched.example.ru`.
|
||||||
|
|
||||||
|
### Обслуживание
|
||||||
|
|
||||||
|
- **Обновление**: Docker — `cd <каталог проекта> && sudo ./deploy.sh`;
|
||||||
|
система — заменить файлы проекта и
|
||||||
|
`sudo systemctl restart schedule-api schedule-frontend`.
|
||||||
|
- **Диагностика**: Docker — `sudo docker compose ps` и
|
||||||
|
`sudo docker logs api --tail 50`; система — `systemctl status schedule-api`
|
||||||
|
и `journalctl -u schedule-api -u schedule-frontend`. Состояние кэша —
|
||||||
|
`/api/status` (смотреть поле `error`).
|
||||||
|
- **Ротация логов контейнеров** — настроить сразу: без лимита логи растут
|
||||||
|
вечно и однажды забивают диск. `/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
затем `sudo systemctl restart docker`.
|
||||||
|
- **Диск**: следить `df -h /`; раз в месяц
|
||||||
|
`sudo docker system prune -af && sudo journalctl --vacuum-size=200M`.
|
||||||
|
|
||||||
|
## Локальная разработка
|
||||||
|
|
||||||
|
Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version # должен быть 3.11 или новее
|
||||||
|
cp .env.example .env # вписать SCHEDULE_LINK
|
||||||
|
make run # создаст .venv, поставит зависимости, поднимет оба сервиса
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть <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`). Если файл недоступен для записи, изменения живут в памяти до
|
||||||
|
перезапуска, а панель предупредит об этом.
|
||||||
|
|
||||||
|
## Структура кода
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
backend/
|
||||||
|
main.py FastAPI: маршруты, create_app(cache), /healthz
|
||||||
|
admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force
|
||||||
|
schedule/
|
||||||
|
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
|
||||||
|
скрытые столбцы, красный текст, серийные даты
|
||||||
|
source.py скачивание файла по публичной ссылке Я.Диска
|
||||||
|
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
||||||
|
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
||||||
|
cache.py ScheduleCache: блокировка, интервал, stale-while-error, force
|
||||||
|
settings.py хранилище настроек админки (ссылка, интервал, история входов)
|
||||||
|
errors.py ScheduleError
|
||||||
|
frontend/
|
||||||
|
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
||||||
|
admin.html страница админки (вне static: прямой доступ закрыт)
|
||||||
|
static/
|
||||||
|
index.html, app.js, style.css
|
||||||
|
manifest.json PWA: имя, иконки, standalone-режим
|
||||||
|
sw.js минимальный service worker (без кэша) для установки приложения
|
||||||
|
img/ логотипы и иконки приложения
|
||||||
|
tools/
|
||||||
|
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
||||||
|
deploy/
|
||||||
|
nginx-site.sh генерация и включение конфига nginx (ручная настройка)
|
||||||
|
tests/
|
||||||
|
conftest.py общие фикстуры
|
||||||
|
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
||||||
|
fixtures.py общая тестовая таблица
|
||||||
|
test_lessons.py все форматы ячеек
|
||||||
|
test_xlsx.py чтение книги
|
||||||
|
test_parsing.py разбор листов, недели, индексы
|
||||||
|
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
|
||||||
|
test_admin.py админка: вход, блокировка, история, ссылка/интервал/force
|
||||||
|
test_settings.py хранилище настроек админки
|
||||||
|
test_deploy.py проверка, что образы копируют все нужные файлы
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные админки (`data/settings.json`) — рабочие, в git не попадают.
|
||||||
|
|
||||||
|
## Как устроена таблица-источник
|
||||||
|
|
||||||
Лист «Бакалавры и магистры» — это сетка:
|
Лист «Бакалавры и магистры» — это сетка:
|
||||||
|
|
||||||
@@ -32,9 +359,7 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
столбцы 2–3 даты «знаменателя»
|
столбцы 2–3 даты «знаменателя»
|
||||||
столбец 4 день недели (указан один раз на блок дня)
|
столбец 4 день недели (указан один раз на блок дня)
|
||||||
столбец 5 номер пары и время (ячейка объединена на две строки пары)
|
столбец 5 номер пары и время (ячейка объединена на две строки пары)
|
||||||
столбцы 6+ группы — по одному столбцу на группу, порядок столбцов = порядок
|
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
|
||||||
групп на сайте (скрытые столбцы пропускаются; имена вида
|
|
||||||
«ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО»)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Каждый день занимает блок строк, и строки идут парами: «числитель» /
|
Каждый день занимает блок строк, и строки идут парами: «числитель» /
|
||||||
@@ -69,7 +394,10 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
|
|
||||||
В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
|
В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
|
||||||
новым маркером «к.в.» или началом нового предмета сразу после строки
|
новым маркером «к.в.» или началом нового предмета сразу после строки
|
||||||
аудитории/преподавателя.
|
аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с
|
||||||
|
целой строки) от предмета оторвался только преподаватель и/или аудитория, этот
|
||||||
|
кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе
|
||||||
|
в расписании аудитории оставались бы одни группы.
|
||||||
|
|
||||||
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
|
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
|
||||||
`lesson = {marker, subject, teacher, room, note}`.
|
`lesson = {marker, subject, teacher, room, note}`.
|
||||||
@@ -88,91 +416,13 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
- Приложение можно «установить» на главный экран (манифест + 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}` | расписание группы на сегодня |
|
||||||
@@ -183,93 +433,10 @@ 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` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) |
|
| GET | `/status` | состояние кэша (`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` защищает только от «случайного» обнаружения панели; реальная
|
|
||||||
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
|
|
||||||
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
|
||||||
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
|
|
||||||
«нечётное число строк, недели могли сдвинуться».
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# Тестовый стенд (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
|
|
||||||
```
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Тестовый стенд 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
123
deploy/nginx-site.sh
Executable file
123
deploy/nginx-site.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/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
|
||||||
@@ -5,19 +5,22 @@ 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:-}
|
||||||
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
|
ADMIN_MAX_ATTEMPTS: ${ADMIN_MAX_ATTEMPTS:-3}
|
||||||
# настройки админки переживают пересборку благодаря тому /data
|
ADMIN_LOCKOUT_MINUTES: ${ADMIN_LOCKOUT_MINUTES:-15}
|
||||||
|
# настройки админки (ссылка, интервал, история входов) живут в томе
|
||||||
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_PORT:-8000}:8000"
|
# api нужен только фронтенду внутри docker-сети; наружу — 127.0.0.1 для отладки
|
||||||
|
- "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)"]
|
||||||
@@ -31,10 +34,13 @@ 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:
|
||||||
- "${FRONTEND_PORT:-8080}:8080"
|
# фронт обслуживает nginx; наружу напрямую не смотрит
|
||||||
|
- "127.0.0.1:${FRONTEND_PORT:-8080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
-r requirements.txt
|
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
httpx2==2.12.0
|
httpx2==2.12.0
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Админка расписания.
|
"""Админка расписания.
|
||||||
|
|
||||||
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
||||||
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После
|
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они
|
||||||
входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние
|
пусты, админка выключена и маршруты /api/admin/* не регистрируются.
|
||||||
между запросами не хранится.
|
|
||||||
|
После нескольких неудачных попыток вход блокируется на время из
|
||||||
|
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
|
||||||
@@ -22,62 +25,32 @@ 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
|
||||||
|
|
||||||
# защита от перебора пароля: не больше N неудач с одного адреса за окно
|
DEFAULT_MAX_ATTEMPTS = 3
|
||||||
LOGIN_MAX_FAILURES = 10
|
DEFAULT_LOCKOUT_MINUTES = 15
|
||||||
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:
|
||||||
@@ -98,9 +71,7 @@ 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(
|
payload = json.dumps({"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")).encode()
|
||||||
{"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)}"
|
||||||
|
|
||||||
@@ -117,6 +88,46 @@ 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
|
||||||
@@ -130,9 +141,30 @@ class IntervalBody(BaseModel):
|
|||||||
interval_minutes: int
|
interval_minutes: int
|
||||||
|
|
||||||
|
|
||||||
def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Реальный адрес клиента: за 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()
|
throttle = LoginThrottle(config.max_attempts, config.lockout_minutes)
|
||||||
|
|
||||||
def require_auth(request: Request) -> None:
|
def require_auth(request: Request) -> None:
|
||||||
token = request.cookies.get(COOKIE_NAME, "")
|
token = request.cookies.get(COOKIE_NAME, "")
|
||||||
@@ -143,24 +175,55 @@ def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
|
|||||||
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 _client_key(request: Request) -> str:
|
def _remember(ip: str, ok: bool) -> None:
|
||||||
return request.client.host if request.client else "unknown"
|
if settings is not None:
|
||||||
|
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:
|
||||||
key = _client_key(request)
|
ip = _client_ip(request)
|
||||||
if throttle.blocked(key):
|
until = throttle.locked_until(ip)
|
||||||
log.warning("Вход в админку: слишком много неудачных попыток с %s", key)
|
if until is not None:
|
||||||
|
minutes = max(1, round((until - time.time()) / 60))
|
||||||
|
log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429, detail="Слишком много попыток входа, попробуйте позже"
|
status_code=429,
|
||||||
|
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(key)
|
throttle.register_failure(ip)
|
||||||
log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40])
|
_remember(ip, False)
|
||||||
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
left = throttle.attempts_left(ip)
|
||||||
throttle.reset(key)
|
if throttle.locked_until(ip) is not None:
|
||||||
|
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),
|
||||||
@@ -170,7 +233,6 @@ def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
|
|||||||
secure=_secure(request),
|
secure=_secure(request),
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
log.info("Вход в админку выполнен")
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
@@ -179,8 +241,17 @@ def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.get("/state", dependencies=[Depends(require_auth)])
|
@router.get("/state", dependencies=[Depends(require_auth)])
|
||||||
def state() -> dict:
|
def state(request: Request) -> dict:
|
||||||
return cache.state()
|
ip = _client_ip(request)
|
||||||
|
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:
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
"""API-приложение расписания (FastAPI)."""
|
"""API-приложение расписания (FastAPI).
|
||||||
|
|
||||||
|
Кроме публичных данных здесь живёт админка (backend/admin.py): она включается
|
||||||
|
только если заданы ADMIN_LOGIN и ADMIN_PASSWORD, и открывается по секретному
|
||||||
|
пути ADMIN_PATH. Все параметры — переменные окружения, см. .env.example.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,7 +27,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"
|
||||||
@@ -49,12 +54,25 @@ 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:
|
||||||
@@ -90,10 +108,17 @@ 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:
|
||||||
cache = cache or default_cache()
|
if cache is None:
|
||||||
|
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, login=ADMIN_LOGIN, password=ADMIN_PASSWORD
|
path=ADMIN_PATH,
|
||||||
|
login=ADMIN_LOGIN,
|
||||||
|
password=ADMIN_PASSWORD,
|
||||||
|
max_attempts=ADMIN_MAX_ATTEMPTS,
|
||||||
|
lockout_minutes=ADMIN_LOCKOUT_MINUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -110,13 +135,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, admin))
|
app.include_router(create_admin_router(cache, settings, admin))
|
||||||
if admin.path:
|
if admin.path:
|
||||||
log.info("Админка включена (секретный путь задан)")
|
log.info("Админка включена (путь задан в ADMIN_PATH)")
|
||||||
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:
|
||||||
@@ -148,7 +173,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}": "Расписание группы на сегодня",
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
||||||
(вместо скачивания на каждый запрос);
|
(вместо скачивания на каждый запрос);
|
||||||
- настройки администратора (ссылка, интервал) живут в SettingsStore и
|
- настройки администратора (ссылка, интервал) живут в SettingsStore и
|
||||||
применяются на лету: смена ссылки проверяется до сохранения, принудительное
|
применяются на лету: смена ссылки проверяется до сохранения.
|
||||||
обновление идёт вне расписания.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -101,22 +100,24 @@ 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 {
|
return self._status()
|
||||||
"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:
|
||||||
return self._state()
|
state = self._status()
|
||||||
|
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:
|
||||||
@@ -125,7 +126,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
|
||||||
@@ -136,7 +137,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:
|
||||||
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
|
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
|
||||||
@@ -161,11 +162,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
|
||||||
@@ -180,16 +181,15 @@ 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 _state(self) -> dict:
|
def _status(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"link": self._link,
|
"cache_ttl_seconds": self._ttl,
|
||||||
"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._reload(now)
|
self._refresh(now)
|
||||||
|
|
||||||
def _reload(self, now: float) -> None:
|
def _refresh(self, now: float) -> None:
|
||||||
# служим старые данные, пока не истекло время повторной попытки
|
# служим старые данные, пока не истекло время повторной попытки
|
||||||
if (
|
if (
|
||||||
self._data is not None
|
self._data is not None
|
||||||
@@ -249,6 +249,7 @@ 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)
|
||||||
|
|||||||
@@ -495,6 +495,40 @@ 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]:
|
||||||
"""Текст ячейки -> список занятий.
|
"""Текст ячейки -> список занятий.
|
||||||
|
|
||||||
@@ -525,6 +559,8 @@ 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:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Настройки расписания, которые правит администратор.
|
"""Настройки, которые правит администратор.
|
||||||
|
|
||||||
Хранятся в JSON-файле (если задан путь) — так ссылка и интервал переживают
|
Хранятся в JSON-файле (`SETTINGS_FILE`, по умолчанию `data/settings.json` в
|
||||||
перезапуск контейнера. Если путь не задан, настройки живут только в памяти
|
корне проекта), поэтому переживают перезапуск службы: ссылка на таблицу,
|
||||||
(поведение прежних версий, прод можно не трогать).
|
интервал обновления и история входов в админку. Если файл недоступен для
|
||||||
|
записи — настройки живут в памяти, а сервис продолжает работать (об этом
|
||||||
|
пишется предупреждение в лог и в состояние админки).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,7 +13,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .errors import ScheduleError
|
from .errors import ScheduleError
|
||||||
@@ -22,14 +25,40 @@ 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 asdict(self)
|
return {
|
||||||
|
"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:
|
||||||
@@ -39,18 +68,19 @@ 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 clamp_interval(minutes: int) -> int:
|
def settings_path_from_env() -> Path | None:
|
||||||
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
|
raw = (os.getenv("SETTINGS_FILE") or "").strip()
|
||||||
|
return Path(raw) if raw else DEFAULT_SETTINGS_PATH
|
||||||
|
|
||||||
|
|
||||||
class SettingsStore:
|
class SettingsStore:
|
||||||
@@ -64,6 +94,7 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -71,6 +102,10 @@ 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:
|
||||||
@@ -81,6 +116,22 @@ 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()
|
||||||
@@ -110,6 +161,17 @@ 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:
|
||||||
@@ -118,7 +180,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)
|
||||||
@@ -131,6 +193,10 @@ 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,
|
||||||
@@ -149,5 +215,7 @@ 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)
|
||||||
|
|||||||
@@ -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: 720px; margin: 0 auto; padding: 26px 18px 60px; }
|
.admin-wrap { max-width: 760px; 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,49 +40,33 @@
|
|||||||
.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; }
|
||||||
abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
|
.bad { color: var(--accent-red-bright); }
|
||||||
|
.good { color: #2f9e5f; }
|
||||||
|
|
||||||
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
|
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
|
||||||
#toast-wrap {
|
#toast-wrap {
|
||||||
position: fixed;
|
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
|
||||||
left: 50%;
|
z-index: 9999; display: flex; flex-direction: column; gap: 8px;
|
||||||
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;
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
align-items: flex-start;
|
background: var(--surface); border: 1px solid var(--border-2);
|
||||||
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;
|
padding: 12px 14px; font-size: 14px; color: var(--text);
|
||||||
font-size: 14px;
|
white-space: pre-line; animation: toast-in .18s ease-out;
|
||||||
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;
|
flex: 0 0 auto; border: none; background: none; color: var(--muted);
|
||||||
border: none;
|
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px;
|
||||||
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 {
|
||||||
@@ -95,8 +79,9 @@
|
|||||||
<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>
|
||||||
@@ -115,6 +100,13 @@
|
|||||||
<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">
|
||||||
@@ -127,7 +119,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">
|
||||||
@@ -140,8 +132,7 @@
|
|||||||
<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 class="admin-hint">По интервалу расписание перечитывается, только если на Диске изменилась дата файла. Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</div>
|
||||||
Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
@@ -171,7 +162,6 @@
|
|||||||
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");
|
||||||
@@ -208,33 +198,71 @@
|
|||||||
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) {
|
function chip(key, value, cls) {
|
||||||
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>`;
|
||||||
div.querySelector(".v").textContent = value;
|
const v = div.querySelector(".v");
|
||||||
|
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.changed_at)));
|
chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified)));
|
||||||
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
|
|
||||||
chips.appendChild(chip("Групп в расписании", String(state.groups)));
|
chips.appendChild(chip("Групп в расписании", String(state.groups)));
|
||||||
|
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
|
||||||
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) {
|
||||||
@@ -253,7 +281,10 @@
|
|||||||
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) { toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error"); return; }
|
if (!r.ok) {
|
||||||
|
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
showLogin(false);
|
showLogin(false);
|
||||||
renderState(r.data);
|
renderState(r.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки.
|
"""Фронтенд расписания: статики, прокси к API и страница админки.
|
||||||
|
|
||||||
Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py):
|
Страница админки отдаётся по секретному пути из ADMIN_PATH; сам API админки —
|
||||||
сам путь не публикуется, входа с сайта нет.
|
/api/admin/* (проксируется в backend). Если ADMIN_PATH не задан, страницы нет.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,14 +21,32 @@ 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")
|
|
||||||
|
|
||||||
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
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("/")
|
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
||||||
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="Расписание — фронт")
|
||||||
@@ -51,7 +69,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"})
|
||||||
|
|
||||||
|
|
||||||
@@ -144,12 +162,18 @@ 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("/")
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление."""
|
"""Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/force."""
|
||||||
|
|
||||||
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 LOGIN_MAX_FAILURES, AdminConfig, make_token, verify_token
|
from admin import 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
|
||||||
@@ -29,8 +28,8 @@ def cache(monkeypatch, store):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client(cache):
|
def client(cache, store):
|
||||||
app = main.create_app(cache, admin=ADMIN)
|
app = main.create_app(cache, admin=ADMIN, settings=store)
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
@@ -75,6 +74,7 @@ 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,14 +84,6 @@ 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")
|
||||||
@@ -101,7 +93,51 @@ 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):
|
||||||
@@ -112,11 +148,9 @@ 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)])
|
||||||
@@ -134,10 +168,9 @@ 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
|
||||||
state = resp.json()
|
assert resp.json()["link"] == "https://disk.yandex.ru/i/new"
|
||||||
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):
|
||||||
@@ -147,12 +180,10 @@ 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():
|
||||||
if store._path.exists():
|
assert json.loads(store.path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
|
||||||
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):
|
||||||
@@ -167,9 +198,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
|
||||||
@@ -182,15 +213,14 @@ 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):
|
def test_admin_disabled_without_credentials(cache, store):
|
||||||
app = main.create_app(cache, admin=AdminConfig())
|
app = main.create_app(cache, admin=AdminConfig(), settings=store)
|
||||||
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
|
||||||
|
|||||||
@@ -171,11 +171,7 @@ 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()
|
||||||
@@ -183,7 +179,7 @@ def test_modified_unchanged_no_download(cache):
|
|||||||
assert cache.calls["modified"] >= 2
|
assert cache.calls["modified"] >= 2
|
||||||
|
|
||||||
|
|
||||||
def test_state_reports_file_changes(monkeypatch):
|
def test_status_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
|
||||||
@@ -194,25 +190,30 @@ def test_state_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")}
|
files = {"data": xlsx_with_room("215"), "modified": "m1"}
|
||||||
monkeypatch.setattr(cache_mod, "fetch_table_modified", lambda url, timeout=20.0: "m1")
|
monkeypatch.setattr(
|
||||||
|
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=3600, retry_after=60)
|
cache = ScheduleCache("test-link", ttl_seconds=0, retry_after=60)
|
||||||
|
|
||||||
cache.get()
|
cache.get()
|
||||||
first = cache.state()
|
first = cache.status()
|
||||||
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")
|
||||||
cache.force_reload()
|
files["modified"] = "m2"
|
||||||
changed = cache.state()
|
cache.get() # ttl=0 и новый modified — перечитываем таблицу
|
||||||
|
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()
|
# новая ревизия файла с тем же содержимым → скачали, но изменений нет
|
||||||
assert cache.state()["changed"] is False # тот же файл — изменений нет
|
files["modified"] = "m3"
|
||||||
|
cache.get()
|
||||||
|
assert cache.status()["changed"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_cache_stale_while_error(cache):
|
def test_cache_stale_while_error(cache):
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Проверки сборки образов: все нужные файлы должны копироваться.
|
"""Проверки сборки образов: все нужные файлы должны копироваться.
|
||||||
|
|
||||||
Ошибка «в Dockerfile забыли COPY admin.py» роняет контейнер на импорте, а
|
Ошибка «в Dockerfile забыли COPY какого-то модуля» роняет контейнер на импорте,
|
||||||
локальные тесты её не видят (они запускаются из исходников). Ловим статически.
|
а тесты из исходников её не видят (они запускаются из рабочей копии). Ловим
|
||||||
|
статически, без docker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -33,15 +34,18 @@ 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_admin_settings():
|
def test_compose_passes_required_env():
|
||||||
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",
|
||||||
"SCHEDULE_INTERVAL_MINUTES",
|
"ADMIN_MAX_ATTEMPTS",
|
||||||
|
"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, "том для настроек админки не подключён"
|
||||||
|
|||||||
@@ -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,6 +295,53 @@ 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"] == "Химия"
|
||||||
|
|
||||||
|
|
||||||
# --- пустые и экзотические ----------------------------------------------------------
|
# --- пустые и экзотические ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,18 @@ 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"}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Тесты хранилища настроек администратора (ссылка, интервал)."""
|
"""Тесты хранилища настроек админки: ссылка, интервал, история входов."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ 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):
|
||||||
@@ -62,20 +64,46 @@ def test_corrupt_file_is_tolerated(tmp_path):
|
|||||||
assert store.interval_minutes == 7
|
assert store.interval_minutes == 7
|
||||||
|
|
||||||
|
|
||||||
def test_no_path_keeps_settings_in_memory():
|
def test_login_history_saved(tmp_path):
|
||||||
store = SettingsStore(link="l", interval_minutes=3)
|
path = tmp_path / "settings.json"
|
||||||
assert store.set_interval_minutes(10) == 10
|
store = SettingsStore(link="l", path=path)
|
||||||
assert store.interval_minutes == 10
|
|
||||||
assert store.set_link("l2") == "l2"
|
store.record_login("10.0.0.1", ok=False)
|
||||||
assert store.link == "l2"
|
assert store.last_attempt["ip"] == "10.0.0.1"
|
||||||
|
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_does_not_crash(tmp_path):
|
def test_unwritable_path_marks_save_error(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():
|
||||||
|
|||||||
Reference in New Issue
Block a user