From cfa6a9963953d4fc8e13bcb4a20f586e7fe29035 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Mon, 28 Sep 2026 12:06:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0:=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=83=D1=82=D1=8C,=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D1=81=D1=8B=D0=BB=D0=BA=D0=B8,=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B0=D0=BB=20=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель без публичного входа: страница живёт по 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, хранилище). --- .env.example | 19 ++- Dockerfile | 4 +- README.md | 73 ++++++++-- deploy/README-dev.md | 84 ++++++++++++ deploy/nginx-dev.conf | 46 +++++++ docker-compose.yml | 13 ++ src/backend/admin.py | 157 +++++++++++++++++++++ src/backend/main.py | 77 ++++++----- src/backend/schedule/cache.py | 148 ++++++++++++++++---- src/backend/schedule/settings.py | 153 +++++++++++++++++++++ src/frontend/admin.html | 225 +++++++++++++++++++++++++++++++ src/frontend/main.py | 68 +++++++++- tests/fixtures.py | 4 +- tests/test_admin.py | 189 ++++++++++++++++++++++++++ tests/test_api.py | 11 +- tests/test_settings.py | 97 +++++++++++++ 16 files changed, 1285 insertions(+), 83 deletions(-) create mode 100644 deploy/README-dev.md create mode 100644 deploy/nginx-dev.conf create mode 100644 src/backend/admin.py create mode 100644 src/backend/schedule/settings.py create mode 100644 src/frontend/admin.html create mode 100644 tests/test_admin.py create mode 100644 tests/test_settings.py diff --git a/.env.example b/.env.example index 570529b..631b306 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,23 @@ +# --- расписание ------------------------------------------------------------- +# Публичная ссылка на Яндекс.Диск (обязательна) SCHEDULE_LINK= -SCHEDULE_CACHE_TTL=900 +# Интервал обновления в минутах (переживает перезапуск: правится в админке) +SCHEDULE_INTERVAL_MINUTES=15 +# Пауза между повторными попытками при сбое источника, секунд SCHEDULE_RETRY_AFTER=60 +# Устаревшее: интервал в секундах (используется, только если не задан SCHEDULE_INTERVAL_MINUTES) +SCHEDULE_CACHE_TTL=900 + +# --- админка (пусто = выключена) --------------------------------------------- +# Секретный путь страницы админки, например /manage-7f3a91 +ADMIN_PATH= +# Логин и пароль администратора +ADMIN_LOGIN= +ADMIN_PASSWORD= +# Где хранить настройки (ссылка/интервал); в docker это том schedule_data +SETTINGS_FILE=/data/settings.json + +# --- порты и адреса ---------------------------------------------------------- API_PORT=8000 FRONTEND_PORT=8080 SCHEDULE_API=http://api:8000 diff --git a/Dockerfile b/Dockerfile index e5f1e4c..8356767 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /app COPY 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 @@ -20,6 +21,7 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", FROM base AS frontend 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 USER app EXPOSE 8080 diff --git a/README.md b/README.md index ea32b4c..2c5759e 100644 --- a/README.md +++ b/README.md @@ -86,28 +86,63 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 - Приложение можно «установить» на главный экран (манифест + service worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. +## Админка + +Отдельного входа на сайте нет: панель открывается по секретному пути из +`ADMIN_PATH` (например `/manage-7f3a91`), логин и пароль — `ADMIN_LOGIN` / +`ADMIN_PASSWORD`. Если эти переменные пусты, админка полностью выключена и +маршруты `/api/admin/*` не регистрируются (прод можно не трогать). + +Что умеет администратор: + +- **посмотреть текущую ссылку** и состояние кэша (обновлено, изменена, + ошибка, число групп); +- **вставить новую ссылку**: она проверяется скачиванием и разбором таблицы + до сохранения — нерабочая не применится, данные на сайте не пострадают; +- **принудительно обновить расписание** — вне интервала и без проверки + «таблица не менялась» (то же, что делает фоновое обновление, но сейчас); +- **задать интервал обновления** в минутах (`1…1440`). + +Как это устроено: + +- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie + (`HttpOnly`, `SameSite=Lax`, `Secure` при https), ключ — пароль админа, + срок 12 часов; неудачные попытки логируются; +- все действия — под `/api/admin/*`, без cookie отвечают `401`; +- ссылка и интервал хранятся в `SETTINGS_FILE` (в docker — том + `schedule_data`, `/data/settings.json`), поэтому переживают перезапуск; + если файл недоступен, настройки живут в памяти; +- страница панели отдаётся фронтендом по `ADMIN_PATH` с заголовком + `X-Robots-Tag: noindex`; путь нигде не публикуется и не попадает в `/`. + ## Структура кода ``` src/ backend/ - main.py FastAPI: маршруты, create_app(cache), /healthz + main.py FastAPI: маршруты, create_app(cache), /healthz + admin.py админка: cookie-сессия, ссылка, интервал, принудительное обновление schedule/ - xlsx.py чтение xlsx: общие строки, объединённые ячейки, - скрытые столбцы, красный текст, серийные даты - source.py скачивание файла по публичной ссылке Я.Диска - lessons.py разбор ячеек занятий (общий конвейер ролей) - parsing.py лист → расписание, недели, индексы преподавателей/аудиторий - cache.py ScheduleCache: блокировка, TTL, modified, stale-while-error - errors.py ScheduleError + xlsx.py чтение xlsx: sharedStrings, мержи, скрытые столбцы, + красный текст, серийные даты + source.py загрузка файла по публичной ссылке Я.Диска + lessons.py разбор ячеек занятий (общий конвейер ролей) + parsing.py листы → расписание, недели, индексы преподавателей/аудиторий + cache.py ScheduleCache: блокировка, интервал, modified, stale-while-error + settings.py хранилище настроек админки (ссылка, интервал) в JSON-файле + errors.py ScheduleError frontend/ main.py FastAPI: статика + прокси /api/* (таймаут 150 c) + admin.html страница админки (вне static: прямой доступ закрыт) static/ index.html, app.js, style.css manifest.json PWA: имя, иконки, standalone-режим sw.js минимальный service worker (без кэша) для установки приложения img/ логотипы (owl.svg, owl-white.svg), иконки приложения (app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png) +deploy/ + nginx-dev.conf сайт тестового стенда (dev.schedule.our-place.ru) + README-dev.md как поднять тестовый стенд рядом с продом tools/ make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) tests/ @@ -150,8 +185,12 @@ tests/ | Переменная | По умолчанию | Где | Описание | |---|---|---|---| | `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** | -| `SCHEDULE_CACHE_TTL` | `900` | api | время жизни кэша, сек | +| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | интервал обновления, мин (правится в админке) | +| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) | | `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника | +| `ADMIN_PATH` | — | api+frontend | секретный путь панели (пусто = админка выключена) | +| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | учётные данные администратора | +| `SETTINGS_FILE` | `/data/settings.json` | api | где хранить ссылку и интервал | | `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) | | `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек | | `API_PORT` | `8000` | docker | внешний порт api | @@ -204,12 +243,22 @@ cd /opt/schedule && git pull && sudo docker compose up -d --build nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт), сертификат Let's Encrypt. Порты api и frontend слушаются только на loopback. +### Тестовый стенд + +Новые штуки обкатываются на отдельном стенде `dev.schedule.our-place.ru` +(ветка `admin-panel`), прод при этом не меняется: другой каталог +(`/opt/schedule-dev`), другие порты (8001/8091), свой том настроек. +Пошаговая инструкция — `deploy/README-dev.md`, конфиг nginx — +`deploy/nginx-dev.conf`. + ## Замечания -- Принудительное обновление таблицы (пропуск кэша, `force`) из API намеренно - убрано; планируется вернуть вместе с авторизацией и ролью администратора. +- Принудительное обновление и смена ссылки живут только в админке + (`/api/admin/*` под cookie-сессией): публичного `force` в API нет. - Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании - api его придётся выносить в общее хранилище. + api его придётся выносить в общее хранилище (настройки админки уже в файле). +- `ADMIN_PATH` защищает только от «случайного» обнаружения панели; реальная + защита — логин и пароль. Стоит держать их длинными и не публиковать путь. - Строки внутри дня в таблице строго чередуются «числитель / знаменатель». Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение «нечётное число строк, недели могли сдвинуться». diff --git a/deploy/README-dev.md b/deploy/README-dev.md new file mode 100644 index 0000000..550d784 --- /dev/null +++ b/deploy/README-dev.md @@ -0,0 +1,84 @@ +# Тестовый стенд (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 и сертификат + +```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/` в браузере, +войти логином/паролем из `.env` и проверить: смена ссылки, интервал, +«Обновить сейчас». + +## Обновление стенда + +```bash +cd /opt/schedule-dev && git pull && sudo docker compose up -d --build +``` diff --git a/deploy/nginx-dev.conf b/deploy/nginx-dev.conf new file mode 100644 index 0000000..5182835 --- /dev/null +++ b/deploy/nginx-dev.conf @@ -0,0 +1,46 @@ +# Тестовый стенд 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 / { + 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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d37650f..e902ec4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,15 @@ services: SCHEDULE_LINK: ${SCHEDULE_LINK} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} 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: - "${API_PORT:-8000}:8000" restart: unless-stopped @@ -23,6 +32,7 @@ services: target: frontend environment: SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} + ADMIN_PATH: ${ADMIN_PATH:-} ports: - "${FRONTEND_PORT:-8080}:8080" restart: unless-stopped @@ -35,3 +45,6 @@ services: timeout: 5s retries: 3 start_period: 10s + +volumes: + schedule_data: diff --git a/src/backend/admin.py b/src/backend/admin.py new file mode 100644 index 0000000..132acf2 --- /dev/null +++ b/src/backend/admin.py @@ -0,0 +1,157 @@ +"""Админка расписания. + +Отдельного входа на сайте нет: панель открывается по секретному пути из +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 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 + + +@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"]) + + 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" + + @router.post("/login") + def login(body: LoginBody, request: Request, response: Response) -> dict: + ok_login = _same(body.login.strip(), config.login) + ok_password = _same(body.password, config.password) + if not (ok_login and ok_password): + log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40]) + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + 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 diff --git a/src/backend/main.py b/src/backend/main.py index c911a3b..a5c57a6 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -4,17 +4,18 @@ from __future__ import annotations import logging import os -import threading from contextlib import asynccontextmanager from datetime import date from pathlib import Path import uvicorn 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 parsing +from schedule.settings import SettingsStore, interval_from_env log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -37,40 +38,36 @@ _dotenv_path = _find_dotenv() if _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() -CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "900")) 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: - try: - return ScheduleCache(SCHEDULE_LINK, ttl_seconds=CACHE_TTL, retry_after=RETRY_AFTER) - except ScheduleError: - log.error("SCHEDULE_LINK не задан — API данных работать не будет") - return _BrokenCache() +def default_settings() -> SettingsStore: + return SettingsStore( + link=SCHEDULE_LINK, + interval_minutes=interval_from_env(), + path=SETTINGS_FILE or None, + ) -class _BrokenCache(ScheduleCache): - """Кэш без ссылки: все запросы данных отвечают понятной ошибкой.""" - - def __init__(self) -> None: - self._link = None - self._ttl = CACHE_TTL - self._retry_after = RETRY_AFTER - self._lock = threading.RLock() - self._data = None - self._weeks = [] - 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 default_cache(settings: SettingsStore | None = None) -> ScheduleCache: + settings = settings or default_settings() + return ScheduleCache(settings=settings, retry_after=RETRY_AFTER) def _day_arg(day: str) -> str: @@ -83,8 +80,14 @@ def _day_arg(day: str) -> str: return normalized -def create_app(cache: ScheduleCache | None = None) -> FastAPI: - cache = cache or _default_cache() +def create_app( + 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 async def lifespan(app: FastAPI): @@ -97,6 +100,16 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI: lifespan=lifespan, ) 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]]: try: diff --git a/src/backend/schedule/cache.py b/src/backend/schedule/cache.py index 13042b7..c30c7e7 100644 --- a/src/backend/schedule/cache.py +++ b/src/backend/schedule/cache.py @@ -2,11 +2,14 @@ - однопоточная перезагрузка (lock): параллельные запросы во время загрузки ждут один download вместо N скачиваний; -- TTL, а также проверка modified таблицы: если таблица не менялась, - скачивание не повторяется; +- TTL (интервал обновления), а также проверка modified таблицы: если таблица + не менялась, скачивание не повторяется; - stale-while-error: если таблица перестала скачиваться, отдаём последнее удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд - (вместо скачивания на каждый запрос). + (вместо скачивания на каждый запрос); +- настройки администратора (ссылка, интервал) живут в SettingsStore и + применяются на лету: смена ссылки проверяется до сохранения, принудительное + обновление идёт вне расписания. """ from __future__ import annotations @@ -18,24 +21,32 @@ from datetime import datetime from . import parsing from .errors import ScheduleError +from .settings import SettingsStore from .source import download_schedule, fetch_table_modified from .xlsx import parse_workbook log = logging.getLogger(__name__) +NO_LINK_ERROR = "Ссылка на расписание не задана" + class ScheduleCache: def __init__( self, - link: str | None, + link: str | None = None, ttl_seconds: float = 900.0, retry_after: float = 60.0, + settings: SettingsStore | 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") - self._link = link - self._ttl = ttl_seconds - self._retry_after = retry_after + self._ttl = float(ttl_seconds) + self._retry_after = float(retry_after) self._lock = threading.RLock() self._data: dict | None = None @@ -46,7 +57,7 @@ class ScheduleCache: self._room_info: dict = {} self._loaded_at: float = 0.0 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 # --- публичное API --------------------------------------------------------- @@ -59,6 +70,11 @@ class ScheduleCache: def room_info(self) -> dict: 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]]: """(data, weeks); бросает ScheduleError, если данных нет совсем.""" with self._lock: @@ -82,18 +98,74 @@ class ScheduleCache: with self._lock: return { "cache_ttl_seconds": self._ttl, + "interval_minutes": self.interval_minutes, "last_updated": self._iso(self._loaded_at), "table_modified": self._modified, "error": self._error, } + def state(self) -> dict: + """Полное состояние для админки.""" + with self._lock: + return self._state() + def warmup(self) -> None: - """Первый запуск данных на старте приложения (ошибка не фатальна).""" + """Первый загруз данных на старте приложения (ошибка не фатальна).""" try: self.get() except ScheduleError as 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 -------------------------------------------------------------- @staticmethod @@ -102,6 +174,17 @@ class ScheduleCache: return None 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, + } + def _ensure_fresh(self) -> None: now = time.time() fresh = ( @@ -122,6 +205,9 @@ class ScheduleCache: and now - self._last_failed < self._retry_after ): return + if not self._link: + self._fail(ScheduleError(NO_LINK_ERROR), now) + return modified = fetch_table_modified(self._link) if ( self._data is not None @@ -133,31 +219,35 @@ class ScheduleCache: self._error = None return try: - log.info("Загружаю расписание по ссылке %s ...", self._link) - 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 - ) + self._load(now, modified) except ScheduleError as exc: self._fail(exc, now) except Exception as exc: # непредвиденное в парсинге/сети log.exception("Ошибка при загрузке расписания") 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) + 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) + def _fail(self, exc: ScheduleError, now: float) -> None: self._error = str(exc) self._last_failed = now diff --git a/src/backend/schedule/settings.py b/src/backend/schedule/settings.py new file mode 100644 index 0000000..be8c4bf --- /dev/null +++ b/src/backend/schedule/settings.py @@ -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) diff --git a/src/frontend/admin.html b/src/frontend/admin.html new file mode 100644 index 0000000..7584704 --- /dev/null +++ b/src/frontend/admin.html @@ -0,0 +1,225 @@ + + + + + + + Админка расписания + + + + + +
+
+ +
Админка расписания
+
+
Управление источником данных и обновлением. Страница доступна только по секретной ссылке.
+ + + + +
+ + + + diff --git a/src/frontend/main.py b/src/frontend/main.py index ddc220e..79e5a2f 100644 --- a/src/frontend/main.py +++ b/src/frontend/main.py @@ -1,15 +1,20 @@ -"""Фронтенд расписания: статики + прокси к API бэкенда.""" +"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки. + +Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py): +сам путь не публикуется, входа с сайта нет. +""" from __future__ import annotations -import os import logging +import os from pathlib import Path import requests import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -21,7 +26,10 @@ load_dotenv(Path(__file__).parent / ".env") # Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше. SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/") PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150")) +ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/") STATIC_DIR = Path(__file__).parent / "static" +# страница админки лежит ВНЕ static: прямой доступ по /admin.html запрещён +ADMIN_PAGE = Path(__file__).parent / "admin.html" app = FastAPI(title="Расписание — фронт") @@ -46,6 +54,30 @@ def _proxy(path: str, params: dict) -> object: return resp.json() +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"), + ) + # 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-маршруты ----------------------------------------------------------- @@ -99,6 +131,22 @@ def api_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 +155,20 @@ def index(): 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") diff --git a/tests/fixtures.py b/tests/fixtures.py index 4f1d997..d65feee 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -85,7 +85,7 @@ def make_sheets(sheets: dict[str, SheetSpec]): 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 с заменёнными сетевыми функциями.""" import schedule.cache as cache_mod 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, "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 diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..3554d99 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,189 @@ +"""Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление.""" + +import json +import time + +import pytest +from fastapi.testclient import TestClient + +import main +from admin import 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_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 diff --git a/tests/test_api.py b/tests/test_api.py index 3c1c858..6c3e365 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -196,12 +196,17 @@ def test_cache_failure_without_data_raises(cache): 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 - 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: assert c.get("/healthz").status_code == 200 assert c.get("/groups").status_code == 502 body = c.get("/status").json() - assert "SCHEDULE_LINK" in body["error"] + assert "Ссылка" in body["error"] diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..1c49381 --- /dev/null +++ b/tests/test_settings.py @@ -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