Compare commits
9 Commits
5b3c9dc00a
...
admin-pane
| Author | SHA1 | Date | |
|---|---|---|---|
| a4798c4a36 | |||
| 475eb3801e | |||
| 67a6671d90 | |||
| d177560f7d | |||
| d4b47c30bf | |||
| ab92fb44e1 | |||
| f69003be75 | |||
| e007a85f56 | |||
| cfa6a99639 |
21
.env.example
21
.env.example
@@ -1,6 +1,25 @@
|
|||||||
|
# --- расписание -------------------------------------------------------------
|
||||||
|
# Публичная ссылка на Яндекс.Диск (обязательна)
|
||||||
SCHEDULE_LINK=
|
SCHEDULE_LINK=
|
||||||
SCHEDULE_CACHE_TTL=900
|
# Интервал обновления в минутах (переживает перезапуск: правится в админке)
|
||||||
|
SCHEDULE_INTERVAL_MINUTES=15
|
||||||
|
# Пауза между повторными попытками при сбое источника, секунд
|
||||||
SCHEDULE_RETRY_AFTER=60
|
SCHEDULE_RETRY_AFTER=60
|
||||||
|
# Устаревшее: интервал в секундах (используется, только если не задан SCHEDULE_INTERVAL_MINUTES)
|
||||||
|
SCHEDULE_CACHE_TTL=900
|
||||||
|
|
||||||
|
# --- админка (пусто = выключена) ---------------------------------------------
|
||||||
|
# Секретный путь страницы админки, например /manage-7f3a91
|
||||||
|
ADMIN_PATH=
|
||||||
|
# Логин и пароль администратора
|
||||||
|
ADMIN_LOGIN=
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
# Где хранить настройки (ссылка/интервал).
|
||||||
|
# В docker по умолчанию /data/settings.json (том schedule_data);
|
||||||
|
# локально можно оставить пустым — тогда настройки живут в памяти.
|
||||||
|
SETTINGS_FILE=
|
||||||
|
|
||||||
|
# --- порты и адреса ----------------------------------------------------------
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
FRONTEND_PORT=8080
|
FRONTEND_PORT=8080
|
||||||
SCHEDULE_API=http://api:8000
|
SCHEDULE_API=http://api:8000
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt \
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
&& groupadd --system app && useradd --system --gid app app
|
&& groupadd --system app && useradd --system --gid app app \
|
||||||
|
&& mkdir -p /data && chown app:app /data
|
||||||
|
|
||||||
FROM base AS api
|
FROM base AS api
|
||||||
|
|
||||||
COPY --chown=app:app src/backend/main.py ./
|
COPY --chown=app:app src/backend/main.py ./
|
||||||
|
COPY --chown=app:app src/backend/admin.py ./
|
||||||
COPY --chown=app:app src/backend/schedule ./schedule
|
COPY --chown=app:app src/backend/schedule ./schedule
|
||||||
USER app
|
USER app
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -20,6 +22,7 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers",
|
|||||||
FROM base AS frontend
|
FROM base AS frontend
|
||||||
|
|
||||||
COPY --chown=app:app src/frontend/main.py frontend/main.py
|
COPY --chown=app:app src/frontend/main.py frontend/main.py
|
||||||
|
COPY --chown=app:app src/frontend/admin.html frontend/admin.html
|
||||||
COPY --chown=app:app src/frontend/static frontend/static
|
COPY --chown=app:app src/frontend/static frontend/static
|
||||||
USER app
|
USER app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
90
README.md
90
README.md
@@ -32,7 +32,9 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
столбцы 2–3 даты «знаменателя»
|
столбцы 2–3 даты «знаменателя»
|
||||||
столбец 4 день недели (указан один раз на блок дня)
|
столбец 4 день недели (указан один раз на блок дня)
|
||||||
столбец 5 номер пары и время (ячейка объединена на две строки пары)
|
столбец 5 номер пары и время (ячейка объединена на две строки пары)
|
||||||
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
|
столбцы 6+ группы — по одному столбцу на группу, порядок столбцов = порядок
|
||||||
|
групп на сайте (скрытые столбцы пропускаются; имена вида
|
||||||
|
«ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО»)
|
||||||
```
|
```
|
||||||
|
|
||||||
Каждый день занимает блок строк, и строки идут парами: «числитель» /
|
Каждый день занимает блок строк, и строки идут парами: «числитель» /
|
||||||
@@ -86,28 +88,72 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
- Приложение можно «установить» на главный экран (манифест + service
|
- Приложение можно «установить» на главный экран (манифест + service
|
||||||
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
|
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
|
||||||
|
|
||||||
|
## Админка
|
||||||
|
|
||||||
|
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
||||||
|
`ADMIN_PATH` (например `/manage-7f3a91`), логин и пароль — `ADMIN_LOGIN` /
|
||||||
|
`ADMIN_PASSWORD`. Если эти переменные пусты, админка полностью выключена и
|
||||||
|
маршруты `/api/admin/*` не регистрируются (прод можно не трогать).
|
||||||
|
|
||||||
|
Что умеет администратор:
|
||||||
|
|
||||||
|
- **посмотреть текущую ссылку** и состояние кэша (обновлено, изменена,
|
||||||
|
ошибка, число групп);
|
||||||
|
- **вставить новую ссылку**: она проверяется скачиванием и разбором таблицы
|
||||||
|
до сохранения — нерабочая не применится, данные на сайте не пострадают;
|
||||||
|
- **принудительно обновить расписание** — вне интервала: кнопка перечитывает
|
||||||
|
таблицу всегда, не глядя на дату изменения файла;
|
||||||
|
- **задать интервал обновления** в минутах (`1…1440`).
|
||||||
|
|
||||||
|
Фоновое обновление по интервалу работает как раньше: сначала сверяется
|
||||||
|
`modified` файла, и если таблица не менялась — скачивания не происходит.
|
||||||
|
Принудительное обновление (кнопка) скачивает файл **всегда**, поэтому его и
|
||||||
|
стоит нажимать сразу после правок в таблице. В панели и в логе при этом видно,
|
||||||
|
изменилось ли содержимое файла: `sha256`, время последнего изменения и отметка
|
||||||
|
«изменился / без изменений». Если вы поправили таблицу, а после обновления
|
||||||
|
написано «без изменений» — значит публичная ссылка Яндекса отдаёт прежнюю
|
||||||
|
версию файла (бывает с задержкой), стоит повторить обновление через минуту.
|
||||||
|
|
||||||
|
Как это устроено:
|
||||||
|
|
||||||
|
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie
|
||||||
|
(`HttpOnly`, `SameSite=Lax`, `Secure` при https), ключ — пароль админа,
|
||||||
|
срок 12 часов; неудачные попытки логируются;
|
||||||
|
- все действия — под `/api/admin/*`, без cookie отвечают `401`;
|
||||||
|
- ссылка и интервал хранятся в `SETTINGS_FILE` (в docker — том
|
||||||
|
`schedule_data`, `/data/settings.json`), поэтому переживают перезапуск;
|
||||||
|
если файл недоступен, настройки живут в памяти;
|
||||||
|
- страница панели отдаётся фронтендом по `ADMIN_PATH` с заголовком
|
||||||
|
`X-Robots-Tag: noindex`; путь нигде не публикуется и не попадает в `/`.
|
||||||
|
|
||||||
## Структура кода
|
## Структура кода
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
backend/
|
backend/
|
||||||
main.py FastAPI: маршруты, create_app(cache), /healthz
|
main.py FastAPI: маршруты, create_app(cache), /healthz
|
||||||
|
admin.py админка: cookie-сессия, ссылка, интервал, принудительное обновление
|
||||||
schedule/
|
schedule/
|
||||||
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
|
xlsx.py чтение xlsx: sharedStrings, мержи, скрытые столбцы,
|
||||||
скрытые столбцы, красный текст, серийные даты
|
красный текст, серийные даты
|
||||||
source.py скачивание файла по публичной ссылке Я.Диска
|
source.py загрузка файла по публичной ссылке Я.Диска
|
||||||
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
||||||
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
parsing.py листы → расписание, недели, индексы преподавателей/аудиторий
|
||||||
cache.py ScheduleCache: блокировка, TTL, modified, stale-while-error
|
cache.py ScheduleCache: блокировка, интервал, stale-while-error
|
||||||
errors.py ScheduleError
|
settings.py хранилище настроек админки (ссылка, интервал) в JSON-файле
|
||||||
|
errors.py ScheduleError
|
||||||
frontend/
|
frontend/
|
||||||
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
||||||
|
admin.html страница админки (вне static: прямой доступ закрыт)
|
||||||
static/
|
static/
|
||||||
index.html, app.js, style.css
|
index.html, app.js, style.css
|
||||||
manifest.json PWA: имя, иконки, standalone-режим
|
manifest.json PWA: имя, иконки, standalone-режим
|
||||||
sw.js минимальный service worker (без кэша) для установки приложения
|
sw.js минимальный service worker (без кэша) для установки приложения
|
||||||
img/ логотипы (owl.svg, owl-white.svg), иконки приложения
|
img/ логотипы (owl.svg, owl-white.svg), иконки приложения
|
||||||
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
|
(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/
|
tools/
|
||||||
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
||||||
tests/
|
tests/
|
||||||
@@ -117,7 +163,7 @@ tests/
|
|||||||
test_lessons.py все форматы ячеек
|
test_lessons.py все форматы ячеек
|
||||||
test_xlsx.py чтение книги
|
test_xlsx.py чтение книги
|
||||||
test_parsing.py разбор листов, недели, индексы
|
test_parsing.py разбор листов, недели, индексы
|
||||||
test_api.py API и поведение кэша (TTL/modified/stale, 404/400/502)
|
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -126,7 +172,7 @@ tests/
|
|||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/groups` | список групп (сначала БО, потом МО) |
|
| GET | `/groups` | группы в том порядке, в каком идут столбцы в таблице |
|
||||||
| GET | `/schedule/{group}` | расписание группы с разбором на занятия |
|
| GET | `/schedule/{group}` | расписание группы с разбором на занятия |
|
||||||
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
|
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
|
||||||
| GET | `/today?group={group}` | расписание группы на сегодня |
|
| GET | `/today?group={group}` | расписание группы на сегодня |
|
||||||
@@ -150,8 +196,12 @@ tests/
|
|||||||
| Переменная | По умолчанию | Где | Описание |
|
| Переменная | По умолчанию | Где | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
||||||
| `SCHEDULE_CACHE_TTL` | `900` | api | время жизни кэша, сек |
|
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | интервал обновления, мин (правится в админке) |
|
||||||
|
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
|
||||||
| `SCHEDULE_RETRY_AFTER` | `60` | 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_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
|
||||||
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
||||||
| `API_PORT` | `8000` | docker | внешний порт api |
|
| `API_PORT` | `8000` | docker | внешний порт api |
|
||||||
@@ -204,12 +254,22 @@ cd /opt/schedule && git pull && sudo docker compose up -d --build
|
|||||||
nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт),
|
nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт),
|
||||||
сертификат Let's Encrypt. Порты api и frontend слушаются только на loopback.
|
сертификат 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`.
|
||||||
|
|
||||||
## Замечания
|
## Замечания
|
||||||
|
|
||||||
- Принудительное обновление таблицы (пропуск кэша, `force`) из API намеренно
|
- Принудительное обновление и смена ссылки живут только в админке
|
||||||
убрано; планируется вернуть вместе с авторизацией и ролью администратора.
|
(`/api/admin/*` под cookie-сессией): публичного `force` в API нет.
|
||||||
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
|
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
|
||||||
api его придётся выносить в общее хранилище.
|
api его придётся выносить в общее хранилище (настройки админки уже в файле).
|
||||||
|
- `ADMIN_PATH` защищает только от «случайного» обнаружения панели; реальная
|
||||||
|
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
|
||||||
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
||||||
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
|
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
|
||||||
«нечётное число строк, недели могли сдвинуться».
|
«нечётное число строк, недели могли сдвинуться».
|
||||||
|
|||||||
87
deploy/README-dev.md
Normal file
87
deploy/README-dev.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Тестовый стенд (dev.schedule.our-place.ru)
|
||||||
|
|
||||||
|
Отдельный стенд для обкатки админки: свой каталог, свои порты, своя база
|
||||||
|
настроек. Прод (`/opt/schedule`, `schedule.our-place.ru`) не трогаем — он
|
||||||
|
продолжает работать как раньше.
|
||||||
|
|
||||||
|
Ветка: `admin-panel`.
|
||||||
|
|
||||||
|
## 1. Код
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/schedule-dev && sudo chown "$USER" /opt/schedule-dev
|
||||||
|
git clone -b admin-panel <адрес репозитория> /opt/schedule-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Адрес — тот же, что у прода: если прод-клон забирает обновления по SSH,
|
||||||
|
берите SSH-адрес (`ssh://git@git.our-place.ru:2222/Mermutef/schedule.git`),
|
||||||
|
если по HTTPS — HTTPS-адрес с токеном:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone -b admin-panel https://Mermutef:<токен>@git.our-place.ru/Mermutef/schedule.git /opt/schedule-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. .env
|
||||||
|
|
||||||
|
Скопировать `.env.example` в `/opt/schedule-dev/.env` и заполнить:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
SCHEDULE_LINK=https://disk.yandex.ru/i/...
|
||||||
|
SCHEDULE_INTERVAL_MINUTES=15
|
||||||
|
SCHEDULE_RETRY_AFTER=60
|
||||||
|
ADMIN_PATH=/manage-<секретная-часть>
|
||||||
|
ADMIN_LOGIN=<логин>
|
||||||
|
ADMIN_PASSWORD=<пароль>
|
||||||
|
API_PORT=127.0.0.1:8001
|
||||||
|
FRONTEND_PORT=127.0.0.1:8091
|
||||||
|
SCHEDULE_API=http://api:8000
|
||||||
|
SETTINGS_FILE=/data/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Порты 8001/8091 выбраны, чтобы не пересечься с продом (8000/8090).
|
||||||
|
`ADMIN_PATH` — секретный путь страницы админки: его нужно знать, чтобы
|
||||||
|
попасть в панель; на сайте ссылки на него нет.
|
||||||
|
|
||||||
|
## 3. Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/schedule-dev
|
||||||
|
sudo docker compose up -d --build
|
||||||
|
sudo docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Настройки админки лежат в томе `schedule-dev_schedule_data` и переживают
|
||||||
|
пересборку; посмотреть можно так:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker run --rm -v schedule-dev_schedule_data:/data alpine cat /data/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. nginx и сертификат
|
||||||
|
|
||||||
|
В `deploy/nginx-dev.conf` порт `proxy_pass` должен совпадать с `FRONTEND_PORT`
|
||||||
|
из `.env` (по умолчанию 8091; если порт меняли — поправьте и в конфиге).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /opt/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/dev.schedule.our-place.ru
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
sudo certbot --nginx -d dev.schedule.our-place.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://dev.schedule.our-place.ru/ | head -3 # 200
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://dev.schedule.our-place.ru/api/admin/state # 401 (нужен вход)
|
||||||
|
```
|
||||||
|
|
||||||
|
Дальше — открыть `https://dev.schedule.our-place.ru/<ADMIN_PATH>` в браузере,
|
||||||
|
войти логином/паролем из `.env` и проверить: смена ссылки, интервал,
|
||||||
|
«Обновить сейчас».
|
||||||
|
|
||||||
|
## Обновление стенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/schedule-dev && git pull && sudo docker compose up -d --build
|
||||||
|
```
|
||||||
47
deploy/nginx-dev.conf
Normal file
47
deploy/nginx-dev.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Тестовый стенд dev.schedule.our-place.ru
|
||||||
|
#
|
||||||
|
# Ставится рядом с прод-сайтом schedule.our-place.ru (тот не трогаем):
|
||||||
|
# отдельный каталог /opt/schedule-dev, отдельные порты, отдельная база настроек.
|
||||||
|
#
|
||||||
|
# sudo cp ~/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
|
||||||
|
# sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/
|
||||||
|
# sudo certbot --nginx -d dev.schedule.our-place.ru
|
||||||
|
# sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name dev.schedule.our-place.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name dev.schedule.our-place.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dev.schedule.our-place.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dev.schedule.our-place.ru/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
client_max_body_size 8m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
# порт должен совпадать с FRONTEND_PORT из .env стенда
|
||||||
|
proxy_pass http://127.0.0.1:8091;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
# принудительное обновление расписания из админки — до ~2 минут
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@ services:
|
|||||||
SCHEDULE_LINK: ${SCHEDULE_LINK}
|
SCHEDULE_LINK: ${SCHEDULE_LINK}
|
||||||
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
|
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
|
||||||
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
|
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
|
||||||
|
# админка: секретный путь панели и учётные данные (пусто = выключена)
|
||||||
|
ADMIN_PATH: ${ADMIN_PATH:-}
|
||||||
|
ADMIN_LOGIN: ${ADMIN_LOGIN:-}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
|
||||||
|
# настройки админки переживают пересборку благодаря тому /data
|
||||||
|
SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json}
|
||||||
|
volumes:
|
||||||
|
- schedule_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -23,6 +32,7 @@ services:
|
|||||||
target: frontend
|
target: frontend
|
||||||
environment:
|
environment:
|
||||||
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
|
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
|
||||||
|
ADMIN_PATH: ${ADMIN_PATH:-}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-8080}:8080"
|
- "${FRONTEND_PORT:-8080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -35,3 +45,6 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
schedule_data:
|
||||||
|
|||||||
207
src/backend/admin.py
Normal file
207
src/backend/admin.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""Админка расписания.
|
||||||
|
|
||||||
|
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
||||||
|
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После
|
||||||
|
входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние
|
||||||
|
между запросами не хранится.
|
||||||
|
|
||||||
|
Что умеет администратор:
|
||||||
|
GET /api/admin/state — текущая ссылка, интервал, состояние кэша;
|
||||||
|
POST /api/admin/link — проверить и поставить новую ссылку;
|
||||||
|
POST /api/admin/interval — интервал обновления в минутах;
|
||||||
|
POST /api/admin/refresh — принудительно обновить расписание сейчас.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from schedule import ScheduleCache, ScheduleError
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COOKIE_NAME = "schedule_admin"
|
||||||
|
SESSION_TTL_SECONDS = 12 * 3600
|
||||||
|
|
||||||
|
# защита от перебора пароля: не больше 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 = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self.login and self.password)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64e(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64d(text: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _same(left: str, right: str) -> bool:
|
||||||
|
"""Сравнение строк за постоянное время (compare_digest не умеет не-ASCII)."""
|
||||||
|
return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
sig = hmac.new(password.encode(), payload, hashlib.sha256).digest()
|
||||||
|
return f"{_b64e(payload)}.{_b64e(sig)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
payload_b64, sig_b64 = token.split(".", 1)
|
||||||
|
payload = _b64d(payload_b64)
|
||||||
|
expected = hmac.new(password.encode(), payload, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(expected, _b64d(sig_b64)):
|
||||||
|
return False
|
||||||
|
return int(json.loads(payload).get("exp", 0)) > time.time()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
login: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LinkBody(BaseModel):
|
||||||
|
link: str
|
||||||
|
|
||||||
|
|
||||||
|
class IntervalBody(BaseModel):
|
||||||
|
interval_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
throttle = LoginThrottle()
|
||||||
|
|
||||||
|
def require_auth(request: Request) -> None:
|
||||||
|
token = request.cookies.get(COOKIE_NAME, "")
|
||||||
|
if not token or not verify_token(token, config.password):
|
||||||
|
raise HTTPException(status_code=401, detail="Требуется вход в админку")
|
||||||
|
|
||||||
|
def _secure(request: Request) -> bool:
|
||||||
|
proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme
|
||||||
|
return proto.split(",")[0].strip() == "https"
|
||||||
|
|
||||||
|
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:
|
||||||
|
key = _client_key(request)
|
||||||
|
if throttle.blocked(key):
|
||||||
|
log.warning("Вход в админку: слишком много неудачных попыток с %s", key)
|
||||||
|
raise HTTPException(
|
||||||
|
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(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),
|
||||||
|
max_age=SESSION_TTL_SECONDS,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=_secure(request),
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
log.info("Вход в админку выполнен")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(response: Response) -> dict:
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.get("/state", dependencies=[Depends(require_auth)])
|
||||||
|
def state() -> dict:
|
||||||
|
return cache.state()
|
||||||
|
|
||||||
|
@router.post("/refresh", dependencies=[Depends(require_auth)])
|
||||||
|
def refresh() -> dict:
|
||||||
|
try:
|
||||||
|
return cache.force_reload()
|
||||||
|
except ScheduleError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc))
|
||||||
|
|
||||||
|
@router.post("/link", dependencies=[Depends(require_auth)])
|
||||||
|
def set_link(body: LinkBody) -> dict:
|
||||||
|
try:
|
||||||
|
return cache.apply_link(body.link)
|
||||||
|
except ScheduleError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
@router.post("/interval", dependencies=[Depends(require_auth)])
|
||||||
|
def set_interval(body: IntervalBody) -> dict:
|
||||||
|
try:
|
||||||
|
minutes = cache.set_interval_minutes(body.interval_minutes)
|
||||||
|
except ScheduleError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return {"interval_minutes": minutes}
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -4,17 +4,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from admin import AdminConfig, create_admin_router
|
||||||
from schedule import ScheduleCache, ScheduleError
|
from schedule import ScheduleCache, ScheduleError
|
||||||
from schedule import parsing
|
from schedule import parsing
|
||||||
|
from schedule.settings import SettingsStore, interval_from_env
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
@@ -37,40 +38,43 @@ _dotenv_path = _find_dotenv()
|
|||||||
if _dotenv_path:
|
if _dotenv_path:
|
||||||
load_dotenv(_dotenv_path)
|
load_dotenv(_dotenv_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_admin_path(raw: str | None) -> str:
|
||||||
|
"""Секретный путь панели: всегда с ведущим слэшем и без хвостового."""
|
||||||
|
path = (raw or "").strip()
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
return path.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
|
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
|
||||||
CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "900"))
|
|
||||||
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
|
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
|
||||||
|
SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip()
|
||||||
|
ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH"))
|
||||||
|
ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip()
|
||||||
|
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or ""
|
||||||
|
|
||||||
|
|
||||||
def _default_cache() -> ScheduleCache:
|
def default_settings() -> SettingsStore:
|
||||||
try:
|
return SettingsStore(
|
||||||
return ScheduleCache(SCHEDULE_LINK, ttl_seconds=CACHE_TTL, retry_after=RETRY_AFTER)
|
link=SCHEDULE_LINK,
|
||||||
except ScheduleError:
|
interval_minutes=interval_from_env(),
|
||||||
log.error("SCHEDULE_LINK не задан — API данных работать не будет")
|
path=SETTINGS_FILE or None,
|
||||||
return _BrokenCache()
|
)
|
||||||
|
|
||||||
|
|
||||||
class _BrokenCache(ScheduleCache):
|
def default_cache(settings: SettingsStore | None = None) -> ScheduleCache:
|
||||||
"""Кэш без ссылки: все запросы данных отвечают понятной ошибкой."""
|
settings = settings or default_settings()
|
||||||
|
if SCHEDULE_LINK and settings.link and settings.link != SCHEDULE_LINK:
|
||||||
def __init__(self) -> None:
|
log.warning(
|
||||||
self._link = None
|
"Ссылка в настройках (%s) отличается от SCHEDULE_LINK из окружения (%s); "
|
||||||
self._ttl = CACHE_TTL
|
"работаем по ссылке из настроек — её видно и можно поменять в админке",
|
||||||
self._retry_after = RETRY_AFTER
|
settings.link,
|
||||||
self._lock = threading.RLock()
|
SCHEDULE_LINK,
|
||||||
self._data = None
|
)
|
||||||
self._weeks = []
|
return ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
|
||||||
self._index = None
|
|
||||||
self._day_times = {}
|
|
||||||
self._teacher_full = {}
|
|
||||||
self._room_info = {}
|
|
||||||
self._loaded_at = 0.0
|
|
||||||
self._modified = None
|
|
||||||
self._error = "Не задана переменная окружения SCHEDULE_LINK"
|
|
||||||
self._last_failed = 0.0
|
|
||||||
|
|
||||||
def _ensure_fresh(self) -> None:
|
|
||||||
raise ScheduleError(self._error)
|
|
||||||
|
|
||||||
|
|
||||||
def _day_arg(day: str) -> str:
|
def _day_arg(day: str) -> str:
|
||||||
@@ -83,8 +87,14 @@ def _day_arg(day: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
def create_app(
|
||||||
cache = cache or _default_cache()
|
cache: ScheduleCache | None = None,
|
||||||
|
admin: AdminConfig | None = None,
|
||||||
|
) -> FastAPI:
|
||||||
|
cache = cache or default_cache()
|
||||||
|
admin = admin if admin is not None else AdminConfig(
|
||||||
|
path=ADMIN_PATH, login=ADMIN_LOGIN, password=ADMIN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -97,6 +107,16 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.cache = cache
|
app.state.cache = cache
|
||||||
|
app.state.admin = admin
|
||||||
|
|
||||||
|
if admin.enabled:
|
||||||
|
app.include_router(create_admin_router(cache, admin))
|
||||||
|
if admin.path:
|
||||||
|
log.info("Админка включена (секретный путь задан)")
|
||||||
|
else:
|
||||||
|
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
|
||||||
|
else:
|
||||||
|
log.info("Админка выключена: не заданы ADMIN_LOGIN/ADMIN_PASSWORD")
|
||||||
|
|
||||||
def _data() -> tuple[dict, list[dict]]:
|
def _data() -> tuple[dict, list[dict]]:
|
||||||
try:
|
try:
|
||||||
@@ -145,11 +165,9 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/groups")
|
@app.get("/groups")
|
||||||
def groups() -> list[str]:
|
def groups() -> list[str]:
|
||||||
|
"""Группы в том порядке, в каком идут столбцы в таблице."""
|
||||||
data, _ = _data()
|
data, _ = _data()
|
||||||
return sorted(
|
return list(data.keys())
|
||||||
data.keys(),
|
|
||||||
key=lambda g: (0 if g.endswith("БО") else (1 if g.endswith("МО") else 2), g),
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/teachers")
|
@app.get("/teachers")
|
||||||
def teachers() -> list[str]:
|
def teachers() -> list[str]:
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
|
|
||||||
- однопоточная перезагрузка (lock): параллельные запросы во время загрузки
|
- однопоточная перезагрузка (lock): параллельные запросы во время загрузки
|
||||||
ждут один download вместо N скачиваний;
|
ждут один download вместо N скачиваний;
|
||||||
- TTL, а также проверка modified таблицы: если таблица не менялась,
|
- интервал обновления: по его истечении сверяем modified таблицы — если файл
|
||||||
скачивание не повторяется;
|
не менялся, скачивание не повторяется; принудительное обновление из админки
|
||||||
|
(force_reload) перечитывает таблицу всегда;
|
||||||
- stale-while-error: если таблица перестала скачиваться, отдаём последнее
|
- stale-while-error: если таблица перестала скачиваться, отдаём последнее
|
||||||
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
||||||
(вместо скачивания на каждый запрос).
|
(вместо скачивания на каждый запрос);
|
||||||
|
- настройки администратора (ссылка, интервал) живут в SettingsStore и
|
||||||
|
применяются на лету: смена ссылки проверяется до сохранения, принудительное
|
||||||
|
обновление идёт вне расписания.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -18,24 +23,32 @@ from datetime import datetime
|
|||||||
|
|
||||||
from . import parsing
|
from . import parsing
|
||||||
from .errors import ScheduleError
|
from .errors import ScheduleError
|
||||||
|
from .settings import SettingsStore
|
||||||
from .source import download_schedule, fetch_table_modified
|
from .source import download_schedule, fetch_table_modified
|
||||||
from .xlsx import parse_workbook
|
from .xlsx import parse_workbook
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NO_LINK_ERROR = "Ссылка на расписание не задана"
|
||||||
|
|
||||||
|
|
||||||
class ScheduleCache:
|
class ScheduleCache:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
link: str | None,
|
link: str | None = None,
|
||||||
ttl_seconds: float = 900.0,
|
ttl_seconds: float = 900.0,
|
||||||
retry_after: float = 60.0,
|
retry_after: float = 60.0,
|
||||||
|
settings: SettingsStore | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not link:
|
self._settings = settings
|
||||||
|
if settings is not None:
|
||||||
|
link = settings.link or link or ""
|
||||||
|
ttl_seconds = settings.interval_minutes * 60
|
||||||
|
self._link = (link or "").strip()
|
||||||
|
if not self._link and settings is None:
|
||||||
raise ScheduleError("Не задана переменная окружения SCHEDULE_LINK")
|
raise ScheduleError("Не задана переменная окружения SCHEDULE_LINK")
|
||||||
self._link = link
|
self._ttl = float(ttl_seconds)
|
||||||
self._ttl = ttl_seconds
|
self._retry_after = float(retry_after)
|
||||||
self._retry_after = retry_after
|
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
self._data: dict | None = None
|
self._data: dict | None = None
|
||||||
@@ -46,8 +59,12 @@ class ScheduleCache:
|
|||||||
self._room_info: dict = {}
|
self._room_info: dict = {}
|
||||||
self._loaded_at: float = 0.0
|
self._loaded_at: float = 0.0
|
||||||
self._modified: str | None = None
|
self._modified: str | None = None
|
||||||
self._error: str | None = None
|
self._error: str | None = None if self._link else NO_LINK_ERROR
|
||||||
self._last_failed: float = 0.0
|
self._last_failed: float = 0.0
|
||||||
|
# следим, менялось ли содержимое скачанного файла
|
||||||
|
self._content_hash: str | None = None
|
||||||
|
self._changed: bool = False
|
||||||
|
self._changed_at: float = 0.0
|
||||||
|
|
||||||
# --- публичное API ---------------------------------------------------------
|
# --- публичное API ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -59,6 +76,11 @@ class ScheduleCache:
|
|||||||
def room_info(self) -> dict:
|
def room_info(self) -> dict:
|
||||||
return self._room_info
|
return self._room_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interval_minutes(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return max(1, round(self._ttl / 60))
|
||||||
|
|
||||||
def get(self) -> tuple[dict, list[dict]]:
|
def get(self) -> tuple[dict, list[dict]]:
|
||||||
"""(data, weeks); бросает ScheduleError, если данных нет совсем."""
|
"""(data, weeks); бросает ScheduleError, если данных нет совсем."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -82,18 +104,74 @@ class ScheduleCache:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
"cache_ttl_seconds": self._ttl,
|
"cache_ttl_seconds": self._ttl,
|
||||||
|
"interval_minutes": self.interval_minutes,
|
||||||
"last_updated": self._iso(self._loaded_at),
|
"last_updated": self._iso(self._loaded_at),
|
||||||
"table_modified": self._modified,
|
"table_modified": self._modified,
|
||||||
"error": self._error,
|
"error": self._error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def state(self) -> dict:
|
||||||
|
"""Полное состояние для админки."""
|
||||||
|
with self._lock:
|
||||||
|
return self._state()
|
||||||
|
|
||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
"""Первый запуск данных на старте приложения (ошибка не фатальна)."""
|
"""Первый загруз данных на старте приложения (ошибка не фатальна)."""
|
||||||
try:
|
try:
|
||||||
self.get()
|
self.get()
|
||||||
except ScheduleError as exc:
|
except ScheduleError as exc:
|
||||||
log.warning("Начальная загрузка расписания не удалась: %s", exc)
|
log.warning("Начальная загрузка расписания не удалась: %s", exc)
|
||||||
|
|
||||||
|
# --- действия администратора ------------------------------------------------
|
||||||
|
|
||||||
|
def force_reload(self) -> dict:
|
||||||
|
"""Принудительно обновить расписание вне интервала (кнопка в админке)."""
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
modified = fetch_table_modified(self._link) if self._link else None
|
||||||
|
try:
|
||||||
|
self._load(now, modified)
|
||||||
|
except ScheduleError as exc:
|
||||||
|
self._error = str(exc)
|
||||||
|
self._last_failed = now
|
||||||
|
log.warning("Принудительное обновление не удалось: %s", exc)
|
||||||
|
raise
|
||||||
|
return self._state()
|
||||||
|
|
||||||
|
def apply_link(self, link: str) -> dict:
|
||||||
|
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
|
||||||
|
link = (link or "").strip()
|
||||||
|
if not link:
|
||||||
|
raise ScheduleError("Ссылка на расписание не может быть пустой")
|
||||||
|
with self._lock:
|
||||||
|
previous_link, previous_modified = self._link, self._modified
|
||||||
|
self._link = link
|
||||||
|
self._modified = None
|
||||||
|
now = time.time()
|
||||||
|
modified = fetch_table_modified(link)
|
||||||
|
try:
|
||||||
|
self._load(now, modified)
|
||||||
|
except ScheduleError as exc:
|
||||||
|
# ссылка не подошла — возвращаемся к прежней, данные не теряем
|
||||||
|
self._link = previous_link
|
||||||
|
self._modified = previous_modified
|
||||||
|
self._last_failed = now
|
||||||
|
if self._data is None:
|
||||||
|
self._error = str(exc)
|
||||||
|
raise ScheduleError(f"Ссылка не подошла: {exc}") from exc
|
||||||
|
if self._settings is not None:
|
||||||
|
self._settings.set_link(link)
|
||||||
|
return self._state()
|
||||||
|
|
||||||
|
def set_interval_minutes(self, minutes: int | str) -> int:
|
||||||
|
if self._settings is None:
|
||||||
|
raise ScheduleError("Настройки не подключены: интервал задаётся через .env")
|
||||||
|
value = self._settings.set_interval_minutes(minutes)
|
||||||
|
with self._lock:
|
||||||
|
self._ttl = value * 60
|
||||||
|
log.info("Интервал обновления расписания: %d мин", value)
|
||||||
|
return value
|
||||||
|
|
||||||
# --- internals --------------------------------------------------------------
|
# --- internals --------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -102,6 +180,21 @@ class ScheduleCache:
|
|||||||
return None
|
return None
|
||||||
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
|
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
|
||||||
|
|
||||||
|
def _state(self) -> dict:
|
||||||
|
return {
|
||||||
|
"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],
|
||||||
|
}
|
||||||
|
|
||||||
def _ensure_fresh(self) -> None:
|
def _ensure_fresh(self) -> None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
fresh = (
|
fresh = (
|
||||||
@@ -122,6 +215,12 @@ class ScheduleCache:
|
|||||||
and now - self._last_failed < self._retry_after
|
and now - self._last_failed < self._retry_after
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
if not self._link:
|
||||||
|
self._fail(ScheduleError(NO_LINK_ERROR), now)
|
||||||
|
return
|
||||||
|
# Обновление по интервалу: сначала сверяем modified — если файл не
|
||||||
|
# менялся, скачивание не повторяем. Принудительное обновление (кнопка
|
||||||
|
# в админке) идёт через force_reload и качает файл всегда.
|
||||||
modified = fetch_table_modified(self._link)
|
modified = fetch_table_modified(self._link)
|
||||||
if (
|
if (
|
||||||
self._data is not None
|
self._data is not None
|
||||||
@@ -133,31 +232,48 @@ class ScheduleCache:
|
|||||||
self._error = None
|
self._error = None
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
log.info("Загружаю расписание по ссылке %s ...", self._link)
|
self._load(now, modified)
|
||||||
started = time.time()
|
|
||||||
xlsx = download_schedule(self._link)
|
|
||||||
sheets = parse_workbook(xlsx)
|
|
||||||
data = parsing.parse_schedule(sheets)
|
|
||||||
weeks = parsing.extract_week_ranges(sheets)
|
|
||||||
index = parsing.build_teacher_room_index(data)
|
|
||||||
self._data = data
|
|
||||||
self._weeks = weeks
|
|
||||||
self._index = index
|
|
||||||
self._day_times = parsing.build_day_times(data)
|
|
||||||
self._teacher_full = parsing.parse_teachers_full(sheets)
|
|
||||||
self._room_info = parsing.parse_rooms_info(sheets)
|
|
||||||
self._modified = modified
|
|
||||||
self._loaded_at = now
|
|
||||||
self._error = None
|
|
||||||
log.info(
|
|
||||||
"Расписание загружено: %d групп за %.1f с", len(data), time.time() - started
|
|
||||||
)
|
|
||||||
except ScheduleError as exc:
|
except ScheduleError as exc:
|
||||||
self._fail(exc, now)
|
self._fail(exc, now)
|
||||||
except Exception as exc: # непредвиденное в парсинге/сети
|
except Exception as exc: # непредвиденное в парсинге/сети
|
||||||
log.exception("Ошибка при загрузке расписания")
|
log.exception("Ошибка при загрузке расписания")
|
||||||
self._fail(ScheduleError(f"Не удалось обработать таблицу: {exc}"), now)
|
self._fail(ScheduleError(f"Не удалось обработать таблицу: {exc}"), now)
|
||||||
|
|
||||||
|
def _load(self, now: float, modified: str | None) -> None:
|
||||||
|
"""Скачать и разобрать таблицу, заменить состояние. Бросает ScheduleError."""
|
||||||
|
if not self._link:
|
||||||
|
raise ScheduleError(NO_LINK_ERROR)
|
||||||
|
log.info("Загружаю расписание по ссылке %s ...", self._link)
|
||||||
|
started = time.time()
|
||||||
|
xlsx = download_schedule(self._link)
|
||||||
|
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)
|
||||||
|
index = parsing.build_teacher_room_index(data)
|
||||||
|
self._data = data
|
||||||
|
self._weeks = weeks
|
||||||
|
self._index = index
|
||||||
|
self._day_times = parsing.build_day_times(data)
|
||||||
|
self._teacher_full = parsing.parse_teachers_full(sheets)
|
||||||
|
self._room_info = parsing.parse_rooms_info(sheets)
|
||||||
|
self._modified = modified
|
||||||
|
self._loaded_at = now
|
||||||
|
self._error = None
|
||||||
|
self._content_hash = digest
|
||||||
|
self._changed = changed
|
||||||
|
if changed or first_load:
|
||||||
|
self._changed_at = now
|
||||||
|
log.info(
|
||||||
|
"Расписание загружено: %d групп за %.1f с; файл %s (sha256 %s)",
|
||||||
|
len(data),
|
||||||
|
time.time() - started,
|
||||||
|
"изменился" if changed else ("первая загрузка" if first_load else "не изменился"),
|
||||||
|
digest[:12],
|
||||||
|
)
|
||||||
|
|
||||||
def _fail(self, exc: ScheduleError, now: float) -> None:
|
def _fail(self, exc: ScheduleError, now: float) -> None:
|
||||||
self._error = str(exc)
|
self._error = str(exc)
|
||||||
self._last_failed = now
|
self._last_failed = now
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ _COL_DAY = 4 # день недели
|
|||||||
_COL_PERIOD = 5 # пара и время
|
_COL_PERIOD = 5 # пара и время
|
||||||
_FIRST_GROUP_COL = 6
|
_FIRST_GROUP_COL = 6
|
||||||
|
|
||||||
_GROUP_RE = re.compile(r"^[А-ЯЁ]{2,4}-\d")
|
# Имя группы: буквенные части через дефис, номер потока и необязательный
|
||||||
|
# суффикс: «ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО», «ИТ-ТОП-12БО».
|
||||||
|
_GROUP_RE = re.compile(r"^[А-ЯЁ]+(?:-[А-ЯЁ]+)*-\d{1,2}[А-ЯЁ]{0,3}$")
|
||||||
_PERIOD_RE = re.compile(r"^\s*(\d{1,2})\s*(.*)$")
|
_PERIOD_RE = re.compile(r"^\s*(\d{1,2})\s*(.*)$")
|
||||||
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.I)
|
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.I)
|
||||||
_SEMESTER_DATE_RE = re.compile(r"(\d{1,2}\.\d{1,2}\.\d{2})")
|
_SEMESTER_DATE_RE = re.compile(r"(\d{1,2}\.\d{1,2}\.\d{2})")
|
||||||
@@ -108,13 +110,16 @@ def current_parity(week_ranges: list[dict], today: date | None = None) -> str |
|
|||||||
|
|
||||||
|
|
||||||
def _find_group_columns(header: list[str], hidden_cols: set[int]) -> dict[int, str]:
|
def _find_group_columns(header: list[str], hidden_cols: set[int]) -> dict[int, str]:
|
||||||
|
"""Столбцы групп: имя из заголовка, порядок — как столбцы в таблице."""
|
||||||
columns: dict[int, str] = {}
|
columns: dict[int, str] = {}
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for idx, cell in enumerate(header):
|
for idx, cell in enumerate(header):
|
||||||
if idx in hidden_cols:
|
if idx in hidden_cols:
|
||||||
continue
|
continue
|
||||||
name = _normalize_header(cell)
|
name = _normalize_header(cell)
|
||||||
if name and _GROUP_RE.match(name) and name not in seen:
|
if not name or name in seen or len(name) > 24:
|
||||||
|
continue
|
||||||
|
if _GROUP_RE.match(name):
|
||||||
columns[idx] = name
|
columns[idx] = name
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
return columns
|
return columns
|
||||||
|
|||||||
153
src/backend/schedule/settings.py
Normal file
153
src/backend/schedule/settings.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Настройки расписания, которые правит администратор.
|
||||||
|
|
||||||
|
Хранятся в JSON-файле (если задан путь) — так ссылка и интервал переживают
|
||||||
|
перезапуск контейнера. Если путь не задан, настройки живут только в памяти
|
||||||
|
(поведение прежних версий, прод можно не трогать).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .errors import ScheduleError
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_INTERVAL_MINUTES = 15
|
||||||
|
MIN_INTERVAL_MINUTES = 1
|
||||||
|
MAX_INTERVAL_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
link: str = ""
|
||||||
|
interval_minutes: int = DEFAULT_INTERVAL_MINUTES
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
|
||||||
|
"""Интервал из SCHEDULE_INTERVAL_MINUTES, иначе из старого SCHEDULE_CACHE_TTL (сек)."""
|
||||||
|
raw_minutes = (os.getenv("SCHEDULE_INTERVAL_MINUTES") or "").strip()
|
||||||
|
if raw_minutes:
|
||||||
|
try:
|
||||||
|
return clamp_interval(int(float(raw_minutes)))
|
||||||
|
except ValueError:
|
||||||
|
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)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_interval(minutes: int) -> int:
|
||||||
|
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsStore:
|
||||||
|
"""Потокобезопасное хранилище настроек администратора."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
link: str = "",
|
||||||
|
interval_minutes: int = DEFAULT_INTERVAL_MINUTES,
|
||||||
|
path: str | os.PathLike | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._path = Path(path) if path else None
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._settings = Settings(
|
||||||
|
link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes)
|
||||||
|
)
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# --- чтение -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link(self) -> str:
|
||||||
|
with self._lock:
|
||||||
|
return self._settings.link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interval_minutes(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return self._settings.interval_minutes
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
return self._settings.as_dict()
|
||||||
|
|
||||||
|
# --- запись -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_link(self, link: str) -> str:
|
||||||
|
link = (link or "").strip()
|
||||||
|
if not link:
|
||||||
|
raise ScheduleError("Ссылка на расписание не может быть пустой")
|
||||||
|
with self._lock:
|
||||||
|
self._settings.link = link
|
||||||
|
self._save()
|
||||||
|
return link
|
||||||
|
|
||||||
|
def set_interval_minutes(self, minutes: int | str) -> int:
|
||||||
|
try:
|
||||||
|
value = int(minutes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ScheduleError("Интервал должен быть целым числом минут") from None
|
||||||
|
if not MIN_INTERVAL_MINUTES <= value <= MAX_INTERVAL_MINUTES:
|
||||||
|
raise ScheduleError(
|
||||||
|
f"Интервал должен быть от {MIN_INTERVAL_MINUTES} до {MAX_INTERVAL_MINUTES} минут"
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._settings.interval_minutes = value
|
||||||
|
self._save()
|
||||||
|
return value
|
||||||
|
|
||||||
|
# --- файл -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self._path or not self._path.is_file():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
log.warning("Не читается файл настроек %s: %s (остаюсь на значениях из окружения)", self._path, exc)
|
||||||
|
return
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path)
|
||||||
|
return
|
||||||
|
link = str(raw.get("link") or "").strip()
|
||||||
|
if link:
|
||||||
|
self._settings.link = link
|
||||||
|
if raw.get("interval_minutes") is not None:
|
||||||
|
try:
|
||||||
|
self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
log.warning("interval_minutes в %s не число, игнорирую", self._path)
|
||||||
|
log.info(
|
||||||
|
"Настройки из %s: интервал %s мин, ссылка %s",
|
||||||
|
self._path,
|
||||||
|
self._settings.interval_minutes,
|
||||||
|
"задана" if self._settings.link else "пустая",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
if not self._path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(self._settings.as_dict(), ensure_ascii=False, indent=1),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tmp.replace(self._path)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -12,18 +13,27 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources"
|
CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources"
|
||||||
|
|
||||||
|
# Публичная ссылка может кэшироваться на стороне Диска: просим не кэшировать
|
||||||
|
# и добавляем в запрос метку времени, чтобы получать текущую версию файла.
|
||||||
|
_NO_CACHE = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||||
|
|
||||||
|
|
||||||
|
def _bust() -> dict:
|
||||||
|
return {"_": int(time.time() * 1000)}
|
||||||
|
|
||||||
|
|
||||||
def download_schedule(public_url: str, timeout: float = 60.0) -> bytes:
|
def download_schedule(public_url: str, timeout: float = 60.0) -> bytes:
|
||||||
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{CLOUD_API}/download",
|
f"{CLOUD_API}/download",
|
||||||
params={"public_key": public_url},
|
params={"public_key": public_url, **_bust()},
|
||||||
|
headers=_NO_CACHE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
href = resp.json()["href"]
|
href = resp.json()["href"]
|
||||||
file_resp = requests.get(href, timeout=timeout)
|
file_resp = requests.get(href, headers=_NO_CACHE, timeout=timeout)
|
||||||
file_resp.raise_for_status()
|
file_resp.raise_for_status()
|
||||||
return file_resp.content
|
return file_resp.content
|
||||||
except (requests.RequestException, KeyError, ValueError) as exc:
|
except (requests.RequestException, KeyError, ValueError) as exc:
|
||||||
@@ -35,7 +45,8 @@ def fetch_table_modified(public_url: str, timeout: float = 20.0) -> str | None:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
CLOUD_API,
|
CLOUD_API,
|
||||||
params={"public_key": public_url},
|
params={"public_key": public_url, **_bust()},
|
||||||
|
headers=_NO_CACHE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
325
src/frontend/admin.html
Normal file
325
src/frontend/admin.html
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Админ кабинет</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<style>
|
||||||
|
.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; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.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: 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;
|
||||||
|
border: 1px solid var(--accent); background: var(--accent); color: #fff;
|
||||||
|
}
|
||||||
|
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
|
||||||
|
.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-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;
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
.toast .toast-close:hover { color: var(--text); }
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-wrap">
|
||||||
|
<div class="admin-head">
|
||||||
|
<img src="/img/owl.svg" alt="">
|
||||||
|
<div class="admin-title">Админ кабинет</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-error hidden"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel" class="hidden">
|
||||||
|
<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 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/...">
|
||||||
|
</div>
|
||||||
|
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
|
||||||
|
Это значение хранится в настройках стенда и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-wrap"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const TOAST_MS = 15000;
|
||||||
|
|
||||||
|
/** Всплывающее уведомление: само скрывается через 15 секунд. */
|
||||||
|
function toast(message, kind = "info", ms = TOAST_MS) {
|
||||||
|
const wrap = $("toast-wrap");
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "toast " + kind;
|
||||||
|
const text = document.createElement("div");
|
||||||
|
text.className = "toast-text";
|
||||||
|
text.textContent = message;
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.className = "toast-close";
|
||||||
|
close.textContent = "✕";
|
||||||
|
close.setAttribute("aria-label", "Закрыть");
|
||||||
|
el.append(text, close);
|
||||||
|
wrap.appendChild(el);
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
el.classList.add("hide");
|
||||||
|
setTimeout(() => el.remove(), 300);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(hide, ms);
|
||||||
|
close.addEventListener("click", hide);
|
||||||
|
return hide;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch("/api/admin" + path, { credentials: "same-origin", ...options });
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 0, data: { detail: "Сеть недоступна: " + err.message } };
|
||||||
|
}
|
||||||
|
let data = null;
|
||||||
|
try { data = await resp.json(); } catch (_) {}
|
||||||
|
return { ok: resp.ok, status: resp.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path, payload) {
|
||||||
|
return api(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 || "";
|
||||||
|
$("interval").value = state.interval_minutes;
|
||||||
|
const err = $("state-error");
|
||||||
|
err.textContent = state.error ? "Ошибка загрузки: " + state.error : "";
|
||||||
|
err.classList.toggle("hidden", !state.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin(show) {
|
||||||
|
$("login-card").classList.toggle("hidden", !show);
|
||||||
|
$("panel").classList.toggle("hidden", show);
|
||||||
|
if (show) $("login").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function busy(button, text) {
|
||||||
|
const original = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = text;
|
||||||
|
return () => { button.disabled = false; button.textContent = original; };
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
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");
|
||||||
|
error.classList.add("hidden");
|
||||||
|
const r = await post("/login", { login: $("login").value, password: $("password").value });
|
||||||
|
if (!r.ok) {
|
||||||
|
error.textContent = (r.data && r.data.detail) || "Не удалось войти";
|
||||||
|
error.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("password").value = "";
|
||||||
|
toast("Вход выполнен.", "ok", 5000);
|
||||||
|
await loadState();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
|
||||||
|
const r = await post("/link", { link: $("link").value });
|
||||||
|
if (!r.ok) {
|
||||||
|
toast("Ссылка не принята: " + ((r.data && r.data.detail) || r.status), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderState(r.data);
|
||||||
|
toast("Ссылка сохранена, расписание загружено.", "ok");
|
||||||
|
}));
|
||||||
|
|
||||||
|
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
|
||||||
|
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
|
||||||
|
if (!r.ok) {
|
||||||
|
toast("Интервал не сохранён: " + ((r.data && r.data.detail) || r.status), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast(`Интервал обновления: ${r.data.interval_minutes} мин.`, "ok");
|
||||||
|
await loadState();
|
||||||
|
}));
|
||||||
|
|
||||||
|
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
||||||
|
const r = await post("/refresh");
|
||||||
|
if (!r.ok) {
|
||||||
|
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
|
||||||
|
} else if (r.data) {
|
||||||
|
renderState(r.data);
|
||||||
|
toast(
|
||||||
|
r.data.changed
|
||||||
|
? "Готово: в файле обнаружены изменения — расписание обновлено."
|
||||||
|
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
|
||||||
|
r.data.changed ? "ok" : "info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await loadState();
|
||||||
|
}));
|
||||||
|
|
||||||
|
$("logout").addEventListener("click", async () => {
|
||||||
|
await post("/logout");
|
||||||
|
showLogin(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadState();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
"""Фронтенд расписания: статики + прокси к API бэкенда."""
|
"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки.
|
||||||
|
|
||||||
|
Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py):
|
||||||
|
сам путь не публикуется, входа с сайта нет.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -21,7 +26,10 @@ load_dotenv(Path(__file__).parent / ".env")
|
|||||||
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
||||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||||
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
|
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
|
||||||
|
ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/")
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
# страница админки лежит ВНЕ static: прямой доступ по /admin.html запрещён
|
||||||
|
ADMIN_PAGE = Path(__file__).parent / "admin.html"
|
||||||
|
|
||||||
app = FastAPI(title="Расписание — фронт")
|
app = FastAPI(title="Расписание — фронт")
|
||||||
|
|
||||||
@@ -43,7 +51,33 @@ def _proxy(path: str, params: dict) -> object:
|
|||||||
except Exception:
|
except Exception:
|
||||||
detail = resp.text
|
detail = resp.text
|
||||||
raise HTTPException(status_code=resp.status_code, detail=detail)
|
raise HTTPException(status_code=resp.status_code, detail=detail)
|
||||||
return resp.json()
|
# данные не кэшируем: после «Обновить сейчас» браузер должен видеть свежее
|
||||||
|
return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response:
|
||||||
|
"""Проксирует админ-запрос как есть: метод, тело, куки и Set-Cookie."""
|
||||||
|
try:
|
||||||
|
resp = requests.request(
|
||||||
|
method,
|
||||||
|
f"{SCHEDULE_API}/{path}",
|
||||||
|
data=body or None,
|
||||||
|
headers=headers,
|
||||||
|
timeout=PROXY_TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||||||
|
out = Response(
|
||||||
|
content=resp.content,
|
||||||
|
status_code=resp.status_code,
|
||||||
|
media_type=resp.headers.get("content-type"),
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
# Set-Cookie (вход/выход админки) передаём как есть
|
||||||
|
raw_headers = getattr(resp.raw, "headers", None)
|
||||||
|
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []:
|
||||||
|
out.headers.append("set-cookie", cookie)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- API-маршруты -----------------------------------------------------------
|
# --- API-маршруты -----------------------------------------------------------
|
||||||
@@ -99,6 +133,22 @@ def api_status():
|
|||||||
return _proxy("status", {})
|
return _proxy("status", {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route("/api/admin/{rest:path}", methods=["GET", "POST"])
|
||||||
|
async def api_admin(rest: str, request: Request):
|
||||||
|
"""Админ-API: тело и куки передаём в бэкенд, ответ — обратно как есть."""
|
||||||
|
body = await request.body()
|
||||||
|
headers = {}
|
||||||
|
content_type = request.headers.get("content-type")
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
cookie = request.headers.get("cookie")
|
||||||
|
if cookie:
|
||||||
|
headers["Cookie"] = cookie
|
||||||
|
return await run_in_threadpool(
|
||||||
|
_proxy_admin, request.method, f"api/admin/{rest}", body, headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- статики -----------------------------------------------------------------
|
# --- статики -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +157,20 @@ def index():
|
|||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
if ADMIN_PATH:
|
||||||
|
@app.get(ADMIN_PATH, include_in_schema=False)
|
||||||
|
def admin_page():
|
||||||
|
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
|
||||||
|
return FileResponse(
|
||||||
|
ADMIN_PAGE,
|
||||||
|
headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("Страница админки включена по секретному пути")
|
||||||
|
else:
|
||||||
|
log.info("ADMIN_PATH не задан — страница админки выключена")
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def make_sheets(sheets: dict[str, SheetSpec]):
|
|||||||
return parse_workbook(build_workbook(sheets))
|
return parse_workbook(build_workbook(sheets))
|
||||||
|
|
||||||
|
|
||||||
def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified"):
|
def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified", settings=None):
|
||||||
"""ScheduleCache с заменёнными сетевыми функциями."""
|
"""ScheduleCache с заменёнными сетевыми функциями."""
|
||||||
import schedule.cache as cache_mod
|
import schedule.cache as cache_mod
|
||||||
from schedule import ScheduleCache
|
from schedule import ScheduleCache
|
||||||
@@ -107,5 +107,5 @@ def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified"
|
|||||||
|
|
||||||
monkeypatch.setattr(cache_mod, "download_schedule", fake_download)
|
monkeypatch.setattr(cache_mod, "download_schedule", fake_download)
|
||||||
monkeypatch.setattr(cache_mod, "fetch_table_modified", fake_modified)
|
monkeypatch.setattr(cache_mod, "fetch_table_modified", fake_modified)
|
||||||
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60)
|
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60, settings=settings)
|
||||||
return cache, calls, fail
|
return cache, calls, fail
|
||||||
|
|||||||
197
tests/test_admin.py
Normal file
197
tests/test_admin.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
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
|
||||||
|
|
||||||
|
ADMIN = AdminConfig(path="/secret-panel", login="admin", password="s3cret")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def store(tmp_path):
|
||||||
|
return SettingsStore(link="test-link", interval_minutes=15, path=tmp_path / "settings.json")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def cache(monkeypatch, store):
|
||||||
|
c, calls, fail = make_cache(monkeypatch, fixture_xlsx(), settings=store)
|
||||||
|
c.calls = calls
|
||||||
|
c.fail = fail
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(cache):
|
||||||
|
app = main.create_app(cache, admin=ADMIN)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def do_login(client, login="admin", password="s3cret"):
|
||||||
|
return client.post("/api/admin/login", json={"login": login, "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
# --- авторизация ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method, path, payload",
|
||||||
|
[
|
||||||
|
("get", "/api/admin/state", None),
|
||||||
|
("post", "/api/admin/refresh", {}),
|
||||||
|
("post", "/api/admin/link", {"link": "https://disk.yandex.ru/i/x"}),
|
||||||
|
("post", "/api/admin/interval", {"interval_minutes": 30}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_requires_login(client, method, path, payload):
|
||||||
|
resp = getattr(client, method)(path, json=payload) if method == "post" else client.get(path)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"login, password",
|
||||||
|
[("admin", "неверный"), ("неверный", "s3cret"), ("", "")],
|
||||||
|
)
|
||||||
|
def test_login_rejects_wrong_credentials(client, login, password):
|
||||||
|
resp = client.post("/api/admin/login", json={"login": login, "password": password})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "schedule_admin" not in client.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_and_state(client, cache):
|
||||||
|
assert do_login(client).status_code == 200
|
||||||
|
resp = client.get("/api/admin/state")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
state = resp.json()
|
||||||
|
assert state["link"] == "test-link"
|
||||||
|
assert state["interval_minutes"] == 15
|
||||||
|
assert state["groups"] == 2
|
||||||
|
assert state["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout(client):
|
||||||
|
do_login(client)
|
||||||
|
assert client.get("/api/admin/state").status_code == 200
|
||||||
|
assert client.post("/api/admin/logout").status_code == 200
|
||||||
|
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")
|
||||||
|
assert not verify_token(token, "другой-пароль")
|
||||||
|
assert not verify_token(token + "x", "s3cret")
|
||||||
|
assert not verify_token("мусор", "s3cret")
|
||||||
|
assert not verify_token(make_token("admin", "s3cret", ttl=-5), "s3cret")
|
||||||
|
|
||||||
|
|
||||||
|
# --- интервал ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_interval(client, cache, store):
|
||||||
|
do_login(client)
|
||||||
|
resp = client.post("/api/admin/interval", json={"interval_minutes": 30})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["interval_minutes"] == 30
|
||||||
|
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"))
|
||||||
|
assert saved["interval_minutes"] == 30
|
||||||
|
# новый процесс видит сохранённое значение
|
||||||
|
assert SettingsStore(path=store._path).interval_minutes == 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value, status", [(0, 400), (1441, 400), (-5, 400), ("abc", 422)])
|
||||||
|
def test_set_interval_validation(client, value, status):
|
||||||
|
do_login(client)
|
||||||
|
resp = client.post("/api/admin/interval", json={"interval_minutes": value})
|
||||||
|
assert resp.status_code == status
|
||||||
|
|
||||||
|
|
||||||
|
# --- смена ссылки ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_link(client, cache, store):
|
||||||
|
do_login(client)
|
||||||
|
before = cache.calls["download"]
|
||||||
|
resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_link_rejects_broken(client, cache, store):
|
||||||
|
do_login(client)
|
||||||
|
old_link = cache.state()["link"]
|
||||||
|
cache.fail["download"] = True
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_empty_link(client, cache):
|
||||||
|
do_login(client)
|
||||||
|
resp = client.post("/api/admin/link", json={"link": " "})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert cache.state()["has_data"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- принудительное обновление ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_refresh_ignores_ttl_and_modified(client, cache):
|
||||||
|
do_login(client)
|
||||||
|
client.get("/api/admin/state") # кэш свежий, дальше без принуждения не качали бы
|
||||||
|
downloads = cache.calls["download"]
|
||||||
|
cache._ttl = 3600
|
||||||
|
resp = client.post("/api/admin/refresh")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert cache.calls["download"] == downloads + 1
|
||||||
|
assert resp.json()["has_data"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_refresh_reports_error(client, cache):
|
||||||
|
do_login(client)
|
||||||
|
cache.fail["download"] = True
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# --- выключенная админка -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
assert c.get("/healthz").status_code == 200
|
||||||
@@ -46,6 +46,21 @@ def test_groups(client):
|
|||||||
assert client.get("/groups").json() == ["ИВТ-11БО", "ИТ-12БО"]
|
assert client.get("/groups").json() == ["ИВТ-11БО", "ИТ-12БО"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups_keep_sheet_order(monkeypatch):
|
||||||
|
"""GET /groups отдаёт группы в порядке столбцов таблицы, а не по алфавиту."""
|
||||||
|
from fixtures import fixture_sheets
|
||||||
|
from xlsx_builder import build_workbook
|
||||||
|
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
header = sheets["Бакалавры и магистры"].rows[0]
|
||||||
|
header[6] = "ЯЯ-ТОП-11БО\nсеместр до 11.01.27"
|
||||||
|
header[7] = "АА-11БО\nсеместр до 29.12.26"
|
||||||
|
|
||||||
|
cache, _, _ = make_cache(monkeypatch, build_workbook(sheets))
|
||||||
|
with TestClient(main.create_app(cache)) as client:
|
||||||
|
assert client.get("/groups").json() == ["ЯЯ-ТОП-11БО", "АА-11БО"]
|
||||||
|
|
||||||
|
|
||||||
def test_teachers(client):
|
def test_teachers(client):
|
||||||
assert client.get("/teachers").json() == [
|
assert client.get("/teachers").json() == [
|
||||||
"Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.",
|
"Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.",
|
||||||
@@ -155,14 +170,51 @@ def test_cache_no_repeated_download(client, cache):
|
|||||||
assert cache.calls["download"] == 1
|
assert cache.calls["download"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cache_modified_unchanged_no_download(cache):
|
def test_modified_unchanged_no_download(cache):
|
||||||
cache._ttl = 0 # даже с истёкшим TTL
|
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем.
|
||||||
|
|
||||||
|
Принудительное обновление из админки при этом качает файл всегда
|
||||||
|
(см. test_force_refresh_ignores_ttl_and_modified).
|
||||||
|
"""
|
||||||
|
cache._ttl = 0
|
||||||
cache.get()
|
cache.get()
|
||||||
cache.get()
|
cache.get()
|
||||||
assert cache.calls["download"] == 1
|
assert cache.calls["download"] == 1
|
||||||
assert cache.calls["modified"] >= 2
|
assert cache.calls["modified"] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_reports_file_changes(monkeypatch):
|
||||||
|
"""Кэш показывает, изменилось ли содержимое файла при последнем чтении."""
|
||||||
|
import schedule.cache as cache_mod
|
||||||
|
from fixtures import fixture_sheets
|
||||||
|
from xlsx_builder import build_workbook
|
||||||
|
|
||||||
|
def xlsx_with_room(room: str) -> bytes:
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}"
|
||||||
|
return build_workbook(sheets)
|
||||||
|
|
||||||
|
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=3600, retry_after=60)
|
||||||
|
|
||||||
|
cache.get()
|
||||||
|
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")
|
||||||
|
cache.force_reload()
|
||||||
|
changed = cache.state()
|
||||||
|
assert changed["changed"] is True
|
||||||
|
assert changed["content_hash"] != first["content_hash"]
|
||||||
|
|
||||||
|
cache.force_reload()
|
||||||
|
assert cache.state()["changed"] is False # тот же файл — изменений нет
|
||||||
|
|
||||||
|
|
||||||
def test_cache_stale_while_error(cache):
|
def test_cache_stale_while_error(cache):
|
||||||
cache._ttl = 0 # форсим повторную попытку
|
cache._ttl = 0 # форсим повторную попытку
|
||||||
cache.get()
|
cache.get()
|
||||||
@@ -196,12 +248,17 @@ def test_cache_failure_without_data_raises(cache):
|
|||||||
assert cache.status()["last_updated"] is None
|
assert cache.status()["last_updated"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_broken_cache_http(client=None):
|
def test_cache_without_link():
|
||||||
|
"""Без ссылки приложение живёт: /healthz есть, данные отвечают понятной ошибкой."""
|
||||||
from fastapi.testclient import TestClient as TC
|
from fastapi.testclient import TestClient as TC
|
||||||
|
|
||||||
app = main.create_app(main._BrokenCache())
|
from schedule import ScheduleCache
|
||||||
|
from schedule.settings import SettingsStore
|
||||||
|
|
||||||
|
cache = ScheduleCache(settings=SettingsStore(link=""), retry_after=60)
|
||||||
|
app = main.create_app(cache)
|
||||||
with TC(app) as c:
|
with TC(app) as c:
|
||||||
assert c.get("/healthz").status_code == 200
|
assert c.get("/healthz").status_code == 200
|
||||||
assert c.get("/groups").status_code == 502
|
assert c.get("/groups").status_code == 502
|
||||||
body = c.get("/status").json()
|
body = c.get("/status").json()
|
||||||
assert "SCHEDULE_LINK" in body["error"]
|
assert "Ссылка" in body["error"]
|
||||||
|
|||||||
47
tests/test_deploy.py
Normal file
47
tests/test_deploy.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Проверки сборки образов: все нужные файлы должны копироваться.
|
||||||
|
|
||||||
|
Ошибка «в Dockerfile забыли COPY admin.py» роняет контейнер на импорте, а
|
||||||
|
локальные тесты её не видят (они запускаются из исходников). Ловим статически.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DOCKERFILE = ROOT / "Dockerfile"
|
||||||
|
COMPOSE = ROOT / "docker-compose.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def _stage(name: str) -> str:
|
||||||
|
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||||
|
part = dockerfile.split(f"FROM base AS {name}", 1)[1]
|
||||||
|
for other in ("api", "frontend"):
|
||||||
|
if other != name:
|
||||||
|
part = part.split(f"FROM base AS {other}", 1)[0]
|
||||||
|
return part
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_image_copies_every_backend_module():
|
||||||
|
stage = _stage("api")
|
||||||
|
for module in sorted((ROOT / "src" / "backend").glob("*.py")):
|
||||||
|
assert module.name in stage, f"{module.name} не копируется в образ api"
|
||||||
|
assert "src/backend/schedule" in stage, "пакет schedule не копируется в образ api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_image_copies_page_and_static():
|
||||||
|
stage = _stage("frontend")
|
||||||
|
for path in ("src/frontend/main.py", "src/frontend/admin.html", "src/frontend/static"):
|
||||||
|
assert path in stage, f"{path} не копируется в образ frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_passes_admin_settings():
|
||||||
|
compose = COMPOSE.read_text(encoding="utf-8")
|
||||||
|
for var in (
|
||||||
|
"ADMIN_PATH",
|
||||||
|
"ADMIN_LOGIN",
|
||||||
|
"ADMIN_PASSWORD",
|
||||||
|
"SCHEDULE_INTERVAL_MINUTES",
|
||||||
|
"SETTINGS_FILE",
|
||||||
|
"SCHEDULE_API",
|
||||||
|
):
|
||||||
|
assert var in compose, f"{var} не передаётся контейнерам"
|
||||||
|
assert "schedule_data:/data" in compose, "том для настроек админки не подключён"
|
||||||
@@ -23,6 +23,28 @@ def test_hidden_group_excluded(data):
|
|||||||
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
|
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_names_with_top_and_sheet_order():
|
||||||
|
"""ТОП-группы распознаются, а порядок групп — как столбцы в таблице."""
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
header = sheets[parsing.MAIN_SHEET].rows[0]
|
||||||
|
header[6] = "ИТ-ТОП-11БО\nсеместр до 11.01.27"
|
||||||
|
header[7] = "АА-11БО\nсеместр до 29.12.26"
|
||||||
|
|
||||||
|
data = parsing.parse_schedule(make_sheets(sheets))
|
||||||
|
assert list(data) == ["ИТ-ТОП-11БО", "АА-11БО"] # не по алфавиту, а по столбцам
|
||||||
|
assert data["ИТ-ТОП-11БО"]["days"]["понедельник"][1]["ч"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_a_group_headers_ignored():
|
||||||
|
"""Похожие, но не групповые заголовки столбцами групп не становятся."""
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
header = sheets[parsing.MAIN_SHEET].rows[0]
|
||||||
|
header[9] = "ИВТ 11БО" # пробел вместо дефиса — это не имя группы
|
||||||
|
|
||||||
|
data = parsing.parse_schedule(make_sheets(sheets))
|
||||||
|
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
|
||||||
|
|
||||||
|
|
||||||
def test_semester(data):
|
def test_semester(data):
|
||||||
assert data["ИВТ-11БО"]["semester_until"] == "11.01.27"
|
assert data["ИВТ-11БО"]["semester_until"] == "11.01.27"
|
||||||
assert data["ИТ-12БО"]["semester_until"] == "29.12.26"
|
assert data["ИТ-12БО"]["semester_until"] == "29.12.26"
|
||||||
|
|||||||
97
tests/test_settings.py
Normal file
97
tests/test_settings.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Тесты хранилища настроек администратора (ссылка, интервал)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from schedule import ScheduleError
|
||||||
|
from schedule.settings import (
|
||||||
|
DEFAULT_INTERVAL_MINUTES,
|
||||||
|
SettingsStore,
|
||||||
|
clamp_interval,
|
||||||
|
interval_from_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults():
|
||||||
|
store = SettingsStore()
|
||||||
|
assert store.link == ""
|
||||||
|
assert store.interval_minutes == DEFAULT_INTERVAL_MINUTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_and_reload(tmp_path):
|
||||||
|
path = tmp_path / "settings.json"
|
||||||
|
store = SettingsStore(link="l1", interval_minutes=20, path=path)
|
||||||
|
store.set_link("l2")
|
||||||
|
store.set_interval_minutes(45)
|
||||||
|
|
||||||
|
again = SettingsStore(link="из-окружения", interval_minutes=1, path=path)
|
||||||
|
assert again.link == "l2"
|
||||||
|
assert again.interval_minutes == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_overrides_env(tmp_path):
|
||||||
|
path = tmp_path / "settings.json"
|
||||||
|
path.write_text(json.dumps({"link": "из-файла", "interval_minutes": 5}), encoding="utf-8")
|
||||||
|
store = SettingsStore(link="из-окружения", interval_minutes=99, path=path)
|
||||||
|
assert store.link == "из-файла"
|
||||||
|
assert store.interval_minutes == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_validation():
|
||||||
|
store = SettingsStore()
|
||||||
|
for bad in (0, 1441, -5, "abc"):
|
||||||
|
with pytest.raises(ScheduleError):
|
||||||
|
store.set_interval_minutes(bad)
|
||||||
|
assert store.set_interval_minutes("30") == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_link_rejected():
|
||||||
|
store = SettingsStore()
|
||||||
|
with pytest.raises(ScheduleError):
|
||||||
|
store.set_link(" ")
|
||||||
|
with pytest.raises(ScheduleError):
|
||||||
|
store.set_link("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_file_is_tolerated(tmp_path):
|
||||||
|
path = tmp_path / "settings.json"
|
||||||
|
path.write_text("{это не json", encoding="utf-8")
|
||||||
|
store = SettingsStore(link="env-link", interval_minutes=7, path=path)
|
||||||
|
assert store.link == "env-link"
|
||||||
|
assert store.interval_minutes == 7
|
||||||
|
|
||||||
|
|
||||||
|
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_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.interval_minutes == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_interval():
|
||||||
|
assert clamp_interval(0) == 1
|
||||||
|
assert clamp_interval(99999) == 1440
|
||||||
|
assert clamp_interval(30) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_from_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("SCHEDULE_INTERVAL_MINUTES", raising=False)
|
||||||
|
monkeypatch.delenv("SCHEDULE_CACHE_TTL", raising=False)
|
||||||
|
assert interval_from_env() == DEFAULT_INTERVAL_MINUTES
|
||||||
|
|
||||||
|
# старый SCHEDULE_CACHE_TTL (в секундах) продолжает работать
|
||||||
|
monkeypatch.setenv("SCHEDULE_CACHE_TTL", "900")
|
||||||
|
assert interval_from_env() == 15
|
||||||
|
|
||||||
|
monkeypatch.setenv("SCHEDULE_INTERVAL_MINUTES", "7")
|
||||||
|
assert interval_from_env() == 7
|
||||||
Reference in New Issue
Block a user