Compare commits
9 Commits
master
...
admin-pane
| Author | SHA1 | Date | |
|---|---|---|---|
| a4798c4a36 | |||
| 475eb3801e | |||
| 67a6671d90 | |||
| d177560f7d | |||
| d4b47c30bf | |||
| ab92fb44e1 | |||
| f69003be75 | |||
| e007a85f56 | |||
| cfa6a99639 |
37
.env.example
37
.env.example
@@ -1,30 +1,25 @@
|
||||
# --- расписание --------------------------------------------------------------
|
||||
# Публичная ссылка на Яндекс.Диск с таблицей расписания (обязательна)
|
||||
# --- расписание -------------------------------------------------------------
|
||||
# Публичная ссылка на Яндекс.Диск (обязательна)
|
||||
SCHEDULE_LINK=
|
||||
|
||||
# Как часто перечитывать таблицу, минут
|
||||
# Интервал обновления в минутах (переживает перезапуск: правится в админке)
|
||||
SCHEDULE_INTERVAL_MINUTES=15
|
||||
# Устаревшее: интервал в секундах (используется, только если не задан предыдущий)
|
||||
SCHEDULE_CACHE_TTL=900
|
||||
# Пауза между повторными попытками, если источник недоступен, секунд
|
||||
# Пауза между повторными попытками при сбое источника, секунд
|
||||
SCHEDULE_RETRY_AFTER=60
|
||||
# Устаревшее: интервал в секундах (используется, только если не задан SCHEDULE_INTERVAL_MINUTES)
|
||||
SCHEDULE_CACHE_TTL=900
|
||||
|
||||
# --- порты (только для docker) -------------------------------------------------
|
||||
# Оба порта публикуются на 127.0.0.1 (жёстко задано в docker-compose.yml):
|
||||
# снаружи сервисы отдаёт nginx, напрямую в интернет они не смотрят.
|
||||
API_PORT=8000
|
||||
FRONTEND_PORT=8080
|
||||
|
||||
# --- админка (необязательно) ----------------------------------------------------
|
||||
# Пока эти переменные пустые, админка выключена и наружу ничего не торчит.
|
||||
# Секретный путь страницы админки: придумайте длинный, например /panel-8f3a91x7
|
||||
# --- админка (пусто = выключена) ---------------------------------------------
|
||||
# Секретный путь страницы админки, например /manage-7f3a91
|
||||
ADMIN_PATH=
|
||||
# Логин и пароль администратора
|
||||
ADMIN_LOGIN=
|
||||
ADMIN_PASSWORD=
|
||||
# Сколько неудачных попыток входа допускается и на сколько минут блокировать после них
|
||||
ADMIN_MAX_ATTEMPTS=3
|
||||
ADMIN_LOCKOUT_MINUTES=15
|
||||
# Где хранить настройки админки (ссылка, интервал, история входов).
|
||||
# Пусто = data/settings.json в корне проекта; в docker задаётся автоматически.
|
||||
# Где хранить настройки (ссылка/интервал).
|
||||
# В docker по умолчанию /data/settings.json (том schedule_data);
|
||||
# локально можно оставить пустым — тогда настройки живут в памяти.
|
||||
SETTINGS_FILE=
|
||||
|
||||
# --- порты и адреса ----------------------------------------------------------
|
||||
API_PORT=8000
|
||||
FRONTEND_PORT=8080
|
||||
SCHEDULE_API=http://api:8000
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,9 +6,3 @@ __pycache__/
|
||||
/.idea/
|
||||
**/*.env
|
||||
.env
|
||||
|
||||
# локальные данные инструментов
|
||||
.kilo/
|
||||
|
||||
# состояние админки (ссылка, интервал, история входов)
|
||||
/data/
|
||||
|
||||
66
Makefile
66
Makefile
@@ -1,68 +1,32 @@
|
||||
PY := $(CURDIR)/.venv/bin/python
|
||||
PIP := $(CURDIR)/.venv/bin/pip
|
||||
.PHONY: build up down logs restart deploy ps dev test
|
||||
|
||||
.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: ## Собрать образы
|
||||
build: ## Собрать образы микросервисов
|
||||
docker compose build
|
||||
|
||||
up: ## Запустить сервисы в фоне
|
||||
up: ## Запустить все микросервисы одной командой
|
||||
docker compose up -d
|
||||
|
||||
down: ## Остановить и удалить контейнеры
|
||||
docker compose down
|
||||
|
||||
restart: ## Перезапустить сервисы
|
||||
restart: ## Перезапустить микросервисы
|
||||
docker compose restart
|
||||
|
||||
logs: ## Логи сервисов (Ctrl+C для выхода)
|
||||
logs: ## Логи всех микросервисов
|
||||
docker compose logs -f
|
||||
|
||||
ps: ## Статус сервисов
|
||||
ps: ## Статус микросервисов
|
||||
docker compose ps
|
||||
|
||||
deploy: ## Собрать и запустить (docker)
|
||||
deploy: ## Собрать и запустить (деплой на сервере)
|
||||
./deploy.sh
|
||||
|
||||
# --- nginx (на сервере) ------------------------------------------------------
|
||||
test: ## Прогнать тесты (локальный venv)
|
||||
$(CURDIR)/.venv/bin/python -m pytest tests -q
|
||||
|
||||
# DOMAIN=sched.example.ru make nginx-print / nginx-install
|
||||
nginx-print: ## Показать конфиг nginx (DOMAIN=домен), ничего не меняя
|
||||
deploy/nginx-site.sh --print --domain $(DOMAIN)
|
||||
|
||||
nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (DOMAIN=домен, с sudo)
|
||||
sudo deploy/nginx-site.sh --domain $(DOMAIN)
|
||||
dev: ## Запустить локально без Docker (бэкенд + фронт)
|
||||
@echo "Запуск бэкенда на :8000 и фронта на :8080..."
|
||||
@trap 'kill 0' EXIT; \
|
||||
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 & \
|
||||
wait
|
||||
|
||||
572
README.md
572
README.md
@@ -1,19 +1,8 @@
|
||||
# Расписание
|
||||
|
||||
Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на
|
||||
Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних
|
||||
библиотек и отдаётся как JSON API и как сайт, который можно «установить» на
|
||||
главный экран телефона (PWA).
|
||||
|
||||
Умеет:
|
||||
|
||||
- расписание по группам, преподавателям и аудиториям с разделением
|
||||
числитель/знаменатель и бейджем текущей недели;
|
||||
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
|
||||
- совпадающие недели показываются одним списком, различающиеся — двумя
|
||||
колонками, пустая — «Окно»;
|
||||
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
|
||||
- PWA: манифест, service worker, иконки — «на главный экран».
|
||||
Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица
|
||||
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
|
||||
API и отображается на фронте. Деплой: `schedule.our-place.ru`.
|
||||
|
||||
## Архитектура
|
||||
|
||||
@@ -21,358 +10,20 @@
|
||||
Яндекс.Диск (xlsx)
|
||||
│ скачивание по публичной ссылке
|
||||
▼
|
||||
api — FastAPI, порт 8000
|
||||
• качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree)
|
||||
api (FastAPI), порт 8000, доступ только с localhost
|
||||
• скачивает и разбирает xlsx без сторонних библиотек
|
||||
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
|
||||
• один воркер, поэтому кэш общий
|
||||
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
|
||||
│
|
||||
frontend — FastAPI, порт из .env (по умолчанию 8080)
|
||||
• отдаёт index.html / app.js / style.css / иконки
|
||||
│ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка)
|
||||
frontend (FastAPI: статика + прокси), прод :8090, dev :8080
|
||||
• отдаёт index.html / app.js / style.css / картинки
|
||||
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
|
||||
▲
|
||||
nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта>
|
||||
nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
||||
```
|
||||
|
||||
Роли:
|
||||
|
||||
- **docker** изолирует оба сервиса, перезапускает их при падении
|
||||
(`restart: unless-stopped`) и следит за здоровьем (healthcheck на
|
||||
`/healthz`); frontend стартует, только когда api стал healthy.
|
||||
- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в
|
||||
`docker-compose.yml`): из интернета контейнеры напрямую недоступны.
|
||||
- **nginx** — единственная дверь из интернета: принимает https и проксирует на
|
||||
фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик).
|
||||
|
||||
## Установка на сервер
|
||||
|
||||
### 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 часов.
|
||||
|
||||
### Как защищён путь админки
|
||||
|
||||
- путь задаётся только в `.env` и больше нигде не хранится: ни в HTML, ни в
|
||||
JSON, ни в JS — неавторизованный пользователь его нигде не увидит;
|
||||
- кнопка «Админка» на главной показывается только вошедшему (сервер проверяет
|
||||
cookie и добавляет в страницу лишь флаг, без пути) и ведёт на служебный
|
||||
`/api/admin/goto`, который отвечает редиректом (302) на секретный путь —
|
||||
поэтому путь не попадает в разметку и его не вычитает краулер;
|
||||
- без валидной сессии `/api/admin/*` отвечает `401`, на неизвестных путях сайт
|
||||
отдаёт обычный 404 — перебором путь не находится;
|
||||
- страница панели отдаётся с заголовками `X-Robots-Tag: noindex, nofollow,
|
||||
noarchive`, `Referrer-Policy: no-referrer` (путь не утечёт во внешние ссылки
|
||||
через Referer), `X-Frame-Options: DENY` и `Cache-Control: no-store`;
|
||||
- в `robots.txt` пути нет (там только `Disallow: /api/`) — иначе мы бы сами его
|
||||
опубликовали; внешние ссылки в панели открываются с `rel="noopener noreferrer"`;
|
||||
- при желании путь можно убрать и из логов nginx: добавить в конфиг сайта
|
||||
`location = <ADMIN_PATH> { access_log off; }`.
|
||||
|
||||
Остаётся только история браузера у самого админа — это неизбежно (браузер должен
|
||||
знать, куда идти), поэтому панель лучше открывать с личного устройства, а путь
|
||||
держать в менеджере паролей.
|
||||
|
||||
Настройки (ссылка, интервал, история входов) хранятся в `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 не попадают.
|
||||
|
||||
## Как устроена таблица-источник
|
||||
## Как устроено расписание в таблице
|
||||
|
||||
Лист «Бакалавры и магистры» — это сетка:
|
||||
|
||||
@@ -381,7 +32,9 @@ tests/
|
||||
столбцы 2–3 даты «знаменателя»
|
||||
столбец 4 день недели (указан один раз на блок дня)
|
||||
столбец 5 номер пары и время (ячейка объединена на две строки пары)
|
||||
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
|
||||
столбцы 6+ группы — по одному столбцу на группу, порядок столбцов = порядок
|
||||
групп на сайте (скрытые столбцы пропускаются; имена вида
|
||||
«ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО»)
|
||||
```
|
||||
|
||||
Каждый день занимает блок строк, и строки идут парами: «числитель» /
|
||||
@@ -416,10 +69,7 @@ tests/
|
||||
|
||||
В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
|
||||
новым маркером «к.в.» или началом нового предмета сразу после строки
|
||||
аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с
|
||||
целой строки) от предмета оторвался только преподаватель и/или аудитория, этот
|
||||
кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе
|
||||
в расписании аудитории оставались бы одни группы.
|
||||
аудитории/преподавателя.
|
||||
|
||||
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
|
||||
`lesson = {marker, subject, teacher, room, note}`.
|
||||
@@ -435,29 +85,94 @@ tests/
|
||||
- Длинные ссылки в карточке переносятся по служебным символам и каждые
|
||||
24 символа (`<wbr>`), поэтому карточка не вылезает за экран даже в старых
|
||||
браузерах, где нет `overflow-wrap`.
|
||||
- На вкладке «Аудитории» есть фильтры: **тип** (в карточке — формулировка из
|
||||
таблицы, в фильтре близкие варианты объединены: «комп.класс» включает
|
||||
«комп.класс Linux» и «компьютерный класс», «кабинет английского» —
|
||||
«английский»; есть пункт «без типа»),
|
||||
**вместимость** («не меньше N мест») и **оснащение** чекбоксами с выбором
|
||||
условия «все выбранные (И)» или «любое из выбранных (ИЛИ)». Выбранное
|
||||
запоминается в браузере, под списком видно «Показано N из M» и пояснения:
|
||||
сколько аудиторий скрыто из-за отсутствия данных о вместимости и какие
|
||||
доступны только по отдельным дням.
|
||||
- Там же переключатель «Занятость за день» / **«Свободные на паре»**: во
|
||||
втором режиме выбирается пара и показываются только свободные аудитории —
|
||||
вместе с фильтрами получается «свободная аудитория на 30 человек с
|
||||
проектором в 3-ю пару».
|
||||
- Приложение можно «установить» на главный экран (манифест + service
|
||||
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
|
||||
|
||||
Полный список методов отдаёт сам бэкенд: `GET /`.
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/groups` | список групп (сначала БО, потом МО) |
|
||||
| GET | `/groups` | группы в том порядке, в каком идут столбцы в таблице |
|
||||
| GET | `/schedule/{group}` | расписание группы с разбором на занятия |
|
||||
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
|
||||
| GET | `/today?group={group}` | расписание группы на сегодня |
|
||||
@@ -465,25 +180,96 @@ tests/
|
||||
| GET | `/teacher/{name}` | расписание преподавателя (+ `full_name`) |
|
||||
| GET | `/rooms` | список аудиторий (занятые ∪ лист «аудитории») |
|
||||
| GET | `/room/{label}` | занятость аудитории |
|
||||
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день (+ атрибуты) |
|
||||
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории (+ атрибуты) |
|
||||
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день |
|
||||
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории |
|
||||
| GET | `/week` | недели семестра и текущая чётность |
|
||||
| GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) |
|
||||
| GET | `/status` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) |
|
||||
| GET | `/healthz` | живость процесса (не обращается к таблице) |
|
||||
|
||||
В ответах `rooms-day` и `rooms-free` по каждой аудитории, кроме `room` и `info`
|
||||
(описание как в таблице), идут разобранные атрибуты: `type` (тип фразой из
|
||||
таблицы: «комп.класс», «кабинет английского», «лекционная»…) и `type_group`
|
||||
(тот же тип, но близкие формулировки сведены к одной — «комп.класс»,
|
||||
«комп.класс Linux» и «компьютерный класс» дают группу «комп.класс»),
|
||||
`capacity`
|
||||
(мест; для компьютерных классов — рабочие места; `null`, если в таблице нет
|
||||
данных), `equipment` (список: «ПК», «Проектор», «Интерактивная панель»,
|
||||
«Телевизор», «Маркер», «Меловая доска») и `available_days` (дни, если в
|
||||
описании указано «только четверг, пятница»). Аудитории с ограничением по дням
|
||||
не попадают в `/rooms-free` для остальных дней.
|
||||
|
||||
Фронт проксирует только то, что использует, под префиксом `/api/...`:
|
||||
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
|
||||
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
|
||||
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Где | Описание |
|
||||
|---|---|---|---|
|
||||
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
||||
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | интервал обновления, мин (правится в админке) |
|
||||
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
|
||||
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
|
||||
| `ADMIN_PATH` | — | api+frontend | секретный путь панели (пусто = админка выключена) |
|
||||
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | учётные данные администратора |
|
||||
| `SETTINGS_FILE` | `/data/settings.json` | api | где хранить ссылку и интервал |
|
||||
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
|
||||
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
||||
| `API_PORT` | `8000` | docker | внешний порт api |
|
||||
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) |
|
||||
|
||||
`.env` ищется вверх от каталога модуля (backend — до корня репозитория);
|
||||
`load_dotenv` не перезаписывает уже выставленные переменные окружения.
|
||||
|
||||
## Запуск
|
||||
|
||||
### Продакшен (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
|
||||
./deploy.sh # = docker compose build && up -d
|
||||
```
|
||||
|
||||
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
|
||||
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
|
||||
|
||||
### Разработка
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements-dev.txt
|
||||
make dev # api :8000 и фронт :8080, оба с --reload
|
||||
```
|
||||
|
||||
### Тесты
|
||||
|
||||
```bash
|
||||
make test # 104 теста, сеть не нужна
|
||||
```
|
||||
|
||||
## Развёртывание на сервере
|
||||
|
||||
Код хранится в Gitea (`git.our-place.ru`, web через nginx, git-ssh на порту
|
||||
2222) и живёт в `/opt/schedule`:
|
||||
|
||||
```bash
|
||||
# первый запуск
|
||||
cd /opt/schedule
|
||||
# .env: SCHEDULE_LINK=... , API_PORT=127.0.0.1:8000, FRONTEND_PORT=127.0.0.1:8090
|
||||
sudo docker compose up -d --build
|
||||
|
||||
# обновление
|
||||
cd /opt/schedule && git pull && sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт),
|
||||
сертификат Let's Encrypt. Порты api и frontend слушаются только на loopback.
|
||||
|
||||
### Тестовый стенд
|
||||
|
||||
Новые штуки обкатываются на отдельном стенде `dev.schedule.our-place.ru`
|
||||
(ветка `admin-panel`), прод при этом не меняется: другой каталог
|
||||
(`/opt/schedule-dev`), другие порты (8001/8091), свой том настроек.
|
||||
Пошаговая инструкция — `deploy/README-dev.md`, конфиг nginx —
|
||||
`deploy/nginx-dev.conf`.
|
||||
|
||||
## Замечания
|
||||
|
||||
- Принудительное обновление и смена ссылки живут только в админке
|
||||
(`/api/admin/*` под cookie-сессией): публичного `force` в API нет.
|
||||
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
|
||||
api его придётся выносить в общее хранилище (настройки админки уже в файле).
|
||||
- `ADMIN_PATH` защищает только от «случайного» обнаружения панели; реальная
|
||||
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
|
||||
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
||||
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
|
||||
«нечётное число строк, недели могли сдвинуться».
|
||||
|
||||
87
deploy/README-dev.md
Normal file
87
deploy/README-dev.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Тестовый стенд (dev.schedule.our-place.ru)
|
||||
|
||||
Отдельный стенд для обкатки админки: свой каталог, свои порты, своя база
|
||||
настроек. Прод (`/opt/schedule`, `schedule.our-place.ru`) не трогаем — он
|
||||
продолжает работать как раньше.
|
||||
|
||||
Ветка: `admin-panel`.
|
||||
|
||||
## 1. Код
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/schedule-dev && sudo chown "$USER" /opt/schedule-dev
|
||||
git clone -b admin-panel <адрес репозитория> /opt/schedule-dev
|
||||
```
|
||||
|
||||
Адрес — тот же, что у прода: если прод-клон забирает обновления по SSH,
|
||||
берите SSH-адрес (`ssh://git@git.our-place.ru:2222/Mermutef/schedule.git`),
|
||||
если по HTTPS — HTTPS-адрес с токеном:
|
||||
|
||||
```bash
|
||||
git clone -b admin-panel https://Mermutef:<токен>@git.our-place.ru/Mermutef/schedule.git /opt/schedule-dev
|
||||
```
|
||||
|
||||
## 2. .env
|
||||
|
||||
Скопировать `.env.example` в `/opt/schedule-dev/.env` и заполнить:
|
||||
|
||||
```ini
|
||||
SCHEDULE_LINK=https://disk.yandex.ru/i/...
|
||||
SCHEDULE_INTERVAL_MINUTES=15
|
||||
SCHEDULE_RETRY_AFTER=60
|
||||
ADMIN_PATH=/manage-<секретная-часть>
|
||||
ADMIN_LOGIN=<логин>
|
||||
ADMIN_PASSWORD=<пароль>
|
||||
API_PORT=127.0.0.1:8001
|
||||
FRONTEND_PORT=127.0.0.1:8091
|
||||
SCHEDULE_API=http://api:8000
|
||||
SETTINGS_FILE=/data/settings.json
|
||||
```
|
||||
|
||||
Порты 8001/8091 выбраны, чтобы не пересечься с продом (8000/8090).
|
||||
`ADMIN_PATH` — секретный путь страницы админки: его нужно знать, чтобы
|
||||
попасть в панель; на сайте ссылки на него нет.
|
||||
|
||||
## 3. Запуск
|
||||
|
||||
```bash
|
||||
cd /opt/schedule-dev
|
||||
sudo docker compose up -d --build
|
||||
sudo docker compose ps
|
||||
```
|
||||
|
||||
Настройки админки лежат в томе `schedule-dev_schedule_data` и переживают
|
||||
пересборку; посмотреть можно так:
|
||||
|
||||
```bash
|
||||
sudo docker run --rm -v schedule-dev_schedule_data:/data alpine cat /data/settings.json
|
||||
```
|
||||
|
||||
## 4. nginx и сертификат
|
||||
|
||||
В `deploy/nginx-dev.conf` порт `proxy_pass` должен совпадать с `FRONTEND_PORT`
|
||||
из `.env` (по умолчанию 8091; если порт меняли — поправьте и в конфиге).
|
||||
|
||||
```bash
|
||||
sudo cp /opt/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
|
||||
sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/dev.schedule.our-place.ru
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d dev.schedule.our-place.ru
|
||||
```
|
||||
|
||||
## 5. Проверка
|
||||
|
||||
```bash
|
||||
curl -sI https://dev.schedule.our-place.ru/ | head -3 # 200
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://dev.schedule.our-place.ru/api/admin/state # 401 (нужен вход)
|
||||
```
|
||||
|
||||
Дальше — открыть `https://dev.schedule.our-place.ru/<ADMIN_PATH>` в браузере,
|
||||
войти логином/паролем из `.env` и проверить: смена ссылки, интервал,
|
||||
«Обновить сейчас».
|
||||
|
||||
## Обновление стенда
|
||||
|
||||
```bash
|
||||
cd /opt/schedule-dev && git pull && sudo docker compose up -d --build
|
||||
```
|
||||
47
deploy/nginx-dev.conf
Normal file
47
deploy/nginx-dev.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
# Тестовый стенд dev.schedule.our-place.ru
|
||||
#
|
||||
# Ставится рядом с прод-сайтом schedule.our-place.ru (тот не трогаем):
|
||||
# отдельный каталог /opt/schedule-dev, отдельные порты, отдельная база настроек.
|
||||
#
|
||||
# sudo cp ~/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
|
||||
# sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/
|
||||
# sudo certbot --nginx -d dev.schedule.our-place.ru
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name dev.schedule.our-place.ru;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name dev.schedule.our-place.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/dev.schedule.our-place.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/dev.schedule.our-place.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 8m;
|
||||
|
||||
location / {
|
||||
# порт должен совпадать с FRONTEND_PORT из .env стенда
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
# принудительное обновление расписания из админки — до ~2 минут
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Генерирует и включает конфиг nginx для сайта расписания.
|
||||
#
|
||||
# SSL обеспечивает сертификат родительского домена (обычно wildcard *.example.ru):
|
||||
# отдельный certbot для поддомена не нужен — достаточно имени сертификата.
|
||||
# Список имён: sudo ls /etc/letsencrypt/live/
|
||||
#
|
||||
# Имя сертификата берётся из --cert, иначе — родительский домен от --domain
|
||||
# (sched.example.ru -> example.ru). Порт фронта берётся из --port, иначе из
|
||||
# FRONTEND_PORT файла .env в корне проекта, иначе 8080.
|
||||
#
|
||||
# Примеры:
|
||||
# deploy/nginx-site.sh --print --domain sched.example.ru # показать конфиг
|
||||
# sudo deploy/nginx-site.sh --domain sched.example.ru # поставить и перечитать nginx
|
||||
# sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2
|
||||
}
|
||||
|
||||
die() { echo "Ошибка: $*" >&2; exit 2; }
|
||||
|
||||
DOMAIN=""
|
||||
CERT_NAME=""
|
||||
FRONTEND_PORT=""
|
||||
PRINT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--domain) DOMAIN="$2"; shift 2 ;;
|
||||
--cert) CERT_NAME="$2"; shift 2 ;;
|
||||
--port) FRONTEND_PORT="$2"; shift 2 ;;
|
||||
--print) PRINT=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Неизвестный аргумент: $1 (см. --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n $DOMAIN ]] || die "Укажите домен: --domain sched.example.ru (см. --help)"
|
||||
[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || die "Кривое имя домена: $DOMAIN"
|
||||
if [[ -z $CERT_NAME ]]; then
|
||||
CERT_NAME="${DOMAIN#*.}" # sched.example.ru -> example.ru (wildcard родителя)
|
||||
fi
|
||||
[[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; }
|
||||
|
||||
if [[ -z $FRONTEND_PORT ]]; then
|
||||
# FRONTEND_PORT из .env; значение вида «127.0.0.1:8090» трактуем как «порт после двоеточия»
|
||||
FRONTEND_PORT=$(
|
||||
grep -E '^FRONTEND_PORT=' .env 2>/dev/null | tail -1 | cut -d= -f2- \
|
||||
| tr -d ' "' | awk -F: '{print $NF}'
|
||||
) || FRONTEND_PORT=""
|
||||
fi
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-8080}
|
||||
[[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; }
|
||||
|
||||
read -r -d '' CONFIG <<'TEMPLATE' || true
|
||||
# Сгенерировано deploy/nginx-site.sh — правки вносить в скрипт, не сюда.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name __DOMAIN__;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name __DOMAIN__;
|
||||
|
||||
# сертификат родительского домена (обычно wildcard), обновляет certbot родителя
|
||||
ssl_certificate /etc/letsencrypt/live/__CERT__/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/__CERT__/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 8m;
|
||||
|
||||
location / {
|
||||
# порт FRONTEND_PORT из .env стенда
|
||||
proxy_pass http://127.0.0.1:__PORT__;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
# холодный кэш: бэкенд качает таблицу, до ~2 минут
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
TEMPLATE
|
||||
|
||||
CONFIG=${CONFIG//__DOMAIN__/$DOMAIN}
|
||||
CONFIG=${CONFIG//__CERT__/$CERT_NAME}
|
||||
CONFIG=${CONFIG//__PORT__/$FRONTEND_PORT}
|
||||
|
||||
if [[ $PRINT == 1 ]]; then
|
||||
printf '%s\n' "$CONFIG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Установка требует root: sudo deploy/nginx-site.sh (посмотреть конфиг — deploy/nginx-site.sh --print)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEST="/etc/nginx/sites-available/${DOMAIN}"
|
||||
printf '%s\n' "$CONFIG" > "$DEST"
|
||||
ln -sf "$DEST" "/etc/nginx/sites-enabled/${DOMAIN}"
|
||||
|
||||
if nginx -t 2>&1; then
|
||||
systemctl reload nginx
|
||||
echo "Готово: https://${DOMAIN} -> 127.0.0.1:${FRONTEND_PORT} (сертификат ${CERT_NAME})"
|
||||
else
|
||||
echo "nginx -t не прошёл: конфиг записан в ${DEST} и включён, но nginx НЕ перечитан. Разберитесь и выполните: sudo nginx -t && sudo systemctl reload nginx" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -5,22 +5,19 @@ services:
|
||||
target: api
|
||||
environment:
|
||||
SCHEDULE_LINK: ${SCHEDULE_LINK}
|
||||
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
|
||||
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
|
||||
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
|
||||
# админка: пусто = выключена (страница и /api/admin/* не появятся)
|
||||
# админка: секретный путь панели и учётные данные (пусто = выключена)
|
||||
ADMIN_PATH: ${ADMIN_PATH:-}
|
||||
ADMIN_LOGIN: ${ADMIN_LOGIN:-}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
ADMIN_MAX_ATTEMPTS: ${ADMIN_MAX_ATTEMPTS:-3}
|
||||
ADMIN_LOCKOUT_MINUTES: ${ADMIN_LOCKOUT_MINUTES:-15}
|
||||
# настройки админки (ссылка, интервал, история входов) живут в томе
|
||||
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
|
||||
# настройки админки переживают пересборку благодаря тому /data
|
||||
SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json}
|
||||
volumes:
|
||||
- schedule_data:/data
|
||||
ports:
|
||||
# api нужен только фронтенду внутри docker-сети; наружу — 127.0.0.1 для отладки
|
||||
- "127.0.0.1:${API_PORT:-8000}:8000"
|
||||
- "${API_PORT:-8000}:8000"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"]
|
||||
@@ -34,13 +31,10 @@ services:
|
||||
context: .
|
||||
target: frontend
|
||||
environment:
|
||||
# внутри compose api доступен по имени сервиса; менять не нужно
|
||||
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
|
||||
# страница админки отдаётся фронтендом по этому пути
|
||||
ADMIN_PATH: ${ADMIN_PATH:-}
|
||||
ports:
|
||||
# фронт обслуживает nginx; наружу напрямую не смотрит
|
||||
- "127.0.0.1:${FRONTEND_PORT:-8080}:8080"
|
||||
- "${FRONTEND_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
httpx2==2.12.0
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
"""Админка расписания.
|
||||
|
||||
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
||||
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они
|
||||
пусты, админка выключена и маршруты /api/admin/* не регистрируются.
|
||||
|
||||
После нескольких неудачных попыток вход блокируется на время из
|
||||
ADMIN_LOCKOUT_MINUTES (по умолчанию: 3 попытки и 15 минут). Каждая попытка
|
||||
запоминается (откуда, когда, успешна ли) — это видно в панели и пишется в лог.
|
||||
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После
|
||||
входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние
|
||||
между запросами не хранится.
|
||||
|
||||
Что умеет администратор:
|
||||
GET /api/admin/state — состояние кэша, ссылка, интервал, история входов;
|
||||
POST /api/admin/link — проверить и поставить новую ссылку на таблицу;
|
||||
GET /api/admin/state — текущая ссылка, интервал, состояние кэша;
|
||||
POST /api/admin/link — проверить и поставить новую ссылку;
|
||||
POST /api/admin/interval — интервал обновления в минутах;
|
||||
POST /api/admin/refresh — принудительно перечитать таблицу сейчас.
|
||||
POST /api/admin/refresh — принудительно обновить расписание сейчас.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,33 +22,62 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from schedule import ScheduleCache, ScheduleError
|
||||
from schedule.settings import SettingsStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
COOKIE_NAME = "schedule_admin"
|
||||
SESSION_TTL_SECONDS = 12 * 3600
|
||||
|
||||
DEFAULT_MAX_ATTEMPTS = 3
|
||||
DEFAULT_LOCKOUT_MINUTES = 15
|
||||
# защита от перебора пароля: не больше N неудач с одного адреса за окно
|
||||
LOGIN_MAX_FAILURES = 10
|
||||
LOGIN_WINDOW_SECONDS = 300.0
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Простейший счётчик неудачных попыток входа по IP."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_failures: int = LOGIN_MAX_FAILURES,
|
||||
window: float = LOGIN_WINDOW_SECONDS,
|
||||
) -> None:
|
||||
self._max = max_failures
|
||||
self._window = window
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
|
||||
def _recent(self, key: str, now: float) -> list[float]:
|
||||
return [ts for ts in self._failures.get(key, []) if now - ts < self._window]
|
||||
|
||||
def blocked(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
recent = self._recent(key, now)
|
||||
self._failures[key] = recent
|
||||
return len(recent) >= self._max
|
||||
|
||||
def register_failure(self, key: str) -> None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._failures[key] = [*self._recent(key, now), now]
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminConfig:
|
||||
"""Настройки админки: секретный путь, учётные данные и защита от перебора."""
|
||||
"""Настройки админки: секретный путь и учётные данные из .env."""
|
||||
|
||||
path: str = ""
|
||||
login: str = ""
|
||||
password: str = ""
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
||||
lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -72,7 +98,9 @@ def _same(left: str, right: str) -> bool:
|
||||
|
||||
|
||||
def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str:
|
||||
payload = json.dumps({"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")).encode()
|
||||
payload = json.dumps(
|
||||
{"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")
|
||||
).encode()
|
||||
sig = hmac.new(password.encode(), payload, hashlib.sha256).digest()
|
||||
return f"{_b64e(payload)}.{_b64e(sig)}"
|
||||
|
||||
@@ -89,46 +117,6 @@ def verify_token(token: str, password: str) -> bool:
|
||||
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):
|
||||
login: str
|
||||
password: str
|
||||
@@ -142,30 +130,9 @@ class IntervalBody(BaseModel):
|
||||
interval_minutes: int
|
||||
|
||||
|
||||
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:
|
||||
def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
throttle = LoginThrottle(config.max_attempts, config.lockout_minutes)
|
||||
throttle = LoginThrottle()
|
||||
|
||||
def require_auth(request: Request) -> None:
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
@@ -176,55 +143,24 @@ def create_admin_router(
|
||||
proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme
|
||||
return proto.split(",")[0].strip() == "https"
|
||||
|
||||
def _remember(ip: str, ok: bool) -> None:
|
||||
if settings is not None:
|
||||
settings.record_login(ip, ok)
|
||||
def _client_key(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
@router.post("/login")
|
||||
def login(body: LoginBody, request: Request, response: Response) -> dict:
|
||||
ip = _client_ip(request)
|
||||
until = throttle.locked_until(ip)
|
||||
if until is not None:
|
||||
minutes = max(1, round((until - time.time()) / 60))
|
||||
log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes)
|
||||
key = _client_key(request)
|
||||
if throttle.blocked(key):
|
||||
log.warning("Вход в админку: слишком много неудачных попыток с %s", key)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Слишком много неудачных попыток. Вход заблокирован ещё на ~{minutes} мин.",
|
||||
status_code=429, detail="Слишком много попыток входа, попробуйте позже"
|
||||
)
|
||||
ok_login = _same(body.login.strip(), config.login)
|
||||
ok_password = _same(body.password, config.password)
|
||||
if not (ok_login and ok_password):
|
||||
throttle.register_failure(ip)
|
||||
_remember(ip, False)
|
||||
left = throttle.attempts_left(ip)
|
||||
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)
|
||||
throttle.register_failure(key)
|
||||
log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40])
|
||||
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
||||
throttle.reset(key)
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
make_token(config.login, config.password),
|
||||
@@ -234,6 +170,7 @@ def create_admin_router(
|
||||
secure=_secure(request),
|
||||
path="/",
|
||||
)
|
||||
log.info("Вход в админку выполнен")
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/logout")
|
||||
@@ -242,33 +179,8 @@ def create_admin_router(
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/state", dependencies=[Depends(require_auth)])
|
||||
def state(request: Request) -> dict:
|
||||
ip = _client_ip(request)
|
||||
until = throttle.locked_until(ip)
|
||||
payload = cache.state()
|
||||
# путь к панели здесь НЕ отдаём: он не должен попадать ни в JSON, ни в
|
||||
# HTML — переход в панель идёт через /api/admin/goto (редирект)
|
||||
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.get("/goto", dependencies=[Depends(require_auth)])
|
||||
def goto() -> Response:
|
||||
"""Переход в панель.
|
||||
|
||||
Так секретный путь не хранится в разметке главной страницы: кнопка
|
||||
«Админка» дёргает этот адрес, и браузер уже получает редирект.
|
||||
Без валидной сессии ответ — 401, то есть ботам тут ничего не видно.
|
||||
"""
|
||||
if not config.path:
|
||||
raise HTTPException(status_code=500, detail="ADMIN_PATH не задан")
|
||||
return RedirectResponse(
|
||||
url=config.path, status_code=302, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
def state() -> dict:
|
||||
return cache.state()
|
||||
|
||||
@router.post("/refresh", dependencies=[Depends(require_auth)])
|
||||
def refresh() -> dict:
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
"""API-приложение расписания (FastAPI).
|
||||
|
||||
Кроме публичных данных здесь живёт админка (backend/admin.py): она включается
|
||||
только если заданы ADMIN_LOGIN и ADMIN_PASSWORD, и открывается по секретному
|
||||
пути ADMIN_PATH. Все параметры — переменные окружения, см. .env.example.
|
||||
"""
|
||||
"""API-приложение расписания (FastAPI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,7 +22,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
|
||||
|
||||
def _find_dotenv() -> Path | None:
|
||||
"""Ищем .env вверх от каталога модуля (общий .env в корне проекта)."""
|
||||
"""Ищем .env вверх от каталога модуля (локальная разработка)."""
|
||||
path = Path(__file__).resolve().parent
|
||||
for _ in range(5):
|
||||
env = path / ".env"
|
||||
@@ -54,25 +49,12 @@ def _normalize_admin_path(raw: str | None) -> str:
|
||||
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()
|
||||
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
|
||||
SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip()
|
||||
ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH"))
|
||||
ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip()
|
||||
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:
|
||||
@@ -108,17 +90,10 @@ def _day_arg(day: str) -> str:
|
||||
def create_app(
|
||||
cache: ScheduleCache | None = None,
|
||||
admin: AdminConfig | None = None,
|
||||
settings: SettingsStore | None = None,
|
||||
) -> FastAPI:
|
||||
if cache is None:
|
||||
settings = settings or default_settings()
|
||||
cache = ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
|
||||
cache = cache or default_cache()
|
||||
admin = admin if admin is not None else AdminConfig(
|
||||
path=ADMIN_PATH,
|
||||
login=ADMIN_LOGIN,
|
||||
password=ADMIN_PASSWORD,
|
||||
max_attempts=ADMIN_MAX_ATTEMPTS,
|
||||
lockout_minutes=ADMIN_LOCKOUT_MINUTES,
|
||||
path=ADMIN_PATH, login=ADMIN_LOGIN, password=ADMIN_PASSWORD
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -135,13 +110,13 @@ def create_app(
|
||||
app.state.admin = admin
|
||||
|
||||
if admin.enabled:
|
||||
app.include_router(create_admin_router(cache, settings, admin))
|
||||
app.include_router(create_admin_router(cache, admin))
|
||||
if admin.path:
|
||||
log.info("Админка включена (путь задан в ADMIN_PATH)")
|
||||
log.info("Админка включена (секретный путь задан)")
|
||||
else:
|
||||
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
|
||||
else:
|
||||
log.info("Админка выключена (не заданы ADMIN_LOGIN/ADMIN_PASSWORD)")
|
||||
log.info("Админка выключена: не заданы ADMIN_LOGIN/ADMIN_PASSWORD")
|
||||
|
||||
def _data() -> tuple[dict, list[dict]]:
|
||||
try:
|
||||
@@ -173,7 +148,7 @@ def create_app(
|
||||
return {
|
||||
"title": "Расписание",
|
||||
"endpoints": {
|
||||
"/groups": "Список групп (в порядке столбцов таблицы)",
|
||||
"/groups": "Список групп",
|
||||
"/schedule/{group}": "Расписание группы",
|
||||
"/schedule/{group}/{day}": "Расписание группы на день",
|
||||
"/today?group={group}": "Расписание группы на сегодня",
|
||||
@@ -264,14 +239,10 @@ def create_app(
|
||||
room_info = cache.room_info
|
||||
free = []
|
||||
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
|
||||
info = room_info.get(label, {})
|
||||
# аудитория может быть доступна только по отдельным дням («402 четверг пятница»)
|
||||
if info.get("available_days") and normalized not in info["available_days"]:
|
||||
continue
|
||||
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
|
||||
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
|
||||
if not busy:
|
||||
free.append(parsing.room_payload(label, info))
|
||||
free.append({"room": label, "info": room_info.get(label, {}).get("info", "")})
|
||||
return free
|
||||
|
||||
@app.get("/rooms-day")
|
||||
@@ -282,7 +253,7 @@ def create_app(
|
||||
day_times = cache.get_day_times().get(normalized, {})
|
||||
rooms_out = []
|
||||
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
|
||||
info = room_info.get(label, {})
|
||||
info = room_info.get(label, {}).get("info", "")
|
||||
occupied_day = rooms_map.get(label, {}).get(normalized, {})
|
||||
periods = {}
|
||||
for period in sorted(set(occupied_day) | set(day_times), key=int):
|
||||
@@ -292,9 +263,7 @@ def create_app(
|
||||
"ч": slot.get("ч") or [],
|
||||
"з": slot.get("з") or [],
|
||||
}
|
||||
room = parsing.room_payload(label, info)
|
||||
room["periods"] = periods
|
||||
rooms_out.append(room)
|
||||
rooms_out.append({"room": label, "info": info, "periods": periods})
|
||||
return {"day": normalized, "rooms": rooms_out}
|
||||
|
||||
@app.get("/week")
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
||||
(вместо скачивания на каждый запрос);
|
||||
- настройки администратора (ссылка, интервал) живут в SettingsStore и
|
||||
применяются на лету: смена ссылки проверяется до сохранения.
|
||||
применяются на лету: смена ссылки проверяется до сохранения, принудительное
|
||||
обновление идёт вне расписания.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -100,24 +101,22 @@ class ScheduleCache:
|
||||
return self._day_times
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Состояние кэша (публичный /status): без ссылки и без админских данных."""
|
||||
with self._lock:
|
||||
return self._status()
|
||||
return {
|
||||
"cache_ttl_seconds": self._ttl,
|
||||
"interval_minutes": self.interval_minutes,
|
||||
"last_updated": self._iso(self._loaded_at),
|
||||
"table_modified": self._modified,
|
||||
"error": self._error,
|
||||
}
|
||||
|
||||
def state(self) -> dict:
|
||||
"""Состояние для админки: то же плюс текущая ссылка и настройки."""
|
||||
"""Полное состояние для админки."""
|
||||
with self._lock:
|
||||
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
|
||||
return self._state()
|
||||
|
||||
def warmup(self) -> None:
|
||||
"""Первая загрузка данных на старте приложения (ошибка не фатальна)."""
|
||||
"""Первый загруз данных на старте приложения (ошибка не фатальна)."""
|
||||
try:
|
||||
self.get()
|
||||
except ScheduleError as exc:
|
||||
@@ -126,7 +125,7 @@ class ScheduleCache:
|
||||
# --- действия администратора ------------------------------------------------
|
||||
|
||||
def force_reload(self) -> dict:
|
||||
"""Принудительно обновить расписание: скачиваем таблицу в любом случае."""
|
||||
"""Принудительно обновить расписание вне интервала (кнопка в админке)."""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
modified = fetch_table_modified(self._link) if self._link else None
|
||||
@@ -137,7 +136,7 @@ class ScheduleCache:
|
||||
self._last_failed = now
|
||||
log.warning("Принудительное обновление не удалось: %s", exc)
|
||||
raise
|
||||
return self.state()
|
||||
return self._state()
|
||||
|
||||
def apply_link(self, link: str) -> dict:
|
||||
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
|
||||
@@ -162,11 +161,11 @@ class ScheduleCache:
|
||||
raise ScheduleError(f"Ссылка не подошла: {exc}") from exc
|
||||
if self._settings is not None:
|
||||
self._settings.set_link(link)
|
||||
return self.state()
|
||||
return self._state()
|
||||
|
||||
def set_interval_minutes(self, minutes: int | str) -> int:
|
||||
if self._settings is None:
|
||||
raise ScheduleError("Настройки не подключены: интервал задаётся в .env")
|
||||
raise ScheduleError("Настройки не подключены: интервал задаётся через .env")
|
||||
value = self._settings.set_interval_minutes(minutes)
|
||||
with self._lock:
|
||||
self._ttl = value * 60
|
||||
@@ -181,15 +180,16 @@ class ScheduleCache:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
|
||||
|
||||
def _status(self) -> dict:
|
||||
def _state(self) -> dict:
|
||||
return {
|
||||
"cache_ttl_seconds": self._ttl,
|
||||
"link": self._link,
|
||||
"interval_minutes": max(1, round(self._ttl / 60)),
|
||||
"last_updated": self._iso(self._loaded_at),
|
||||
"table_modified": self._modified,
|
||||
"error": self._error,
|
||||
"groups": len(self._data) if self._data else 0,
|
||||
"has_data": self._data is not None,
|
||||
# при последнем чтении файла: изменилось ли его содержимое
|
||||
"changed": self._changed,
|
||||
"changed_at": self._iso(self._changed_at),
|
||||
"content_hash": (self._content_hash or "")[:12],
|
||||
@@ -205,9 +205,9 @@ class ScheduleCache:
|
||||
)
|
||||
if fresh:
|
||||
return
|
||||
self._refresh(now)
|
||||
self._reload(now)
|
||||
|
||||
def _refresh(self, now: float) -> None:
|
||||
def _reload(self, now: float) -> None:
|
||||
# служим старые данные, пока не истекло время повторной попытки
|
||||
if (
|
||||
self._data is not None
|
||||
@@ -249,7 +249,6 @@ class ScheduleCache:
|
||||
digest = hashlib.sha256(xlsx).hexdigest()
|
||||
first_load = self._content_hash is None
|
||||
changed = not first_load and digest != self._content_hash
|
||||
|
||||
sheets = parse_workbook(xlsx)
|
||||
data = parsing.parse_schedule(sheets)
|
||||
weeks = parsing.extract_week_ranges(sheets)
|
||||
|
||||
@@ -159,25 +159,6 @@ def _line_is_room(line: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
_PATRONYMIC_END = re.compile(r"(вич|вна|ична|инична)$", re.I)
|
||||
_SURNAME_END = re.compile(r"(ов|ев|ёв|ин|ын|ский|цкий|ая|ко|ук|юк)$", re.I)
|
||||
|
||||
|
||||
def _looks_like_person(text: str) -> bool:
|
||||
"""Похоже ли «Имя Отчество Фамилия» на ФИО (а не на название предмета).
|
||||
|
||||
Для трёх слов требуем отчество (-вич/-вна), для двух — «фамильное»
|
||||
окончание хотя бы у одного слова: иначе «Машинное Обучение» уедет
|
||||
в преподаватели.
|
||||
"""
|
||||
parts = [part for part in text.split() if part]
|
||||
if len(parts) >= 3:
|
||||
return any(_PATRONYMIC_END.search(part) for part in parts[1:])
|
||||
if len(parts) == 2:
|
||||
return bool(_SURNAME_END.search(parts[0]) or _SURNAME_END.search(parts[1]))
|
||||
return False
|
||||
|
||||
|
||||
def _classify_line(line: str) -> _LineRoles:
|
||||
roles = _LineRoles()
|
||||
|
||||
@@ -278,13 +259,11 @@ def _classify_line(line: str) -> _LineRoles:
|
||||
if m:
|
||||
roles.subject, roles.teacher = m.group(1).strip(), m.group(2).strip()
|
||||
return roles
|
||||
# «Предмет Денис Верховцев (Сигма)» / «Предмет Денис Сергеевич Верховцев (Сигма)» —
|
||||
# ФИО в конце строки, может быть из двух или трёх слов
|
||||
# «Предмет Денис Верховцев (Сигма)» — ФИО без отчества в конце строки
|
||||
m = re.search(
|
||||
r"^(.*?\s)([А-ЯЁ][а-яё]{2,}(?:\s+[А-ЯЁ][а-яё]{2,}){1,2})(\s*\([^()]*\))?\s*$",
|
||||
subject,
|
||||
r"^(.*\s)([А-ЯЁ][а-яё]{2,}\s+[А-ЯЁ][а-яё]{2,})(\s*\([^()]*\))?\s*$", subject
|
||||
)
|
||||
if m and len(subject.split()) >= 4 and _looks_like_person(m.group(2)):
|
||||
if m and len(subject.split()) >= 4:
|
||||
teacher = _squish(m.group(2) + " " + (m.group(3) or ""))
|
||||
roles.subject, roles.teacher = m.group(1).strip(), teacher
|
||||
return roles
|
||||
@@ -516,40 +495,6 @@ def _normalize_marker_of(line: str) -> Optional[str]:
|
||||
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]:
|
||||
"""Текст ячейки -> список занятий.
|
||||
|
||||
@@ -580,8 +525,6 @@ def parse_lesson_cell(cell_text: str) -> list[Lesson]:
|
||||
lesson = replace(lesson, marker=last_marker)
|
||||
lessons.append(lesson)
|
||||
|
||||
lessons = _merge_detached_fragments(lessons)
|
||||
|
||||
if not cell_notes:
|
||||
return lessons
|
||||
if not lessons:
|
||||
@@ -600,34 +543,6 @@ def is_teacher_name(text: str) -> bool:
|
||||
return bool(_NAME_INITIALS_RE.search(text) or _FULL_NAME_RE.match(text.strip()))
|
||||
|
||||
|
||||
# 2-3 слова с большой буквы — ФИО в любом порядке (без «НПО Криста»: там Криста с большой,
|
||||
# но первое слово «НПО» — аббревиатура и не подходит под [А-ЯЁ][а-яё]{2,})
|
||||
_FULL_NAME_WORDS_RE = re.compile(r"^[А-ЯЁ][а-яё]{2,}(?:\s+[А-ЯЁ][а-яё]{2,}){1,2}$")
|
||||
|
||||
|
||||
def _short_from_parts(parts: list[str]) -> str:
|
||||
"""ФИО -> «Фамилия И.О.» с учётом порядка слов.
|
||||
|
||||
В таблице встречаются оба порядка: «Верховцев Денис Сергеевич» и
|
||||
«Денис Сергеевич Верховцев». Фамилию определяем по отчеству (-вич/-вна),
|
||||
а для двух слов — по «фамильному» окончанию.
|
||||
"""
|
||||
if len(parts) >= 3 and _PATRONYMIC_END.search(parts[1]):
|
||||
surname, given, patronymic = parts[2], parts[0], parts[1]
|
||||
elif len(parts) == 2 and _SURNAME_END.search(parts[1]) and not _SURNAME_END.search(parts[0]):
|
||||
surname, given, patronymic = parts[1], parts[0], ""
|
||||
else:
|
||||
surname = parts[0]
|
||||
given = parts[1] if len(parts) > 1 else ""
|
||||
patronymic = parts[2] if len(parts) > 2 else ""
|
||||
short = surname
|
||||
if given:
|
||||
short += f" {given[0].upper()}."
|
||||
if patronymic:
|
||||
short += f"{patronymic[0].upper()}."
|
||||
return short
|
||||
|
||||
|
||||
def extract_teacher_names(teacher: str) -> list[str]:
|
||||
"""Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса).
|
||||
|
||||
@@ -639,9 +554,16 @@ def extract_teacher_names(teacher: str) -> list[str]:
|
||||
names = [m.strip() for m in _NAME_INITIALS_RE.findall(teacher) if m.strip()]
|
||||
if not names:
|
||||
full = re.sub(r"\s*\([^()]*\)", "", teacher).strip()
|
||||
full_m = _FULL_NAME_RE.match(full) or _FULL_NAME_WORDS_RE.match(full)
|
||||
full_m = _FULL_NAME_RE.match(full)
|
||||
if full_m:
|
||||
return [_short_from_parts(full_m.group(0).split())]
|
||||
if len(teacher) <= 30 and not re.search(r"\d", teacher):
|
||||
return [teacher] # организация: «ПравоТех», «НПО Криста»
|
||||
parts = full_m.group(0).split()
|
||||
short = parts[0] + " " + parts[1][0].upper() + "."
|
||||
if len(parts) > 2:
|
||||
short += parts[2][0].upper() + "."
|
||||
return [short]
|
||||
if (
|
||||
not re.search(r"\d|\s", teacher)
|
||||
or (len(teacher) <= 30 and " " in teacher)
|
||||
) and not re.search(r"[а-яё][А-ЯЁ]", teacher):
|
||||
return [teacher] # организация одним словом или «НПО Криста»
|
||||
return names
|
||||
|
||||
@@ -83,9 +83,7 @@ def extract_week_ranges(sheets: dict[str, Worksheet]) -> list[dict]:
|
||||
return []
|
||||
ranges: list[dict] = []
|
||||
seen: set[tuple[str, date]] = set()
|
||||
for row_idx, row in enumerate(sheet.rows[1:], start=1):
|
||||
if row_idx in sheet.hidden_rows:
|
||||
continue # скрытые строки не парсим
|
||||
for row in sheet.rows[1:]:
|
||||
if not any(row[i] for i in range(4)):
|
||||
continue
|
||||
for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)):
|
||||
@@ -150,9 +148,7 @@ def parse_schedule(sheets: dict[str, Worksheet]) -> dict:
|
||||
parity: str | None = None
|
||||
rows_in_day = 0
|
||||
|
||||
for row_idx, row in enumerate(rows[1:], start=1):
|
||||
if row_idx in sheet.hidden_rows:
|
||||
continue # скрытые строки не парсим
|
||||
for row in rows[1:]:
|
||||
if not any(row[i] for i in range(4)):
|
||||
continue
|
||||
day = _normalize_header(row[_COL_DAY]).lower()
|
||||
@@ -348,182 +344,26 @@ def build_room_schedule(rooms: dict, label: str, day_times: dict | None = None)
|
||||
# --- вспомогательные листы ------------------------------------------------------
|
||||
|
||||
|
||||
# --- атрибуты аудиторий из строки описания ------------------------------------
|
||||
# В листе «аудитории» под номером лежит описание вида
|
||||
# «комп.класс МО и СТ; без проектора; маркер; 12 мест» (части через «;», но
|
||||
# встречаются и запятые). Разбираем его в тип, вместимость, оснащение и дни.
|
||||
|
||||
# Тип берём фразой как в таблице (порядок — от специфичных к общим).
|
||||
_ROOM_TYPE_PATTERNS = [
|
||||
re.compile(r"комп\.?\s*класс(?:\s+(?:Linux|Windows|OS)(?:\s*[/,]\s*(?:Linux|Windows|OS))*)?", re.I),
|
||||
re.compile(
|
||||
r"компьютерн\w*\s+класс(?:\s+(?:Linux|Windows|OS)(?:\s*[/,]\s*(?:Linux|Windows|OS))*)?",
|
||||
re.I,
|
||||
),
|
||||
re.compile(r"кабинет\s+англ\w*", re.I),
|
||||
re.compile(r"лекционно-практическая", re.I),
|
||||
re.compile(r"лекционная", re.I),
|
||||
re.compile(r"аудитория\s+для\s+практических", re.I),
|
||||
re.compile(r"\bангл\w*", re.I),
|
||||
re.compile(r"ауд\.?\s*каф\.?\s*[А-ЯA-Z]{0,4}\.?", re.I),
|
||||
]
|
||||
# Оснащение: тег -> как искать в тексте.
|
||||
_ROOM_EQUIPMENT = [
|
||||
("ПК", re.compile(r"\bпк\b|комп\.?\s*класс|компьютерн", re.I)),
|
||||
("Проектор", re.compile(r"проектор", re.I)),
|
||||
("Интерактивная панель", re.compile(r"интерактивн", re.I)),
|
||||
("Телевизор", re.compile(r"телевизор", re.I)),
|
||||
("Маркер", re.compile(r"маркер", re.I)),
|
||||
("Меловая доска", re.compile(r"мелов", re.I)), # «меловая доска», «мелова доска»
|
||||
]
|
||||
# Корни дней недели: «четверг», «по пятницам» и т.п.
|
||||
_WEEKDAY_STEMS = [
|
||||
("понедельник", "понедельник"),
|
||||
("вторник", "вторник"),
|
||||
("среда", "сред"),
|
||||
("четверг", "четверг"),
|
||||
("пятница", "пятниц"),
|
||||
("суббота", "суббот"),
|
||||
("воскресенье", "воскресен"),
|
||||
]
|
||||
|
||||
_ROOM_PART_SPLIT = re.compile(r"[;,]")
|
||||
_NEGATION_RE = re.compile(r"\bбез\b\s*(.*)", re.I)
|
||||
|
||||
|
||||
_WORD_RE = re.compile(r"[A-Za-zА-ЯЁа-яё]+")
|
||||
|
||||
|
||||
def lower_except_abbr(text: str) -> str:
|
||||
"""Слова в нижний регистр; аббревиатуры («ПК», «МО», «СТ») остаёмся заглавными."""
|
||||
def repl(match: re.Match) -> str:
|
||||
word = match.group(0)
|
||||
return word if len(word) >= 2 and word.isupper() else word.lower()
|
||||
|
||||
return _WORD_RE.sub(repl, text)
|
||||
|
||||
|
||||
def room_capacity(text: str) -> int | None:
|
||||
"""Вместимость: «N мест», «N раб.» (рабочие места в комп.классе)."""
|
||||
m = re.search(r"(\d+)\s*мест", text, re.I)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
m = re.search(r"(\d+)\s*раб", text, re.I) # «12 раб. + 1 осн.» — считаем рабочие
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def room_type(text: str) -> str:
|
||||
"""Тип аудитории фразой из таблицы; пустая строка, если типа нет."""
|
||||
for pattern in _ROOM_TYPE_PATTERNS:
|
||||
m = pattern.search(text)
|
||||
if m:
|
||||
return lower_except_abbr(re.sub(r"\s{2,}", " ", m.group(0).strip(" .,")))
|
||||
return ""
|
||||
|
||||
|
||||
def room_type_group(type_text: str) -> str:
|
||||
"""Общий тип для фильтра: близкие формулировки сводим к одной.
|
||||
|
||||
«комп.класс», «комп.класс Linux», «компьютерный класс» -> «комп.класс»;
|
||||
«английский», «кабинет английского» -> «кабинет английского».
|
||||
В карточке при этом остаётся формулировка из таблицы.
|
||||
"""
|
||||
low = (type_text or "").lower()
|
||||
if not low:
|
||||
return ""
|
||||
if "комп" in low and "класс" in low:
|
||||
return "комп.класс"
|
||||
if "англ" in low:
|
||||
return "кабинет английского"
|
||||
if "каф" in low:
|
||||
return "аудитория кафедры"
|
||||
if low.startswith("лекционно"):
|
||||
return "лекционно-практическая"
|
||||
if low.startswith("лекционн"):
|
||||
return "лекционная"
|
||||
if "практическ" in low:
|
||||
return "аудитория для практических"
|
||||
return type_text
|
||||
|
||||
|
||||
def room_equipment(text: str) -> list[str]:
|
||||
"""Оснащение тегами; «без проектора» означает, что проектора нет."""
|
||||
negated: set[str] = set()
|
||||
for part in _ROOM_PART_SPLIT.split(text):
|
||||
m = _NEGATION_RE.search(part)
|
||||
if not m:
|
||||
continue
|
||||
for tag, pattern in _ROOM_EQUIPMENT:
|
||||
if pattern.search(m.group(1)):
|
||||
negated.add(tag)
|
||||
return [
|
||||
lower_except_abbr(tag)
|
||||
for tag, pattern in _ROOM_EQUIPMENT
|
||||
if tag not in negated and pattern.search(text)
|
||||
]
|
||||
|
||||
|
||||
def room_weekdays(text: str) -> list[str] | None:
|
||||
"""Дни, если в описании перечислены («402 четверг пятница») — иначе None."""
|
||||
low = text.lower()
|
||||
found = [name for name, stem in _WEEKDAY_STEMS if stem in low]
|
||||
return found or None
|
||||
|
||||
|
||||
def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict:
|
||||
"""Лист «аудитории»: 'ауд. N' -> атрибуты аудитории.
|
||||
|
||||
{'room', 'info' (как в таблице), 'type', 'capacity', 'equipment', 'available_days'}
|
||||
"""
|
||||
"""Лист «аудитории»: 'ауд. N' -> {'room', 'info'} (оснащение, вместимость)."""
|
||||
sheet = sheets.get(ROOMS_SHEET)
|
||||
if sheet is None or len(sheet.rows) < 2:
|
||||
return {}
|
||||
header, desc = sheet.rows[0], sheet.rows[1]
|
||||
info: dict = {}
|
||||
|
||||
def add(col: int) -> None:
|
||||
for col in range(_FIRST_GROUP_COL, max(len(header), len(desc))):
|
||||
m = re.search(r"(\d+)", str(header[col]))
|
||||
if not m:
|
||||
return
|
||||
label = f"ауд. {m.group(1)}"
|
||||
if label in info:
|
||||
return # дубль с тем же номером: первый столбец важнее
|
||||
text = _strip_marks(
|
||||
" ".join(ln.strip() for ln in str(desc[col]).split("\n") if ln.strip())
|
||||
continue
|
||||
number = m.group(1)
|
||||
text = " ".join(
|
||||
ln.strip() for ln in str(desc[col]).split("\n") if ln.strip()
|
||||
)
|
||||
room_kind = room_type(text)
|
||||
info[label] = {
|
||||
"room": label,
|
||||
"info": text,
|
||||
"type": room_kind,
|
||||
"type_group": room_type_group(room_kind),
|
||||
"capacity": room_capacity(text),
|
||||
"equipment": room_equipment(text),
|
||||
"available_days": room_weekdays(text),
|
||||
}
|
||||
|
||||
for col in range(_FIRST_GROUP_COL, max(len(header), len(desc))):
|
||||
if col not in sheet.hidden_cols: # скрытое не парсим
|
||||
add(col)
|
||||
label = f"ауд. {number}"
|
||||
info[label] = {"room": label, "info": _strip_marks(text)}
|
||||
return info
|
||||
|
||||
|
||||
def room_payload(label: str, info: dict | None = None) -> dict:
|
||||
"""Аудитория для API: номер, описание как в таблице и разобранные атрибуты."""
|
||||
info = info or {}
|
||||
return {
|
||||
"room": label,
|
||||
"info": info.get("info", ""),
|
||||
"type": info.get("type", ""),
|
||||
"type_group": info.get("type_group", ""),
|
||||
"capacity": info.get("capacity"),
|
||||
"equipment": list(info.get("equipment") or []),
|
||||
"available_days": info.get("available_days"),
|
||||
}
|
||||
|
||||
|
||||
def _initials_from_parts(first: str, patronymic: str) -> str:
|
||||
initials = ""
|
||||
if first:
|
||||
@@ -541,8 +381,6 @@ def parse_teachers_full(sheets: dict[str, Worksheet]) -> dict:
|
||||
header = sheet.rows[1]
|
||||
result: dict = {}
|
||||
for col in range(_FIRST_GROUP_COL, len(header)):
|
||||
if col in sheet.hidden_cols: # скрытое не парсим
|
||||
continue
|
||||
value = _strip_marks(str(header[col]))
|
||||
parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()]
|
||||
if not parts:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Настройки, которые правит администратор.
|
||||
"""Настройки расписания, которые правит администратор.
|
||||
|
||||
Хранятся в JSON-файле (`SETTINGS_FILE`, по умолчанию `data/settings.json` в
|
||||
корне проекта), поэтому переживают перезапуск службы: ссылка на таблицу,
|
||||
интервал обновления и история входов в админку. Если файл недоступен для
|
||||
записи — настройки живут в памяти, а сервис продолжает работать (об этом
|
||||
пишется предупреждение в лог и в состояние админки).
|
||||
Хранятся в JSON-файле (если задан путь) — так ссылка и интервал переживают
|
||||
перезапуск контейнера. Если путь не задан, настройки живут только в памяти
|
||||
(поведение прежних версий, прод можно не трогать).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,8 +11,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ScheduleError
|
||||
@@ -25,40 +22,14 @@ DEFAULT_INTERVAL_MINUTES = 15
|
||||
MIN_INTERVAL_MINUTES = 1
|
||||
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
|
||||
class Settings:
|
||||
link: str = ""
|
||||
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:
|
||||
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)))
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
|
||||
@@ -68,19 +39,18 @@ def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
|
||||
try:
|
||||
return clamp_interval(int(float(raw_minutes)))
|
||||
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()
|
||||
if raw_ttl:
|
||||
try:
|
||||
return clamp_interval(round(float(raw_ttl) / 60) or 1)
|
||||
except ValueError:
|
||||
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру по умолчанию", raw_ttl)
|
||||
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl)
|
||||
return default
|
||||
|
||||
|
||||
def settings_path_from_env() -> Path | None:
|
||||
raw = (os.getenv("SETTINGS_FILE") or "").strip()
|
||||
return Path(raw) if raw else DEFAULT_SETTINGS_PATH
|
||||
def clamp_interval(minutes: int) -> int:
|
||||
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
@@ -94,7 +64,6 @@ class SettingsStore:
|
||||
) -> None:
|
||||
self._path = Path(path) if path else None
|
||||
self._lock = threading.RLock()
|
||||
self._save_error: str | None = None
|
||||
self._settings = Settings(
|
||||
link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes)
|
||||
)
|
||||
@@ -102,10 +71,6 @@ class SettingsStore:
|
||||
|
||||
# --- чтение -----------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def link(self) -> str:
|
||||
with self._lock:
|
||||
@@ -116,22 +81,6 @@ class SettingsStore:
|
||||
with self._lock:
|
||||
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:
|
||||
with self._lock:
|
||||
return self._settings.as_dict()
|
||||
@@ -161,17 +110,6 @@ class SettingsStore:
|
||||
self._save()
|
||||
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:
|
||||
@@ -180,7 +118,7 @@ class SettingsStore:
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
log.warning("Не читается файл настроек %s: %s (беру значения из окружения)", self._path, exc)
|
||||
log.warning("Не читается файл настроек %s: %s (остаюсь на значениях из окружения)", self._path, exc)
|
||||
return
|
||||
if not isinstance(raw, dict):
|
||||
log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path)
|
||||
@@ -193,10 +131,6 @@ class SettingsStore:
|
||||
self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"]))
|
||||
except (TypeError, ValueError):
|
||||
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(
|
||||
"Настройки из %s: интервал %s мин, ссылка %s",
|
||||
self._path,
|
||||
@@ -215,7 +149,5 @@ class SettingsStore:
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(self._path)
|
||||
self._save_error = None
|
||||
except OSError as exc:
|
||||
self._save_error = str(exc)
|
||||
log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc)
|
||||
|
||||
@@ -14,7 +14,7 @@ import io
|
||||
import re
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from .errors import ScheduleError
|
||||
@@ -32,7 +32,6 @@ _REF_RE = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
class Worksheet:
|
||||
rows: list[list[str]]
|
||||
hidden_cols: set[int]
|
||||
hidden_rows: set[int] = field(default_factory=set)
|
||||
|
||||
def cell(self, row_idx: int, col_idx: int) -> str:
|
||||
"""Значение ячейки, "" вне границ матрицы."""
|
||||
@@ -136,14 +135,11 @@ def _parse_sheet(xml_bytes: bytes, shared: list[str]) -> Worksheet:
|
||||
if col.get("hidden") == "1":
|
||||
hidden_cols.update(range(c_min - 1, c_max))
|
||||
|
||||
hidden_rows: set[int] = set()
|
||||
for row in root.iter(f"{{{XLSX_NS}}}row"):
|
||||
row_num = row.get("r")
|
||||
if not row_num:
|
||||
continue
|
||||
r = int(row_num) - 1
|
||||
if row.get("hidden") == "1":
|
||||
hidden_rows.add(r)
|
||||
for cell in row.iter(f"{{{XLSX_NS}}}c"):
|
||||
ref = cell.get("r")
|
||||
if not ref:
|
||||
@@ -198,7 +194,7 @@ def _parse_sheet(xml_bytes: bytes, shared: list[str]) -> Worksheet:
|
||||
[cells.get((r, c), "") for c in range(max_col + 1)]
|
||||
for r in range(max_row + 1)
|
||||
]
|
||||
return Worksheet(rows=rows, hidden_cols=hidden_cols, hidden_rows=hidden_rows)
|
||||
return Worksheet(rows=rows, hidden_cols=hidden_cols)
|
||||
|
||||
|
||||
def _iter_text(node: ET.Element):
|
||||
|
||||
@@ -4,229 +4,151 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Админка расписания</title>
|
||||
<title>Админ кабинет</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script>
|
||||
// тему применяем до отрисовки, чтобы не мигало
|
||||
try {
|
||||
const saved = localStorage.getItem("theme");
|
||||
document.documentElement.setAttribute("data-theme", saved === "dark" ? "dark" : "light");
|
||||
} catch (_) {}
|
||||
</script>
|
||||
<style>
|
||||
body { background: var(--bg); }
|
||||
|
||||
.admin-main { max-width: 1080px; margin: 0 auto; padding: 22px 18px 70px; }
|
||||
.admin-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.admin-ghost {
|
||||
display: inline-flex; align-items: center; height: 36px; padding: 0 12px;
|
||||
border: 1px solid var(--border-2); background: var(--surface); color: var(--muted);
|
||||
font-size: 13px; font-weight: 600; text-decoration: none;
|
||||
}
|
||||
.admin-ghost:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* ---- вход ---- */
|
||||
.admin-login {
|
||||
max-width: 420px; margin: 6vh auto 0; padding: 30px 28px 26px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
box-shadow: 0 14px 40px var(--shadow); text-align: center;
|
||||
}
|
||||
.admin-login-logo {
|
||||
width: 58px; height: 58px; padding: 10px; margin: 0 auto 14px;
|
||||
background: linear-gradient(160deg, var(--nav-1), var(--nav-2));
|
||||
border-radius: 14px; display: block;
|
||||
}
|
||||
.admin-login h1 { font-size: 20px; color: var(--text); margin-bottom: 6px; }
|
||||
.admin-login-hint { font-size: 13px; color: var(--muted); margin-bottom: 20px; }
|
||||
.admin-login form { display: flex; flex-direction: column; gap: 12px; text-align: left; }
|
||||
.admin-login .admin-btn { margin-top: 4px; width: 100%; }
|
||||
|
||||
/* ---- шапка состояния ---- */
|
||||
.admin-hero {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent);
|
||||
padding: 16px 18px; margin-bottom: 16px;
|
||||
}
|
||||
.admin-hero.is-ok { border-left-color: #2f9e5f; }
|
||||
.admin-hero.is-error { border-left-color: var(--accent-red-bright); }
|
||||
.admin-hero-state { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 700; color: var(--text); }
|
||||
.admin-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted-2); }
|
||||
.admin-hero.is-ok .admin-dot { background: #2f9e5f; }
|
||||
.admin-hero.is-error .admin-dot { background: var(--accent-red-bright); }
|
||||
.admin-hero-sub { color: var(--muted); font-size: 13px; margin-top: 5px; }
|
||||
|
||||
/* ---- карточки ---- */
|
||||
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; }
|
||||
.admin-wrap { max-width: 720px; margin: 0 auto; padding: 26px 18px 60px; }
|
||||
.admin-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.admin-head img { width: 40px; height: 40px; }
|
||||
.admin-title { font-size: 22px; font-weight: 700; color: var(--text); }
|
||||
.admin-sub { color: var(--muted); font-size: 14px; margin-bottom: 18px; }
|
||||
.admin-card {
|
||||
background: var(--surface); border: 1px solid var(--border); padding: 16px 18px 14px;
|
||||
display: flex; flex-direction: column; gap: 10px; min-width: 0;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
padding: 16px 18px; margin-bottom: 14px;
|
||||
}
|
||||
.admin-card h2 {
|
||||
font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--muted); font-weight: 700;
|
||||
}
|
||||
.admin-card-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.admin-inline { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.admin-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.admin-field > span { font-size: 12px; color: var(--muted); }
|
||||
.admin-field-narrow { flex: 0 1 160px; }
|
||||
.admin-card h2 { font-size: 15px; margin-bottom: 10px; color: var(--text); }
|
||||
.admin-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.admin-field { display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0; }
|
||||
.admin-field label { font-size: 13px; color: var(--muted); }
|
||||
.admin-field input {
|
||||
font-size: 14px; padding: 9px 11px; width: 100%; min-width: 0;
|
||||
border: 1px solid var(--border-2); background: var(--surface-2); color: var(--text);
|
||||
font-size: 14px; padding: 8px 10px; border: 1px solid var(--border-2);
|
||||
background: var(--surface-2); color: var(--text); width: 100%; min-width: 0;
|
||||
}
|
||||
.admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
|
||||
|
||||
.admin-btn {
|
||||
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer; white-space: nowrap;
|
||||
border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent);
|
||||
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer;
|
||||
border: 1px solid var(--accent); background: var(--accent); color: #fff;
|
||||
}
|
||||
.admin-btn:hover { filter: brightness(1.06); }
|
||||
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
|
||||
.admin-btn-lg { font-size: 15px; padding: 11px 18px; }
|
||||
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; }
|
||||
|
||||
.admin-link { font-size: 13px; color: var(--accent-blue); text-decoration: none; }
|
||||
.admin-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- факты ---- */
|
||||
.admin-facts { display: flex; flex-direction: column; gap: 6px; margin: 0; }
|
||||
.admin-facts > div { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; }
|
||||
.admin-facts dt { font-size: 13px; color: var(--muted); flex: 0 0 auto; }
|
||||
.admin-facts dd {
|
||||
margin: 0; font-size: 14px; color: var(--text); font-weight: 600; text-align: right;
|
||||
min-width: 0; overflow-wrap: anywhere;
|
||||
.admin-btn.danger { background: var(--accent-red-bright); border-color: var(--accent-red-bright); }
|
||||
.admin-btn[disabled] { opacity: .55; cursor: default; }
|
||||
.admin-status { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.admin-chip {
|
||||
background: var(--surface-2); border: 1px solid var(--border); padding: 8px 12px; min-width: 0;
|
||||
}
|
||||
.admin-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; font-size: 13px; }
|
||||
.good { color: #2f9e5f; }
|
||||
.bad { color: var(--accent-red-bright); }
|
||||
|
||||
.admin-hint { font-size: 12px; color: var(--muted-2); line-height: 1.45; }
|
||||
.admin-alert {
|
||||
font-size: 13px; padding: 10px 12px; margin-bottom: 14px;
|
||||
background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.admin-alert.warn { background: color-mix(in srgb, #b8860b 12%, var(--surface)); border-color: #b8860b; color: #8a6300; }
|
||||
.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-error { color: var(--accent-red-bright); font-size: 13px; margin-top: 10px; word-break: break-word; overflow-wrap: anywhere; }
|
||||
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
|
||||
.hidden { display: none !important; }
|
||||
abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
|
||||
|
||||
/* ---- уведомления ---- */
|
||||
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
|
||||
#toast-wrap {
|
||||
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
|
||||
z-index: 9999; display: flex; flex-direction: column; gap: 8px;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: var(--surface); border: 1px solid var(--border-2); border-left: 4px solid var(--accent-blue);
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
padding: 12px 14px; font-size: 14px; color: var(--text);
|
||||
white-space: pre-line; animation: toast-in .18s ease-out;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-2);
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
white-space: pre-line;
|
||||
animation: toast-in .18s ease-out;
|
||||
}
|
||||
.toast.ok { border-left-color: #2f9e5f; }
|
||||
.toast.error { border-left-color: var(--accent-red-bright); }
|
||||
.toast.hide { opacity: 0; transition: opacity .3s ease; }
|
||||
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.toast .toast-close {
|
||||
flex: 0 0 auto; border: none; background: none; color: var(--muted);
|
||||
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px;
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.toast .toast-close:hover { color: var(--text); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-main { padding: 16px 12px 60px; }
|
||||
.admin-hero { padding: 14px; }
|
||||
.admin-hero .admin-btn { width: 100%; }
|
||||
.admin-login { margin-top: 2vh; padding: 24px 18px; }
|
||||
.admin-facts > div { flex-direction: column; gap: 2px; }
|
||||
.admin-facts dd { text-align: left; }
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<img class="topbar-logo" src="/img/owl.svg" alt="">
|
||||
<div class="topbar-text">
|
||||
<span class="topbar-university">Расписание — админка</span>
|
||||
<span class="topbar-faculty">источник данных и обновление</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<a class="admin-ghost" href="/">Расписание</a>
|
||||
<button class="theme-toggle" id="theme-toggle" title="Переключить тему">
|
||||
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
<button class="topbar-logout" id="logout" type="button">Выйти</button>
|
||||
</div>
|
||||
<div class="admin-wrap">
|
||||
<div class="admin-head">
|
||||
<img src="/img/owl.svg" alt="">
|
||||
<div class="admin-title">Админ кабинет</div>
|
||||
</div>
|
||||
|
||||
<main class="admin-main">
|
||||
<section id="login-card" class="admin-login hidden">
|
||||
<img class="admin-login-logo" src="/img/owl-white.svg" alt="">
|
||||
<h1>Вход в админку</h1>
|
||||
<p class="admin-login-hint">Страница открывается только по секретной ссылке. После нескольких неудачных попыток вход временно блокируется.</p>
|
||||
<form id="login-form" autocomplete="on">
|
||||
<label class="admin-field"><span>Логин</span><input id="login" autocomplete="username" required></label>
|
||||
<label class="admin-field"><span>Пароль</span><input id="password" type="password" autocomplete="current-password" required></label>
|
||||
<section id="login-card" class="admin-card hidden">
|
||||
<h2>Вход</h2>
|
||||
<form id="login-form" autocomplete="off">
|
||||
<div class="admin-field"><label for="login">Логин</label><input id="login" required></div>
|
||||
<div class="admin-field" style="margin-top:10px"><label for="password">Пароль</label><input id="password" type="password" required></div>
|
||||
<div class="admin-row" style="margin-top:12px">
|
||||
<button class="admin-btn" type="submit">Войти</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="login-error" class="admin-alert hidden" style="margin:14px 0 0"></div>
|
||||
<div id="login-error" class="admin-error hidden"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel" class="hidden">
|
||||
<div class="admin-hero" id="hero">
|
||||
<div>
|
||||
<div class="admin-hero-state"><span class="admin-dot"></span><span id="hero-title">Загружаю…</span></div>
|
||||
<div class="admin-hero-sub" id="hero-sub"></div>
|
||||
<div class="admin-card">
|
||||
<h2>Состояние</h2>
|
||||
<div class="admin-status" id="status-chips"></div>
|
||||
<div id="state-error" class="admin-error hidden"></div>
|
||||
</div>
|
||||
<button class="admin-btn admin-btn-lg" id="refresh" type="button">Обновить сейчас</button>
|
||||
</div>
|
||||
<div id="hero-alert" class="admin-alert hidden"></div>
|
||||
|
||||
<div class="admin-grid">
|
||||
<article class="admin-card">
|
||||
<div class="admin-card">
|
||||
<h2>Ссылка на расписание</h2>
|
||||
<label class="admin-field"><span>Публичная ссылка Яндекс.Диска</span>
|
||||
<div class="admin-row">
|
||||
<div class="admin-field">
|
||||
<label for="link">Публичная ссылка Яндекс.Диска</label>
|
||||
<input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/...">
|
||||
</label>
|
||||
<div class="admin-card-actions">
|
||||
</div>
|
||||
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
|
||||
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener noreferrer">открыть таблицу</a>
|
||||
</div>
|
||||
</article>
|
||||
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
|
||||
Это значение хранится в настройках стенда и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div>
|
||||
</div>
|
||||
|
||||
<article class="admin-card">
|
||||
<div class="admin-card">
|
||||
<h2>Обновление</h2>
|
||||
<div class="admin-inline">
|
||||
<label class="admin-field admin-field-narrow"><span>Интервал, минут</span>
|
||||
<div class="admin-row">
|
||||
<div class="admin-field" style="flex:0 1 200px">
|
||||
<label for="interval">Интервал, минут</label>
|
||||
<input id="interval" type="number" min="1" max="1440" step="1">
|
||||
</label>
|
||||
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить</button>
|
||||
</div>
|
||||
<dl class="admin-facts">
|
||||
<div><dt>Файл при последнем обновлении</dt><dd id="fact-changed">—</dd></div>
|
||||
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
|
||||
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
|
||||
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
|
||||
</div>
|
||||
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.
|
||||
Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.</div>
|
||||
</div>
|
||||
|
||||
<article class="admin-card">
|
||||
<h2>Входы в админку</h2>
|
||||
<dl class="admin-facts">
|
||||
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
|
||||
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
|
||||
</dl>
|
||||
<div id="auth-lock" class="admin-alert warn hidden" style="margin:10px 0 0"></div>
|
||||
</article>
|
||||
<div class="admin-card">
|
||||
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast-wrap"></div>
|
||||
|
||||
@@ -249,6 +171,7 @@
|
||||
close.setAttribute("aria-label", "Закрыть");
|
||||
el.append(text, close);
|
||||
wrap.appendChild(el);
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(timer);
|
||||
el.classList.add("hide");
|
||||
@@ -285,84 +208,33 @@
|
||||
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** «5 минут назад» для свежести данных. */
|
||||
function ago(value) {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (isNaN(d)) return "";
|
||||
const sec = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000));
|
||||
if (sec < 60) return "только что";
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min} мин назад`;
|
||||
const hours = Math.round(min / 60);
|
||||
if (hours < 24) return `${hours} ч назад`;
|
||||
return `${Math.round(hours / 24)} дн назад`;
|
||||
}
|
||||
|
||||
function loginText(info) {
|
||||
if (!info) return "нет данных";
|
||||
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
|
||||
return `${fmt(info.at)} · ${info.ip}${status}`;
|
||||
}
|
||||
|
||||
function setText(id, value, cls) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.textContent = value;
|
||||
el.classList.remove("good", "bad");
|
||||
if (cls) el.classList.add(cls);
|
||||
function chip(key, value) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "admin-chip";
|
||||
div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`;
|
||||
div.querySelector(".v").textContent = value;
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
// крупная строка состояния
|
||||
const hero = $("hero");
|
||||
const hasError = Boolean(state.error);
|
||||
hero.classList.toggle("is-error", hasError);
|
||||
hero.classList.toggle("is-ok", !hasError && state.has_data);
|
||||
$("hero-title").textContent = hasError
|
||||
? "Ошибка загрузки расписания"
|
||||
: (state.has_data ? "Работает" : "Жду данные");
|
||||
const parts = [];
|
||||
if (state.last_updated) parts.push(`обновлено ${fmt(state.last_updated)} (${ago(state.last_updated)})`);
|
||||
parts.push(`групп: ${state.groups}`);
|
||||
parts.push(`интервал ${state.interval_minutes} мин`);
|
||||
$("hero-sub").textContent = parts.join(" · ");
|
||||
|
||||
const alert = $("hero-alert");
|
||||
const warnings = [];
|
||||
if (state.error) warnings.push("Ошибка загрузки: " + state.error);
|
||||
if (state.settings_save_error) {
|
||||
warnings.push("Настройки не сохраняются на диск: " + state.settings_save_error +
|
||||
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env");
|
||||
}
|
||||
alert.classList.toggle("warn", !state.error && Boolean(state.settings_save_error));
|
||||
alert.textContent = warnings.join(" ");
|
||||
alert.classList.toggle("hidden", !warnings.length);
|
||||
|
||||
// ссылка
|
||||
const chips = $("status-chips");
|
||||
chips.innerHTML = "";
|
||||
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
|
||||
chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
|
||||
chips.appendChild(chip("Таблица изменена", fmt(state.table_modified)));
|
||||
chips.appendChild(chip(
|
||||
"Файл при последнем обновлении",
|
||||
state.changed ? "изменился ✓" : "без изменений (та же версия)",
|
||||
));
|
||||
chips.appendChild(chip("Содержимое менялось", fmt(state.changed_at)));
|
||||
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
|
||||
chips.appendChild(chip("Групп в расписании", String(state.groups)));
|
||||
chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
|
||||
$("link").value = state.link || "";
|
||||
const open = $("link-open");
|
||||
open.href = state.link || "#";
|
||||
open.classList.toggle("hidden", !state.link);
|
||||
|
||||
// обновление
|
||||
$("interval").value = state.interval_minutes;
|
||||
setText("fact-changed", state.changed ? "изменился" : "без изменений", state.changed ? "good" : null);
|
||||
setText("fact-modified", fmt(state.table_modified));
|
||||
setText("fact-hash", state.content_hash || "—");
|
||||
|
||||
// входы
|
||||
const attempt = state.last_attempt;
|
||||
setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
|
||||
setText("auth-success", loginText(state.last_success));
|
||||
const throttle = state.throttle || {};
|
||||
const lock = $("auth-lock");
|
||||
if (throttle.locked_until) {
|
||||
lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`;
|
||||
lock.classList.remove("hidden");
|
||||
} else {
|
||||
lock.classList.add("hidden");
|
||||
}
|
||||
const err = $("state-error");
|
||||
err.textContent = state.error ? "Ошибка загрузки: " + state.error : "";
|
||||
err.classList.toggle("hidden", !state.error);
|
||||
}
|
||||
|
||||
function showLogin(show) {
|
||||
@@ -378,22 +250,19 @@
|
||||
return () => { button.disabled = false; button.textContent = original; };
|
||||
}
|
||||
|
||||
async function withButton(button, text, fn) {
|
||||
const done = busy(button, text);
|
||||
try { await fn(); } finally { done(); }
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const r = await api("/state");
|
||||
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);
|
||||
renderState(r.data);
|
||||
}
|
||||
|
||||
async function withButton(button, text, fn) {
|
||||
const done = busy(button, text);
|
||||
try { await fn(); } finally { done(); }
|
||||
}
|
||||
|
||||
$("login-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const error = $("login-error");
|
||||
@@ -450,13 +319,6 @@
|
||||
showLogin(true);
|
||||
});
|
||||
|
||||
$("theme-toggle").addEventListener("click", () => {
|
||||
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
const next = current === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
try { localStorage.setItem("theme", next); } catch (_) {}
|
||||
});
|
||||
|
||||
loadState();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Фронтенд расписания: статики, прокси к API и страница админки.
|
||||
"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки.
|
||||
|
||||
Страница админки отдаётся по секретному пути из ADMIN_PATH; сам API админки —
|
||||
/api/admin/* (проксируется в backend). Если ADMIN_PATH не задан, страницы нет.
|
||||
Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py):
|
||||
сам путь не публикуется, входа с сайта нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,65 +15,21 @@ import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
def _find_dotenv() -> Path | None:
|
||||
"""Ищем .env вверх от каталога модуля: один общий .env в корне проекта."""
|
||||
path = Path(__file__).resolve().parent
|
||||
for _ in range(5):
|
||||
env = path / ".env"
|
||||
if env.is_file():
|
||||
return env
|
||||
if path.parent == path:
|
||||
break
|
||||
path = path.parent
|
||||
return None
|
||||
|
||||
|
||||
_dotenv_path = _find_dotenv()
|
||||
if _dotenv_path:
|
||||
load_dotenv(_dotenv_path)
|
||||
|
||||
# Адрес бэкенда: в docker это http://api:8000 (задаётся в docker-compose),
|
||||
# локально — http://127.0.0.1:8000.
|
||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
|
||||
ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/")
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
# страница админки вне static: прямой доступ по /admin.html закрыт
|
||||
# страница админки лежит ВНЕ static: прямой доступ по /admin.html запрещён
|
||||
ADMIN_PAGE = Path(__file__).parent / "admin.html"
|
||||
INDEX_PAGE = STATIC_DIR / "index.html"
|
||||
# проверка «это админ?» при отдаче главной — свой короткий таймаут
|
||||
ADMIN_CHECK_TIMEOUT = 2.0
|
||||
|
||||
|
||||
def admin_session_active(request: Request) -> bool:
|
||||
"""Есть ли валидная админская сессия у запроса.
|
||||
|
||||
Сам путь к панели при этом не читаем и никому не отдаём: главная страница
|
||||
получает только булев флаг, по которому показывает кнопку «Админка».
|
||||
"""
|
||||
if not ADMIN_PATH:
|
||||
return False
|
||||
cookie = request.headers.get("cookie")
|
||||
if not cookie:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{SCHEDULE_API}/api/admin/state",
|
||||
headers={"Cookie": cookie},
|
||||
timeout=ADMIN_CHECK_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException:
|
||||
return False
|
||||
return resp.status_code == 200
|
||||
|
||||
|
||||
app = FastAPI(title="Расписание — фронт")
|
||||
|
||||
@@ -95,7 +51,7 @@ def _proxy(path: str, params: dict) -> object:
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
raise HTTPException(status_code=resp.status_code, detail=detail)
|
||||
# данные не кэшируем: браузер всегда показывает актуальное расписание
|
||||
# данные не кэшируем: после «Обновить сейчас» браузер должен видеть свежее
|
||||
return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@@ -108,9 +64,6 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
|
||||
data=body or None,
|
||||
headers=headers,
|
||||
timeout=PROXY_TIMEOUT,
|
||||
# редирект /api/admin/goto отдаём браузеру как есть: он и уводит
|
||||
# на страницу панели, чтобы путь не появлялся в разметке
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||||
@@ -120,9 +73,6 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
|
||||
media_type=resp.headers.get("content-type"),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
location = resp.headers.get("location")
|
||||
if location:
|
||||
out.headers["location"] = location
|
||||
# Set-Cookie (вход/выход админки) передаём как есть
|
||||
raw_headers = getattr(resp.raw, "headers", None)
|
||||
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []:
|
||||
@@ -194,36 +144,17 @@ async def api_admin(rest: str, request: Request):
|
||||
cookie = request.headers.get("cookie")
|
||||
if 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(
|
||||
_proxy_admin, request.method, f"api/admin/{rest}", body, headers
|
||||
)
|
||||
|
||||
|
||||
# --- статики и админка --------------------------------------------------------
|
||||
# --- статики -----------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index(request: Request):
|
||||
"""Главная страница.
|
||||
|
||||
Вошедшему админу добавляем только флаг (без пути!): по нему страница
|
||||
покажет кнопку «Админка», которая ведёт на /api/admin/goto — сервер сам
|
||||
ответит редиректом на секретный путь. Так путь не попадает ни в разметку,
|
||||
ни в данные, и неавторизованный пользователь его нигде не увидит.
|
||||
"""
|
||||
if admin_session_active(request):
|
||||
html = INDEX_PAGE.read_text(encoding="utf-8")
|
||||
html = html.replace(
|
||||
"<body>", "<body>\n <script>window.__SCHEDULE_ADMIN__ = true;</script>", 1
|
||||
)
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||
return FileResponse(INDEX_PAGE, headers={"Cache-Control": "no-store"})
|
||||
def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
if ADMIN_PATH:
|
||||
@@ -232,13 +163,7 @@ if ADMIN_PATH:
|
||||
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
|
||||
return FileResponse(
|
||||
ADMIN_PAGE,
|
||||
headers={
|
||||
"X-Robots-Tag": "noindex, nofollow, noarchive",
|
||||
"Cache-Control": "no-store",
|
||||
# путь не должен утекать во внешние ссылки через Referer
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
log.info("Страница админки включена по секретному пути")
|
||||
|
||||
@@ -405,181 +405,31 @@ function renderTeacher(data, status) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- аудитории: занятость по дню и свободные на паре ---------- */
|
||||
/* ---------- аудитории: занятость по выбранному дню ---------- */
|
||||
|
||||
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||||
const ROOMS_FILTER_KEY = "rooms-filter";
|
||||
// порядок показа оснащения; сами теги берём из данных таблицы, чтобы новые
|
||||
// пункты появлялись автоматически
|
||||
const EQUIPMENT_ORDER = ["ПК", "Проектор", "Интерактивная панель", "Телевизор", "Маркер", "Меловая доска"];
|
||||
const ROOM_MODES = { busy: "Занятость за день", free: "Свободные на паре" };
|
||||
const ROOM_PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
let roomsState = { day: null, mode: "busy", period: 1, loaded: null, status: null, filter: loadRoomsFilter() };
|
||||
|
||||
/** Фильтры аудиторий живут между заходами. */
|
||||
function loadRoomsFilter() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(ROOMS_FILTER_KEY) || "{}");
|
||||
return {
|
||||
type: typeof raw.type === "string" ? raw.type : "",
|
||||
capacity: raw.capacity ? String(raw.capacity) : "",
|
||||
equipment: Array.isArray(raw.equipment) ? raw.equipment.filter((t) => typeof t === "string") : [],
|
||||
mode: raw.mode === "or" ? "or" : "and",
|
||||
};
|
||||
} catch (_) {
|
||||
return { type: "", capacity: "", equipment: [], mode: "and" };
|
||||
}
|
||||
}
|
||||
|
||||
function saveRoomsFilter() {
|
||||
localStorage.setItem(ROOMS_FILTER_KEY, JSON.stringify(roomsState.filter));
|
||||
}
|
||||
|
||||
/** Слова в нижний регистр, аббревиатуры («ПК», «МО») остаются заглавными. */
|
||||
function lcWords(text) {
|
||||
return String(text || "").replace(/[A-Za-zА-ЯЁа-яё]+/g, (word) =>
|
||||
word.length >= 2 && word === word.toUpperCase() ? word : word.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
/** «Четверг» -> «четвергам» (для фразы «по четвергам и пятницам»). */
|
||||
function weekdayPlural(day) {
|
||||
if (day.endsWith("ье")) return day.slice(0, -2) + "ьям"; // воскресенье
|
||||
if (day.endsWith("а")) return day + "м"; // пятница, среда
|
||||
return day + "ам"; // четверг, вторник
|
||||
}
|
||||
|
||||
function daysPhrase(days) {
|
||||
const forms = days.map(weekdayPlural);
|
||||
if (forms.length === 1) return `по ${forms[0]}`;
|
||||
return `по ${forms.slice(0, -1).join(", ")} и ${forms[forms.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Дополнение из таблицы: оставляем только фразы, которых нет в разобранных
|
||||
* атрибутах («без проектора», «МО и СТ»), не разрезая слова. Если описание
|
||||
* целиком сводится к типу/оснащению/вместимости/номеру/дням — не показываем.
|
||||
*/
|
||||
function roomInfoText(room) {
|
||||
const info = (room.info || "").trim();
|
||||
if (!info) return "";
|
||||
|
||||
const known = new Set();
|
||||
const addText = (text) =>
|
||||
(text || "")
|
||||
.toLowerCase()
|
||||
.split(/[\s;,.:()\/-]+/)
|
||||
.forEach((word) => { if (word.length >= 2) known.add(word); });
|
||||
addText(room.type);
|
||||
addText(room.type_group);
|
||||
(room.equipment || []).forEach(addText);
|
||||
(room.available_days || []).forEach(addText);
|
||||
|
||||
const STOP = new Set(["и", "в", "на", "для", "по", "с", "из", "до", "за", "от", "к", "о", "об", "при", "под", "над", "у"]);
|
||||
const CAPACITY = /\d+\s*(?:мест[а-яё]*|раб\.?|осн\.?)/gi;
|
||||
const isKnown = (token) => {
|
||||
const clean = token.toLowerCase().replace(/[^а-яёa-z0-9]+/g, "");
|
||||
if (!clean || !/[а-яёa-z]/.test(clean)) return true; // числа и знаки
|
||||
if (clean.length < 2) return true;
|
||||
if (STOP.has(clean)) return true;
|
||||
if (/^(мест|раб|осн)/.test(clean)) return true; // «место», «раб.»
|
||||
return [...known].some(
|
||||
(k) => clean.startsWith(k.slice(0, 4)) || k.startsWith(clean.slice(0, 4)),
|
||||
);
|
||||
};
|
||||
|
||||
return info
|
||||
.replace(CAPACITY, " ")
|
||||
.split(/[;,]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const tokens = part.split(/\s+/);
|
||||
if (tokens.every(isKnown)) return "";
|
||||
const rest = [...tokens];
|
||||
while (rest.length && isKnown(rest[0])) rest.shift(); // убираем ведущие известные слова
|
||||
return lcWords(rest.join(" "));
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** Русское склонение существительного при числе. */
|
||||
function pluralRu(count, one, few, many) {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
function roomsAll() {
|
||||
return (roomsState.loaded && roomsState.loaded.rooms) || [];
|
||||
}
|
||||
|
||||
/** Теги оснащения: сначала известные по порядку, затем всё остальное из данных. */
|
||||
function equipmentTags() {
|
||||
const found = new Set();
|
||||
roomsAll().forEach((room) => (room.equipment || []).forEach((tag) => found.add(tag)));
|
||||
const known = EQUIPMENT_ORDER.filter((tag) => found.has(tag));
|
||||
const extra = [...found].filter((tag) => !EQUIPMENT_ORDER.includes(tag)).sort((a, b) => a.localeCompare(b, "ru"));
|
||||
return [...known, ...extra];
|
||||
}
|
||||
|
||||
/** Разобранные атрибуты аудитории: «комп.класс · 24 места · ПК, Проектор». */
|
||||
function roomAttrsText(room) {
|
||||
const parts = [];
|
||||
if (room.type) parts.push(room.type);
|
||||
if (room.capacity != null) parts.push(`${room.capacity} мест`);
|
||||
if ((room.equipment || []).length) parts.push(room.equipment.join(", "));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function roomMatchesFilter(room) {
|
||||
const filter = roomsState.filter;
|
||||
const group = room.type_group || "";
|
||||
if (filter.type === "__none__") {
|
||||
if (group) return false; // только аудитории без указанного типа
|
||||
} else if (filter.type && group !== filter.type) {
|
||||
return false;
|
||||
}
|
||||
const min = parseInt(filter.capacity, 10);
|
||||
if (!isNaN(min) && min > 0 && (room.capacity == null || room.capacity < min)) return false;
|
||||
const wanted = filter.equipment;
|
||||
if (wanted.length) {
|
||||
const has = new Set(room.equipment || []);
|
||||
const ok = filter.mode === "or" ? wanted.some((t) => has.has(t)) : wanted.every((t) => has.has(t));
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function roomFreeAt(room, period) {
|
||||
const slot = (room.periods || {})[String(period)] || {};
|
||||
return !((slot.ч || []).length || (slot.з || []).length);
|
||||
}
|
||||
|
||||
function roomAvailableOn(room) {
|
||||
const days = room.available_days;
|
||||
return !days || days.includes(roomsState.day);
|
||||
}
|
||||
|
||||
async function renderRoomsView(day) {
|
||||
roomsState.day = FREE_WEEKDAYS.includes(day)
|
||||
? day
|
||||
: (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
||||
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
||||
const seq = ++requestSeq;
|
||||
scheduleEl.innerHTML = "";
|
||||
errorEl.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
try {
|
||||
const status = await loadStatus();
|
||||
const data = await fetchJSON(`/api/rooms-day?day=${enc(roomsState.day)}`);
|
||||
const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`);
|
||||
if (seq !== requestSeq) return;
|
||||
roomsState.loaded = data;
|
||||
roomsState.status = status;
|
||||
drawRoomsScreen(status);
|
||||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "schedule-title",
|
||||
textContent: "Занятость аудиторий",
|
||||
}));
|
||||
const top = statusRowHTML(status);
|
||||
if (top) scheduleEl.appendChild(top);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "rooms-wrap";
|
||||
scheduleEl.appendChild(wrap);
|
||||
wrap.appendChild(roomsDayTable(data));
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq) return;
|
||||
errorEl.textContent = "Ошибка: " + err.message;
|
||||
@@ -589,198 +439,27 @@ async function renderRoomsView(day) {
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoomsScreen(status) {
|
||||
scheduleEl.innerHTML = "";
|
||||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "schedule-title",
|
||||
textContent: "Аудитории",
|
||||
}));
|
||||
const top = statusRowHTML(status);
|
||||
if (top) scheduleEl.appendChild(top);
|
||||
scheduleEl.appendChild(roomsToolbar());
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "rooms-wrap";
|
||||
wrap.appendChild(roomsListBlock());
|
||||
scheduleEl.appendChild(wrap);
|
||||
updateRoomsSummary(); // панель уже в DOM — можно заполнить счётчик
|
||||
function roomsDayTable(data) {
|
||||
const periodNums = [];
|
||||
data.rooms.forEach((r) => {
|
||||
Object.keys(r.periods || {}).forEach((p) => {
|
||||
if (!periodNums.includes(p)) periodNums.push(p);
|
||||
});
|
||||
});
|
||||
periodNums.sort((a, b) => Number(a) - Number(b));
|
||||
return buildRoomsList(data, periodNums);
|
||||
}
|
||||
|
||||
function roomsToolbar() {
|
||||
const all = roomsAll();
|
||||
// близкие формулировки типов объединены в общий тип (room_type_group)
|
||||
const types = [...new Set(all.map((r) => r.type_group).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru"));
|
||||
const filter = roomsState.filter;
|
||||
const box = document.createElement("div");
|
||||
box.className = "rooms-filters";
|
||||
box.innerHTML = `
|
||||
<div class="rf-row rf-tabs">
|
||||
${Object.entries(ROOM_MODES).map(([key, label]) =>
|
||||
`<button type="button" class="rf-tab${roomsState.mode === key ? " active" : ""}" data-mode="${key}">${label}</button>`
|
||||
).join("")}
|
||||
${roomsState.mode === "free" ? `
|
||||
<label class="rf-field rf-narrow">
|
||||
<select id="rf-period">${ROOM_PERIODS.map((p) =>
|
||||
`<option value="${p}"${roomsState.period === p ? " selected" : ""}>Пара ${p}</option>`).join("")}</select>
|
||||
</label>` : ""}
|
||||
</div>
|
||||
<div class="rf-row">
|
||||
<label class="rf-field"><span>Тип аудитории</span>
|
||||
<select id="rf-type">
|
||||
<option value="">все типы</option>
|
||||
${types.map((t) => `<option value="${escapeHTML(t)}"${filter.type === t ? " selected" : ""}>${escapeHTML(t)}</option>`).join("")}
|
||||
<option value="__none__"${filter.type === "__none__" ? " selected" : ""}>без типа</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="rf-field rf-narrow"><span>Мест не меньше</span>
|
||||
<input id="rf-capacity" type="number" min="1" step="1" placeholder="например 30" value="${escapeHTML(filter.capacity)}">
|
||||
</label>
|
||||
<label class="rf-field rf-narrow"><span>Оснащение</span>
|
||||
<select id="rf-mode">
|
||||
<option value="and"${filter.mode === "and" ? " selected" : ""}>все выбранные (И)</option>
|
||||
<option value="or"${filter.mode === "or" ? " selected" : ""}>любое из выбранных (ИЛИ)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="rf-equip">
|
||||
${equipmentTags().map((tag) => `
|
||||
<label class="rf-check"><input type="checkbox" data-equip="${escapeHTML(tag)}"${filter.equipment.includes(tag) ? " checked" : ""}> ${escapeHTML(tag)}</label>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="rf-hint hidden" id="rf-hint"></div>
|
||||
<div class="rf-row rf-actions">
|
||||
<button type="button" class="rf-reset" id="rf-reset">Сбросить фильтры</button>
|
||||
<div class="rf-summary" id="rf-summary"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
box.querySelectorAll(".rf-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
roomsState.mode = btn.dataset.mode;
|
||||
drawRoomsScreen(roomsState.status);
|
||||
});
|
||||
});
|
||||
const periodSel = box.querySelector("#rf-period");
|
||||
if (periodSel) {
|
||||
periodSel.addEventListener("change", () => {
|
||||
roomsState.period = Number(periodSel.value);
|
||||
refreshRoomsList();
|
||||
});
|
||||
}
|
||||
box.querySelector("#rf-type").addEventListener("change", (e) => {
|
||||
roomsState.filter.type = e.target.value;
|
||||
saveRoomsFilter();
|
||||
refreshRoomsList();
|
||||
});
|
||||
box.querySelector("#rf-capacity").addEventListener("input", (e) => {
|
||||
roomsState.filter.capacity = e.target.value;
|
||||
saveRoomsFilter();
|
||||
refreshRoomsList();
|
||||
});
|
||||
box.querySelector("#rf-mode").addEventListener("change", (e) => {
|
||||
roomsState.filter.mode = e.target.value;
|
||||
saveRoomsFilter();
|
||||
refreshRoomsList();
|
||||
});
|
||||
box.querySelectorAll("[data-equip]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
const tag = input.dataset.equip;
|
||||
const set = new Set(roomsState.filter.equipment);
|
||||
if (input.checked) set.add(tag); else set.delete(tag);
|
||||
roomsState.filter.equipment = [...set];
|
||||
saveRoomsFilter();
|
||||
refreshRoomsList();
|
||||
});
|
||||
});
|
||||
box.querySelector("#rf-reset").addEventListener("click", () => {
|
||||
roomsState.filter = { type: "", capacity: "", equipment: [], mode: "and" };
|
||||
saveRoomsFilter();
|
||||
drawRoomsScreen(roomsState.status);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Перерисовать только список аудиторий (фильтры и поля ввода не трогаем). */
|
||||
function refreshRoomsList() {
|
||||
const old = scheduleEl.querySelector(".rooms-list, .rooms-empty");
|
||||
const next = roomsListBlock();
|
||||
if (old) old.replaceWith(next); else (scheduleEl.querySelector(".rooms-wrap") || scheduleEl).appendChild(next);
|
||||
updateRoomsSummary();
|
||||
}
|
||||
|
||||
function updateRoomsSummary() {
|
||||
const summary = scheduleEl.querySelector("#rf-summary");
|
||||
const hint = scheduleEl.querySelector("#rf-hint");
|
||||
if (!summary) return;
|
||||
const all = roomsAll();
|
||||
const shown = visibleRooms();
|
||||
const pieces = [`Показано ${shown.length} из ${all.length} аудиторий`];
|
||||
if (roomsState.mode === "free") pieces.push(`свободны на паре ${roomsState.period}`);
|
||||
summary.textContent = pieces.join(" · ");
|
||||
|
||||
const notes = [];
|
||||
const min = parseInt(roomsState.filter.capacity, 10);
|
||||
if (!isNaN(min) && min > 0) {
|
||||
const unknown = all.filter((r) => r.capacity == null).length;
|
||||
if (unknown) {
|
||||
notes.push(`скрыто ${unknown} ${pluralRu(unknown, "аудитория", "аудитории", "аудиторий")}: в таблице нет данных о вместимости`);
|
||||
}
|
||||
}
|
||||
if (hint) {
|
||||
hint.textContent = notes
|
||||
.map((text) => text.charAt(0).toUpperCase() + text.slice(1))
|
||||
.join(". ");
|
||||
hint.classList.toggle("hidden", !notes.length);
|
||||
}
|
||||
}
|
||||
|
||||
function visibleRooms() {
|
||||
const filterFn = (room) => roomMatchesFilter(room);
|
||||
if (roomsState.mode !== "free") return roomsAll().filter(filterFn);
|
||||
return roomsAll().filter((room) => filterFn(room) && roomAvailableOn(room) && roomFreeAt(room, roomsState.period));
|
||||
}
|
||||
|
||||
function roomsListBlock() {
|
||||
const rooms = visibleRooms();
|
||||
function buildRoomsList(data, periodNums) {
|
||||
const list = document.createElement("div");
|
||||
list.className = "rooms-list";
|
||||
if (!rooms.length) {
|
||||
list.className = "rooms-empty";
|
||||
list.textContent = "Под фильтры ничего не подошло — ослабьте условия или сбросьте фильтры.";
|
||||
return list;
|
||||
}
|
||||
const free = roomsState.mode === "free";
|
||||
if (!free) {
|
||||
const periodNums = [];
|
||||
roomsAll().forEach((r) => Object.keys(r.periods || {}).forEach((p) => {
|
||||
if (!periodNums.includes(p)) periodNums.push(p);
|
||||
}));
|
||||
periodNums.sort((a, b) => Number(a) - Number(b));
|
||||
rooms.forEach((room) => list.appendChild(roomsDayCard(room, periodNums)));
|
||||
return list;
|
||||
}
|
||||
rooms.forEach((room) => list.appendChild(roomsFreeCard(room)));
|
||||
return list;
|
||||
}
|
||||
|
||||
function roomsCardHead(room) {
|
||||
const head = document.createElement("div");
|
||||
head.className = "day-head";
|
||||
const attrs = roomAttrsText(room);
|
||||
const info = roomInfoText(room);
|
||||
head.innerHTML = `
|
||||
<div class="room-title">
|
||||
<span class="day-title">${escapeHTML(room.room)}</span>
|
||||
${attrs ? `<div class="room-attrs">${escapeHTML(attrs)}</div>` : ""}
|
||||
${info ? `<div class="room-info">${escapeHTML(info)}</div>` : ""}
|
||||
${room.available_days ? `<div class="room-note days">занятия только ${escapeHTML(daysPhrase(room.available_days))}</div>` : ""}
|
||||
</div>`;
|
||||
return head;
|
||||
}
|
||||
|
||||
function roomsDayCard(room, periodNums) {
|
||||
data.rooms.forEach((room) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "room-card";
|
||||
card.appendChild(roomsCardHead(room));
|
||||
const head = document.createElement("div");
|
||||
head.className = "day-head";
|
||||
head.innerHTML = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`;
|
||||
card.appendChild(head);
|
||||
const periods = room.periods || {};
|
||||
periodNums.forEach((p) => {
|
||||
const slot = periods[p] || {};
|
||||
@@ -802,18 +481,9 @@ function roomsDayCard(room, periodNums) {
|
||||
row.appendChild(body);
|
||||
card.appendChild(row);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function roomsFreeCard(room) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "room-card room-card-free";
|
||||
card.appendChild(roomsCardHead(room));
|
||||
const note = document.createElement("div");
|
||||
note.className = "room-free-note";
|
||||
note.textContent = `Свободна на паре ${roomsState.period} (${roomsState.day})`;
|
||||
card.appendChild(note);
|
||||
return card;
|
||||
list.appendChild(card);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
@@ -851,24 +521,6 @@ themeToggle.addEventListener("click", () => {
|
||||
|
||||
initTheme();
|
||||
|
||||
/* Кнопки админки в шапке: сервер подставляет путь, только если сессия валидна */
|
||||
const adminLink = document.getElementById("admin-link");
|
||||
const adminLogout = document.getElementById("admin-logout");
|
||||
|
||||
if (adminLink && adminLogout && window.__SCHEDULE_ADMIN__) {
|
||||
// путь к панели в разметке не храним: сервер сам ответит редиректом
|
||||
adminLink.classList.remove("hidden");
|
||||
adminLogout.classList.remove("hidden");
|
||||
adminLink.addEventListener("click", () => { window.location.href = "/api/admin/goto"; });
|
||||
adminLogout.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" });
|
||||
} catch (_) {}
|
||||
adminLink.classList.add("hidden");
|
||||
adminLogout.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".side-menu a").forEach((link) => {
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -28,14 +28,10 @@
|
||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button id="admin-link" class="topbar-admin hidden" type="button">Админка</button>
|
||||
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
|
||||
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
<button id="admin-logout" class="topbar-logout hidden" type="button">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /api/
|
||||
@@ -112,10 +112,7 @@ a:hover { color: var(--accent-red); }
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-brand > a { display: inline-flex; }
|
||||
|
||||
.topbar-logo {
|
||||
display: block;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
@@ -154,40 +151,6 @@ a:hover { color: var(--accent-red); }
|
||||
}
|
||||
|
||||
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* ---- кнопки в шапке: админка и выход ---- */
|
||||
.topbar-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
|
||||
.topbar-admin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
}
|
||||
.topbar-admin:hover { background: var(--accent-blue); border-color: var(--accent-blue); }
|
||||
|
||||
/* красная кнопка выхода: при наведении темнеет, как остальные кнопки */
|
||||
.topbar-logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent-red-bright);
|
||||
background: var(--accent-red-bright);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
}
|
||||
.topbar-logout:hover { background: var(--accent-red); border-color: var(--accent-red); }
|
||||
.theme-toggle .icon { width: 18px; height: 18px; }
|
||||
|
||||
.icon-sun { display: none; }
|
||||
@@ -688,77 +651,6 @@ main {
|
||||
/* ---- аудитории: список карточками (мобильная версия) ---- */
|
||||
.rooms-list { display: flex; flex-direction: column; }
|
||||
|
||||
/* ---- фильтры аудиторий ---- */
|
||||
.rooms-filters {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rf-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.rf-tabs { align-items: center; }
|
||||
|
||||
.rf-tab {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.rf-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.rf-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 0 1 260px; }
|
||||
.rf-field > span { font-size: 12px; color: var(--muted); }
|
||||
.rf-field select,
|
||||
.rf-field input {
|
||||
font-size: 14px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.rf-narrow { flex: 0 1 170px; }
|
||||
|
||||
.rf-equip { display: flex; flex-wrap: wrap; gap: 8px 16px; padding-top: 2px; }
|
||||
.rf-check { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text); cursor: pointer; }
|
||||
|
||||
.rf-actions { justify-content: space-between; align-items: baseline; }
|
||||
.rf-reset {
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.rf-summary { font-size: 13px; color: var(--muted); }
|
||||
.rf-hint { font-size: 12px; color: #b8860b; }
|
||||
|
||||
.rooms-empty {
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border-2);
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-card-free { padding-bottom: 12px; }
|
||||
.room-attrs { font-size: 13px; font-weight: 600; color: var(--text); margin-top: 3px; }
|
||||
.room-note { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
/* пометка про дни доступности — заметная, жёлтым */
|
||||
.room-note.days { color: #b8860b; font-weight: 600; }
|
||||
[data-theme="dark"] .room-note.days { color: #e8b339; }
|
||||
.room-free-note { font-size: 13px; color: var(--muted); margin-top: 10px; }
|
||||
|
||||
|
||||
.room-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/force."""
|
||||
"""Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from admin import AdminConfig, make_token, verify_token
|
||||
from admin import LOGIN_MAX_FAILURES, AdminConfig, make_token, verify_token
|
||||
from fixtures import fixture_xlsx, make_cache
|
||||
from schedule import ScheduleError
|
||||
from schedule.settings import SettingsStore
|
||||
@@ -28,8 +29,8 @@ def cache(monkeypatch, store):
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(cache, store):
|
||||
app = main.create_app(cache, admin=ADMIN, settings=store)
|
||||
def client(cache):
|
||||
app = main.create_app(cache, admin=ADMIN)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
@@ -74,19 +75,6 @@ def test_login_and_state(client, cache):
|
||||
assert state["interval_minutes"] == 15
|
||||
assert state["groups"] == 2
|
||||
assert state["error"] is None
|
||||
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts
|
||||
# путь к панели не должен утекать в JSON: переход идёт через /goto
|
||||
assert "admin_path" not in state
|
||||
assert ADMIN.path not in str(state)
|
||||
|
||||
|
||||
def test_goto_redirects_to_panel_only_for_authorized(client):
|
||||
"""Переход в панель: без сессии 401, с сессией — редирект на секретный путь."""
|
||||
assert client.get("/api/admin/goto", follow_redirects=False).status_code == 401
|
||||
do_login(client)
|
||||
resp = client.get("/api/admin/goto", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["location"] == ADMIN.path
|
||||
|
||||
|
||||
def test_logout(client):
|
||||
@@ -96,6 +84,14 @@ def test_logout(client):
|
||||
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():
|
||||
token = make_token("admin", "s3cret", ttl=60)
|
||||
assert verify_token(token, "s3cret")
|
||||
@@ -105,51 +101,7 @@ def test_token_helpers():
|
||||
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):
|
||||
@@ -160,9 +112,11 @@ def test_set_interval(client, cache, store):
|
||||
assert cache.interval_minutes == 30
|
||||
assert cache._ttl == 1800
|
||||
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 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)])
|
||||
@@ -180,9 +134,10 @@ def test_set_link(client, cache, store):
|
||||
before = cache.calls["download"]
|
||||
resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["link"] == "https://disk.yandex.ru/i/new"
|
||||
state = resp.json()
|
||||
assert state["link"] == "https://disk.yandex.ru/i/new"
|
||||
assert cache.calls["download"] == before + 1 # перечитали таблицу
|
||||
assert 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):
|
||||
@@ -192,10 +147,12 @@ def test_set_link_rejects_broken(client, cache, store):
|
||||
resp = client.post("/api/admin/link", json={"link": "https://disk.yandex.ru/i/broken"})
|
||||
assert resp.status_code == 400
|
||||
assert "не подошла" in resp.json()["detail"]
|
||||
# ссылка и данные не изменились
|
||||
assert cache.state()["link"] == old_link
|
||||
assert cache.state()["has_data"] is True
|
||||
if store.path.exists():
|
||||
assert json.loads(store.path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
|
||||
# в файл настроек нерабочая ссылка не попала
|
||||
if store._path.exists():
|
||||
assert json.loads(store._path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
|
||||
|
||||
|
||||
def test_set_empty_link(client, cache):
|
||||
@@ -210,9 +167,9 @@ def test_set_empty_link(client, cache):
|
||||
|
||||
def test_force_refresh_ignores_ttl_and_modified(client, cache):
|
||||
do_login(client)
|
||||
client.get("/api/admin/state")
|
||||
client.get("/api/admin/state") # кэш свежий, дальше без принуждения не качали бы
|
||||
downloads = cache.calls["download"]
|
||||
cache._ttl = 3600 # кэш свежий; обычное обновление не сработало бы
|
||||
cache._ttl = 3600
|
||||
resp = client.post("/api/admin/refresh")
|
||||
assert resp.status_code == 200
|
||||
assert cache.calls["download"] == downloads + 1
|
||||
@@ -225,14 +182,15 @@ def test_force_refresh_reports_error(client, cache):
|
||||
resp = client.post("/api/admin/refresh")
|
||||
assert resp.status_code == 502
|
||||
assert cache.state()["error"] is not None
|
||||
assert cache.state()["has_data"] is True # старые данные не потеряли
|
||||
# старые данные не потеряли
|
||||
assert cache.state()["has_data"] is True
|
||||
|
||||
|
||||
# --- выключенная админка -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_disabled_without_credentials(cache, store):
|
||||
app = main.create_app(cache, admin=AdminConfig(), settings=store)
|
||||
def test_admin_disabled_without_credentials(cache):
|
||||
app = main.create_app(cache, admin=AdminConfig())
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/admin/state").status_code == 404
|
||||
assert c.post("/api/admin/login", json={"login": "a", "password": "b"}).status_code == 404
|
||||
|
||||
@@ -171,7 +171,11 @@ def test_cache_no_repeated_download(client, cache):
|
||||
|
||||
|
||||
def test_modified_unchanged_no_download(cache):
|
||||
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем."""
|
||||
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем.
|
||||
|
||||
Принудительное обновление из админки при этом качает файл всегда
|
||||
(см. test_force_refresh_ignores_ttl_and_modified).
|
||||
"""
|
||||
cache._ttl = 0
|
||||
cache.get()
|
||||
cache.get()
|
||||
@@ -179,7 +183,7 @@ def test_modified_unchanged_no_download(cache):
|
||||
assert cache.calls["modified"] >= 2
|
||||
|
||||
|
||||
def test_status_reports_file_changes(monkeypatch):
|
||||
def test_state_reports_file_changes(monkeypatch):
|
||||
"""Кэш показывает, изменилось ли содержимое файла при последнем чтении."""
|
||||
import schedule.cache as cache_mod
|
||||
from fixtures import fixture_sheets
|
||||
@@ -190,30 +194,25 @@ def test_status_reports_file_changes(monkeypatch):
|
||||
sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}"
|
||||
return build_workbook(sheets)
|
||||
|
||||
files = {"data": xlsx_with_room("215"), "modified": "m1"}
|
||||
monkeypatch.setattr(
|
||||
cache_mod, "fetch_table_modified", lambda url, timeout=20.0: files["modified"]
|
||||
)
|
||||
files = {"data": xlsx_with_room("215")}
|
||||
monkeypatch.setattr(cache_mod, "fetch_table_modified", lambda url, timeout=20.0: "m1")
|
||||
monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"])
|
||||
cache = ScheduleCache("test-link", ttl_seconds=0, retry_after=60)
|
||||
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60)
|
||||
|
||||
cache.get()
|
||||
first = cache.status()
|
||||
first = cache.state()
|
||||
assert first["content_hash"]
|
||||
assert first["changed"] is False # первая загрузка — не «изменение»
|
||||
assert first["changed_at"] is not None
|
||||
|
||||
files["data"] = xlsx_with_room("216")
|
||||
files["modified"] = "m2"
|
||||
cache.get() # ttl=0 и новый modified — перечитываем таблицу
|
||||
changed = cache.status()
|
||||
cache.force_reload()
|
||||
changed = cache.state()
|
||||
assert changed["changed"] is True
|
||||
assert changed["content_hash"] != first["content_hash"]
|
||||
|
||||
# новая ревизия файла с тем же содержимым → скачали, но изменений нет
|
||||
files["modified"] = "m3"
|
||||
cache.get()
|
||||
assert cache.status()["changed"] is False
|
||||
cache.force_reload()
|
||||
assert cache.state()["changed"] is False # тот же файл — изменений нет
|
||||
|
||||
|
||||
def test_cache_stale_while_error(cache):
|
||||
@@ -263,31 +262,3 @@ def test_cache_without_link():
|
||||
assert c.get("/groups").status_code == 502
|
||||
body = c.get("/status").json()
|
||||
assert "Ссылка" in body["error"]
|
||||
|
||||
|
||||
def test_rooms_day_and_free_include_attributes(monkeypatch):
|
||||
"""Аудитории отдаются с типом/вместимостью/оснащением и учётом доступных дней."""
|
||||
from fixtures import fixture_sheets
|
||||
from xlsx_builder import build_workbook
|
||||
|
||||
sheets = fixture_sheets()
|
||||
rooms = sheets["аудитории"]
|
||||
rooms.rows[0].append("402")
|
||||
rooms.rows[1].append("402 четверг пятница")
|
||||
|
||||
cache, _, _ = make_cache(monkeypatch, build_workbook(sheets))
|
||||
with TestClient(main.create_app(cache)) as client:
|
||||
day = client.get("/rooms-day", params={"day": "понедельник"}).json()
|
||||
by_label = {r["room"]: r for r in day["rooms"]}
|
||||
room215 = by_label["ауд. 215"]
|
||||
assert room215["type"] == "комп.класс"
|
||||
assert room215["capacity"] == 12
|
||||
assert room215["equipment"] == ["ПК"]
|
||||
assert by_label["ауд. 402"]["available_days"] == ["четверг", "пятница"]
|
||||
|
||||
free_monday = client.get("/rooms-free", params={"day": "понедельник", "period": 1}).json()
|
||||
assert not any(r["room"] == "ауд. 402" for r in free_monday)
|
||||
assert all("capacity" in r and "equipment" in r for r in free_monday)
|
||||
|
||||
free_thursday = client.get("/rooms-free", params={"day": "четверг", "period": 1}).json()
|
||||
assert any(r["room"] == "ауд. 402" for r in free_thursday)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Проверки сборки образов: все нужные файлы должны копироваться.
|
||||
|
||||
Ошибка «в Dockerfile забыли COPY какого-то модуля» роняет контейнер на импорте,
|
||||
а тесты из исходников её не видят (они запускаются из рабочей копии). Ловим
|
||||
статически, без docker.
|
||||
Ошибка «в Dockerfile забыли COPY admin.py» роняет контейнер на импорте, а
|
||||
локальные тесты её не видят (они запускаются из исходников). Ловим статически.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -34,18 +33,15 @@ def test_frontend_image_copies_page_and_static():
|
||||
assert path in stage, f"{path} не копируется в образ frontend"
|
||||
|
||||
|
||||
def test_compose_passes_required_env():
|
||||
def test_compose_passes_admin_settings():
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
for var in (
|
||||
"SCHEDULE_LINK",
|
||||
"SCHEDULE_INTERVAL_MINUTES",
|
||||
"SCHEDULE_API",
|
||||
"ADMIN_PATH",
|
||||
"ADMIN_LOGIN",
|
||||
"ADMIN_PASSWORD",
|
||||
"ADMIN_MAX_ATTEMPTS",
|
||||
"ADMIN_LOCKOUT_MINUTES",
|
||||
"SCHEDULE_INTERVAL_MINUTES",
|
||||
"SETTINGS_FILE",
|
||||
"SCHEDULE_API",
|
||||
):
|
||||
assert var in compose, f"{var} не передаётся контейнерам"
|
||||
assert "schedule_data:/data" in compose, "том для настроек админки не подключён"
|
||||
|
||||
@@ -14,7 +14,7 @@ def lessons(text):
|
||||
|
||||
def one(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()
|
||||
|
||||
|
||||
@@ -295,53 +295,6 @@ def test_lesson_with_red_between_lessons():
|
||||
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"] == "Химия"
|
||||
|
||||
|
||||
# --- пустые и экзотические ----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -384,14 +337,6 @@ def test_inline_lesson_after_room_no_blank_line():
|
||||
assert ls[1]["room"] == "ауд. 231"
|
||||
|
||||
|
||||
def test_full_name_three_words_after_subject():
|
||||
"""«Предмет Денис Сергеевич Верховцев (Сигма)» — ФИО не должно попасть в предмет."""
|
||||
result = one("Основы тестирования веб-приложений Денис Сергеевич Верховцев (Сигма)\nонлайн")
|
||||
assert result["subject"] == "Основы тестирования веб-приложений"
|
||||
assert result["teacher"] == "Денис Сергеевич Верховцев (Сигма)"
|
||||
assert result["room"] == "онлайн"
|
||||
|
||||
|
||||
def test_plain_time_paren_line():
|
||||
result = one("Параллельное программирование (пр)\nМещеряков И.А.\nауд. 213\n(начало пары в 12:30)")
|
||||
assert result["note"] == "начало пары в 12:30"
|
||||
@@ -428,20 +373,3 @@ def test_inline_note_stays_with_its_lesson():
|
||||
ls = lessons(raw)
|
||||
assert ls[0]["note"] == "с 7.09 по 20.09 пары не будет"
|
||||
assert ls[1]["note"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teacher, expected",
|
||||
[
|
||||
("Верховцев Денис Сергеевич", ["Верховцев Д.С."]),
|
||||
("Денис Сергеевич Верховцев", ["Верховцев Д.С."]),
|
||||
("Денис Верховцев (Сигма)", ["Верховцев Д."]),
|
||||
("Лабеев Игорь Владимирович", ["Лабеев И.В."]),
|
||||
("ПравоТех", ["ПравоТех"]),
|
||||
],
|
||||
)
|
||||
def test_teacher_keys_order(teacher, expected):
|
||||
"""Ключ преподавателя — «Фамилия И.О.» в любом порядке ФИО в таблице."""
|
||||
from schedule.lessons import extract_teacher_names
|
||||
|
||||
assert extract_teacher_names(teacher) == expected
|
||||
|
||||
@@ -145,18 +145,6 @@ def test_teacher_room_index(data):
|
||||
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):
|
||||
times = parsing.build_day_times(data)
|
||||
assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"}
|
||||
@@ -176,20 +164,11 @@ def test_group_schedule_shape(data):
|
||||
|
||||
def test_rooms_info(sheets):
|
||||
info = parsing.parse_rooms_info(sheets)
|
||||
assert set(info) == {"ауд. 215", "ауд. 301", "ауд. 220"}
|
||||
assert info["ауд. 215"]["info"] == "комп.класс, 12 мест"
|
||||
assert info["ауд. 215"]["type"] == "комп.класс"
|
||||
assert info["ауд. 215"]["capacity"] == 12
|
||||
assert info["ауд. 215"]["equipment"] == ["ПК"]
|
||||
|
||||
assert info["ауд. 301"]["info"] == "интерактивная панель, 24 места"
|
||||
assert info["ауд. 301"]["capacity"] == 24
|
||||
assert info["ауд. 301"]["equipment"] == ["интерактивная панель"]
|
||||
|
||||
assert info["ауд. 220"]["info"] == "20 мест"
|
||||
assert info["ауд. 220"]["type"] == ""
|
||||
assert info["ауд. 220"]["capacity"] == 20
|
||||
assert info["ауд. 220"]["equipment"] == []
|
||||
assert info == {
|
||||
"ауд. 215": {"room": "ауд. 215", "info": "комп.класс, 12 мест"},
|
||||
"ауд. 301": {"room": "ауд. 301", "info": "интерактивная панель, 24 места"},
|
||||
"ауд. 220": {"room": "ауд. 220", "info": "20 мест"},
|
||||
}
|
||||
|
||||
|
||||
def test_teachers_full(sheets):
|
||||
@@ -214,144 +193,3 @@ def test_build_room_schedule(data):
|
||||
assert set(out["days"]) == {"понедельник", "вторник"}
|
||||
assert out["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Физика"
|
||||
assert parsing.build_room_schedule(rooms, "ауд. 999", times) is None
|
||||
|
||||
|
||||
def test_room_attributes_from_descriptions():
|
||||
"""Атрибуты аудиторий разбираются из описаний вида «тип; оснащение; N мест»."""
|
||||
from xlsx_builder import SheetSpec
|
||||
|
||||
descriptions = {
|
||||
"201": "комп.класс МО и СТ; без проектора; маркер; 12 мест",
|
||||
"203": "аудитория для практических; без проектора; меловая доска; 44 места",
|
||||
"210": "комп.класс; проектор; маркер; 28 мест",
|
||||
"305": "кабинет английского; телевизор; маркер; 24 места",
|
||||
"402": "402 четверг пятница",
|
||||
"405": "пк",
|
||||
"410": "",
|
||||
}
|
||||
spec = SheetSpec(rows=[
|
||||
[None] * 6 + list(descriptions.keys()),
|
||||
[None] * 6 + list(descriptions.values()),
|
||||
])
|
||||
info = parsing.parse_rooms_info(make_sheets({parsing.ROOMS_SHEET: spec}))
|
||||
|
||||
assert info["ауд. 201"]["type"] == "комп.класс"
|
||||
assert info["ауд. 201"]["capacity"] == 12
|
||||
# «без проектора» — проектора нет, хотя слово в тексте есть
|
||||
assert info["ауд. 201"]["equipment"] == ["ПК", "маркер"]
|
||||
assert info["ауд. 201"]["available_days"] is None
|
||||
|
||||
assert info["ауд. 203"]["type"] == "аудитория для практических"
|
||||
assert info["ауд. 203"]["equipment"] == ["меловая доска"]
|
||||
assert info["ауд. 203"]["capacity"] == 44
|
||||
|
||||
assert info["ауд. 210"]["equipment"] == ["ПК", "проектор", "маркер"]
|
||||
|
||||
assert info["ауд. 305"]["type"] == "кабинет английского"
|
||||
assert info["ауд. 305"]["equipment"] == ["телевизор", "маркер"]
|
||||
|
||||
assert info["ауд. 402"]["available_days"] == ["четверг", "пятница"]
|
||||
assert info["ауд. 402"]["capacity"] is None
|
||||
assert info["ауд. 402"]["type"] == ""
|
||||
|
||||
assert info["ауд. 405"]["equipment"] == ["ПК"]
|
||||
assert info["ауд. 410"]["info"] == ""
|
||||
assert info["ауд. 410"]["type"] == ""
|
||||
|
||||
|
||||
def test_room_payload_shape():
|
||||
payload = parsing.room_payload("ауд. 215", {
|
||||
"info": "лекционная; проектор; 96 мест",
|
||||
"type": "лекционная",
|
||||
"type_group": "лекционная",
|
||||
"capacity": 96,
|
||||
"equipment": ["проектор"],
|
||||
"available_days": None,
|
||||
})
|
||||
assert payload == {
|
||||
"room": "ауд. 215",
|
||||
"info": "лекционная; проектор; 96 мест",
|
||||
"type": "лекционная",
|
||||
"type_group": "лекционная",
|
||||
"capacity": 96,
|
||||
"equipment": ["проектор"],
|
||||
"available_days": None,
|
||||
}
|
||||
|
||||
|
||||
def test_room_type_group_merges_similar():
|
||||
"""Близкие формулировки типов сводятся к общему типу для фильтра."""
|
||||
assert parsing.room_type_group("комп.класс") == "комп.класс"
|
||||
assert parsing.room_type_group("комп.класс Linux") == "комп.класс"
|
||||
assert parsing.room_type_group("компьютерный класс") == "комп.класс"
|
||||
assert parsing.room_type_group("английский") == "кабинет английского"
|
||||
assert parsing.room_type_group("кабинет английского") == "кабинет английского"
|
||||
assert parsing.room_type_group("ауд. каф. ДА") == "аудитория кафедры"
|
||||
assert parsing.room_type_group("лекционная") == "лекционная"
|
||||
assert parsing.room_type_group("лекционно-практическая") == "лекционно-практическая"
|
||||
assert parsing.room_type_group("аудитория для практических") == "аудитория для практических"
|
||||
assert parsing.room_type_group("") == ""
|
||||
|
||||
|
||||
def test_lowercase_except_abbreviations():
|
||||
"""Обозначения — в нижнем регистре, аббревиатуры остаются заглавными."""
|
||||
assert parsing.room_type("комп.класс Linux; 10 мест") == "комп.класс linux"
|
||||
assert parsing.room_type("Кабинет Английского") == "кабинет английского"
|
||||
assert parsing.room_equipment("ПК, Проектор, интернет МО") == ["ПК", "проектор"]
|
||||
assert parsing.lower_except_abbr("МО и СТ; без проектора") == "МО и СТ; без проектора"
|
||||
|
||||
|
||||
def test_room_visible_column_wins_over_hidden_duplicate():
|
||||
"""Скрытый столбец-дубль (у 223) не парсится вовсе — берём видимый."""
|
||||
from xlsx_builder import SheetSpec
|
||||
|
||||
spec = SheetSpec(
|
||||
rows=[
|
||||
[None] * 6 + ["223", "223"],
|
||||
[None] * 6 + [
|
||||
"комп.класс Linux/Windows; интерактивная панель; маркер; 20 мест",
|
||||
"компьютерный класс для МО и СТ; 24 места",
|
||||
],
|
||||
],
|
||||
hidden_cols=[(7, 7)],
|
||||
)
|
||||
info = parsing.parse_rooms_info(make_sheets({parsing.ROOMS_SHEET: spec}))
|
||||
room = info["ауд. 223"]
|
||||
assert room["capacity"] == 20
|
||||
assert "интерактивная панель" in room["equipment"]
|
||||
assert room["type"] == "комп.класс linux/windows"
|
||||
|
||||
|
||||
def test_room_only_in_hidden_column_not_parsed():
|
||||
"""Скрытое не парсим: аудитория со скрытым столбцом описания не получает."""
|
||||
from xlsx_builder import SheetSpec
|
||||
|
||||
spec = SheetSpec(
|
||||
rows=[
|
||||
[None] * 6 + ["402", "410"],
|
||||
[None] * 6 + ["402 четверг пятница", "проектор; 30 мест"],
|
||||
],
|
||||
hidden_cols=[(6, 7)],
|
||||
)
|
||||
info = parsing.parse_rooms_info(make_sheets({parsing.ROOMS_SHEET: spec}))
|
||||
assert info == {}
|
||||
|
||||
|
||||
def test_hidden_rows_not_parsed(monkeypatch):
|
||||
"""Скрытые строки листа в расписание не попадают."""
|
||||
from fixtures import fixture_sheets
|
||||
from xlsx_builder import SheetSpec, build_workbook
|
||||
|
||||
sheets = fixture_sheets()
|
||||
main = sheets[parsing.MAIN_SHEET]
|
||||
# строки: 0 заголовок, 1 с/по, 2..5 понедельник, 6..7 вторник — скроем строку с первым вторником
|
||||
spec = SheetSpec(
|
||||
rows=main.rows,
|
||||
merges=main.merges,
|
||||
hidden_cols=main.hidden_cols,
|
||||
hidden_rows=[6],
|
||||
)
|
||||
data = parsing.parse_schedule(make_sheets({parsing.MAIN_SHEET: spec}))
|
||||
tuesday = data["ИВТ-11БО"]["days"].get("вторник", {})
|
||||
assert not tuesday, "строка вторника скрыта — занятий быть не должно"
|
||||
assert data["ИВТ-11БО"]["days"]["понедельник"][1]["ч"] # понедельник на месте
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Тесты хранилища настроек админки: ссылка, интервал, история входов."""
|
||||
"""Тесты хранилища настроек администратора (ссылка, интервал)."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -17,8 +17,6 @@ def test_defaults():
|
||||
store = SettingsStore()
|
||||
assert store.link == ""
|
||||
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):
|
||||
@@ -64,46 +62,20 @@ def test_corrupt_file_is_tolerated(tmp_path):
|
||||
assert store.interval_minutes == 7
|
||||
|
||||
|
||||
def test_login_history_saved(tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
store = SettingsStore(link="l", path=path)
|
||||
|
||||
store.record_login("10.0.0.1", ok=False)
|
||||
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_no_path_keeps_settings_in_memory():
|
||||
store = SettingsStore(link="l", interval_minutes=3)
|
||||
assert store.set_interval_minutes(10) == 10
|
||||
assert store.interval_minutes == 10
|
||||
assert store.set_link("l2") == "l2"
|
||||
assert store.link == "l2"
|
||||
|
||||
|
||||
def test_unwritable_path_marks_save_error(tmp_path):
|
||||
def test_unwritable_path_does_not_crash(tmp_path):
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("x", encoding="utf-8")
|
||||
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.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():
|
||||
|
||||
@@ -28,7 +28,6 @@ class SheetSpec:
|
||||
rows: list[list]
|
||||
merges: list[str] = field(default_factory=list)
|
||||
hidden_cols: list[tuple[int, int]] = field(default_factory=list) # 0-based, инклязивно
|
||||
hidden_rows: list[int] = field(default_factory=list) # 0-based
|
||||
|
||||
|
||||
def build_workbook(sheets: dict[str, SheetSpec]) -> bytes:
|
||||
@@ -84,8 +83,7 @@ def build_workbook(sheets: dict[str, SheetSpec]) -> bytes:
|
||||
t = f' t="{t_attr}"' if t_attr else ""
|
||||
cells.append(f'<c r="{col_letter(c)}{r}"{t}><v>{escape(str(v))}</v></c>')
|
||||
if cells:
|
||||
hidden = ' hidden="1"' if (r - 1) in spec.hidden_rows else ""
|
||||
row_xml.append(f'<row r="{r}"{hidden}>{"".join(cells)}</row>')
|
||||
row_xml.append(f'<row r="{r}">{"".join(cells)}</row>')
|
||||
|
||||
merges_xml = ""
|
||||
if spec.merges:
|
||||
|
||||
Reference in New Issue
Block a user