Compare commits

...

9 Commits

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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