Compare commits

9 Commits

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -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
View File

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

View File

@@ -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: ## Остановить и удалить контейнеры
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

574
README.md
View File

@@ -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://api:8000 (внутри docker-сети)
│ 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
View File

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

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

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

View File

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

View File

@@ -5,22 +5,19 @@ services:
target: api
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:

View File

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

View File

@@ -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:

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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):

View File

@@ -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 class="admin-wrap">
<div class="admin-head">
<img src="/img/owl.svg" alt="">
<div class="admin-title">Админ кабинет</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>
<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>
<button class="admin-btn" type="submit">Войти</button>
<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>
<button class="admin-btn admin-btn-lg" id="refresh" type="button">Обновить сейчас</button>
<div class="admin-card">
<h2>Состояние</h2>
<div class="admin-status" id="status-chips"></div>
<div id="state-error" class="admin-error hidden"></div>
</div>
<div id="hero-alert" class="admin-alert hidden"></div>
<div class="admin-grid">
<article class="admin-card">
<h2>Ссылка на расписание</h2>
<label class="admin-field"><span>Публичная ссылка Яндекс.Диска</span>
<div class="admin-card">
<h2>Ссылка на расписание</h2>
<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">
<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>
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
</div>
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
Это значение хранится в настройках стенда и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div>
</div>
<article class="admin-card">
<h2>Обновление</h2>
<div class="admin-inline">
<label class="admin-field admin-field-narrow"><span>Интервал, минут</span>
<input id="interval" type="number" min="1" max="1440" step="1">
</label>
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить</button>
<div class="admin-card">
<h2>Обновление</h2>
<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">
</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>

View File

@@ -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("Страница админки включена по секретному пути")

View File

@@ -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,231 +439,51 @@ 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 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);
function roomsDayTable(data) {
const periodNums = [];
data.rooms.forEach((r) => {
Object.keys(r.periods || {}).forEach((p) => {
if (!periodNums.includes(p)) periodNums.push(p);
});
});
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;
periodNums.sort((a, b) => Number(a) - Number(b));
return buildRoomsList(data, periodNums);
}
/** Перерисовать только список аудиторий (фильтры и поля ввода не трогаем). */
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) {
const card = document.createElement("div");
card.className = "room-card";
card.appendChild(roomsCardHead(room));
const periods = room.periods || {};
periodNums.forEach((p) => {
const slot = periods[p] || {};
const row = document.createElement("div");
row.className = "room-row";
const left = document.createElement("div");
left.className = "room-row-num";
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
row.appendChild(left);
const body = document.createElement("div");
body.className = "room-row-body";
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) {
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
} else {
body.innerHTML = lessonsColHTML(chEntries, znEntries);
}
row.appendChild(body);
card.appendChild(row);
data.rooms.forEach((room) => {
const card = document.createElement("div");
card.className = "room-card";
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] || {};
const row = document.createElement("div");
row.className = "room-row";
const left = document.createElement("div");
left.className = "room-row-num";
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
row.appendChild(left);
const body = document.createElement("div");
body.className = "room-row-body";
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) {
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
} else {
body.innerHTML = lessonsColHTML(chEntries, znEntries);
}
row.appendChild(body);
card.appendChild(row);
});
list.appendChild(card);
});
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;
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();

View File

@@ -28,14 +28,10 @@
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</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="Переключить тему">
<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>
</button>
</div>
<div class="layout">

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /api/

View File

@@ -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);

View File

@@ -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

View File

@@ -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)

View File

@@ -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, "том для настроек админки не подключён"

View File

@@ -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

View File

@@ -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]["ч"] # понедельник на месте

View File

@@ -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():

View File

@@ -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: