diff --git a/.env.example b/.env.example index e5fdc34..f4c111d 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,17 @@ SCHEDULE_RETRY_AFTER=60 # снаружи сервисы отдаёт nginx, напрямую в интернет они не смотрят. API_PORT=8000 FRONTEND_PORT=8080 + +# --- админка (необязательно) ---------------------------------------------------- +# Пока эти переменные пустые, админка выключена и наружу ничего не торчит. +# Секретный путь страницы админки: придумайте длинный, например /panel-8f3a91x7 +ADMIN_PATH= +# Логин и пароль администратора +ADMIN_LOGIN= +ADMIN_PASSWORD= +# Сколько неудачных попыток входа допускается и на сколько минут блокировать после них +ADMIN_MAX_ATTEMPTS=3 +ADMIN_LOCKOUT_MINUTES=15 +# Где хранить настройки админки (ссылка, интервал, история входов). +# Пусто = data/settings.json в корне проекта; в docker задаётся автоматически. +SETTINGS_FILE= diff --git a/.gitignore b/.gitignore index 3ed4e25..0cee9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ __pycache__/ # локальные данные инструментов .kilo/ + +# состояние админки (ссылка, интервал, история входов) +/data/ diff --git a/Dockerfile b/Dockerfile index e5f1e4c..c5d189f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,11 +7,13 @@ 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 COPY --chown=app:app src/backend/main.py ./ +COPY --chown=app:app src/backend/admin.py ./ COPY --chown=app:app src/backend/schedule ./schedule USER app EXPOSE 8000 @@ -20,6 +22,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 7ac283e..a07a282 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,11 @@ PyPI не раздаёт вовсе. Зеркала отстают по-разн | `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника | | `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) | | `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек | +| `ADMIN_PATH` | — | api+frontend | секретный путь админки; пусто = админка выключена | +| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | логин и пароль администратора | +| `ADMIN_MAX_ATTEMPTS` | `3` | api | сколько неудачных попыток входа допускается | +| `ADMIN_LOCKOUT_MINUTES` | `15` | api | на сколько минут блокировать вход после них | +| `SETTINGS_FILE` | `data/settings.json` | api | где хранить ссылку, интервал и историю входов | | `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) | | `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) | @@ -301,6 +306,42 @@ PyPI не раздаёт вовсе. Зеркала отстают по-разн `.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные переменные окружения. +## Админка + +Необязательная страница для управления источником данных. Отдельного входа на +сайте нет: панель открывается по секретному пути `ADMIN_PATH`, а пока +`ADMIN_LOGIN`/`ADMIN_PASSWORD` пусты, админка выключена и наружу ничего не +торчит (маршруты `/api/admin/*` не регистрируются). + +Включить: заполнить в `.env` четыре переменные и перезапустить службу. + +```ini +ADMIN_PATH=/panel-8f3a91x7 # придумайте длинный и неочевидный +ADMIN_LOGIN=admin +ADMIN_PASSWORD=<длинный пароль> +``` + +Что умеет: + +- **посмотреть состояние**: сколько групп, когда обновлялось, дата файла на + Диске, изменился ли файл при последнем чтении (`sha256`), есть ли ошибки; +- **вставить новую ссылку** на таблицу: она проверяется скачиванием и разбором + до сохранения — нерабочая не применится, данные на сайте не пострадают; +- **задать интервал обновления** в минутах (`1…1440`); +- **обновить сейчас**: скачивает таблицу принудительно, не дожидаясь интервала + (по интервалу файл перечитывается, только если на Диске изменилась дата); +- **видеть входы**: последняя попытка (откуда, когда, успешна ли) и последний + успешный вход — и в панели, и в логе службы. + +Защита входа: после `ADMIN_MAX_ATTEMPTS` неудач (по умолчанию 3) вход с этого +адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия — +подписанная cookie на 12 часов. + +Настройки (ссылка, интервал, история входов) хранятся в `SETTINGS_FILE` +(по умолчанию `data/settings.json` в корне проекта; в docker — в томе +`schedule_data`). Если файл недоступен для записи, изменения живут в памяти до +перезапуска, а панель предупредит об этом. + ## Структура кода ``` @@ -308,16 +349,19 @@ install.sh интерактивная установка на сер src/ backend/ main.py FastAPI: маршруты, create_app(cache), /healthz + admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force schedule/ xlsx.py чтение xlsx: общие строки, объединённые ячейки, скрытые столбцы, красный текст, серийные даты source.py скачивание файла по публичной ссылке Я.Диска lessons.py разбор ячеек занятий (общий конвейер ролей) parsing.py лист → расписание, недели, индексы преподавателей/аудиторий - cache.py ScheduleCache: блокировка, интервал, stale-while-error + cache.py ScheduleCache: блокировка, интервал, stale-while-error, force + settings.py хранилище настроек админки (ссылка, интервал, история входов) errors.py ScheduleError frontend/ main.py FastAPI: статика + прокси /api/* (таймаут 150 c) + admin.html страница админки (вне static: прямой доступ закрыт) static/ index.html, app.js, style.css manifest.json PWA: имя, иконки, standalone-режим @@ -335,9 +379,13 @@ tests/ test_xlsx.py чтение книги test_parsing.py разбор листов, недели, индексы test_api.py API и поведение кэша (интервал/stale, 404/400/502) + test_admin.py админка: вход, блокировка, история, ссылка/интервал/force + test_settings.py хранилище настроек админки test_deploy.py проверка, что образы копируют все нужные файлы ``` +Данные админки (`data/settings.json`) — рабочие, в git не попадают. + ## Как устроена таблица-источник Лист «Бакалавры и магистры» — это сетка: diff --git a/docker-compose.yml b/docker-compose.yml index abee3c9..15a9951 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,16 @@ services: SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60} + # админка: пусто = выключена (страница и /api/admin/* не появятся) + ADMIN_PATH: ${ADMIN_PATH:-} + ADMIN_LOGIN: ${ADMIN_LOGIN:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ADMIN_MAX_ATTEMPTS: ${ADMIN_MAX_ATTEMPTS:-3} + ADMIN_LOCKOUT_MINUTES: ${ADMIN_LOCKOUT_MINUTES:-15} + # настройки админки (ссылка, интервал, история входов) живут в томе + SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json} + volumes: + - schedule_data:/data ports: # api нужен только фронтенду внутри docker-сети; наружу — 127.0.0.1 для отладки - "127.0.0.1:${API_PORT:-8000}:8000" @@ -26,6 +36,8 @@ services: environment: # внутри compose api доступен по имени сервиса; менять не нужно SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} + # страница админки отдаётся фронтендом по этому пути + ADMIN_PATH: ${ADMIN_PATH:-} ports: # фронт обслуживает nginx; наружу напрямую не смотрит - "127.0.0.1:${FRONTEND_PORT:-8080}:8080" @@ -39,3 +51,6 @@ services: timeout: 5s retries: 3 start_period: 10s + +volumes: + schedule_data: diff --git a/install.sh b/install.sh index 177a540..b6c5f9f 100755 --- a/install.sh +++ b/install.sh @@ -180,6 +180,14 @@ SCHEDULE_INTERVAL_MINUTES=15 SCHEDULE_RETRY_AFTER=60 API_PORT=$API_PORT FRONTEND_PORT=$FRONT_PORT + +# Админка (необязательно). Заполните path/логин/пароль, чтобы включить панель: +# страница будет доступна только по https://<домен> +ADMIN_PATH= +ADMIN_LOGIN= +ADMIN_PASSWORD= +ADMIN_MAX_ATTEMPTS=3 +ADMIN_LOCKOUT_MINUTES=15 EOF log ".env записан (прежний, если был, сохранён в .env.install-backup)" } diff --git a/src/backend/admin.py b/src/backend/admin.py new file mode 100644 index 0000000..42cccd1 --- /dev/null +++ b/src/backend/admin.py @@ -0,0 +1,278 @@ +"""Админка расписания. + +Отдельного входа на сайте нет: панель открывается по секретному пути из +ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они +пусты, админка выключена и маршруты /api/admin/* не регистрируются. + +После нескольких неудачных попыток вход блокируется на время из +ADMIN_LOCKOUT_MINUTES (по умолчанию: 3 попытки и 15 минут). Каждая попытка +запоминается (откуда, когда, успешна ли) — это видно в панели и пишется в лог. + +Что умеет администратор: + 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 datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel + +from schedule import ScheduleCache, ScheduleError +from schedule.settings import SettingsStore + +log = logging.getLogger(__name__) + +COOKIE_NAME = "schedule_admin" +SESSION_TTL_SECONDS = 12 * 3600 + +DEFAULT_MAX_ATTEMPTS = 3 +DEFAULT_LOCKOUT_MINUTES = 15 + + +@dataclass +class AdminConfig: + """Настройки админки: секретный путь, учётные данные и защита от перебора.""" + + path: str = "" + login: str = "" + password: str = "" + max_attempts: int = DEFAULT_MAX_ATTEMPTS + lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES + + @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 LoginThrottle: + """Счётчик неудачных попыток: N промахов — пауза на lockout_minutes.""" + + def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS, lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES) -> None: + self.max_attempts = max(1, int(max_attempts)) + self.lockout_seconds = max(1, int(lockout_minutes)) * 60 + self._lock = threading.Lock() + self._failures: dict[str, int] = {} + self._blocked_until: dict[str, float] = {} + + def locked_until(self, key: str) -> float | None: + with self._lock: + until = self._blocked_until.get(key) + if until is None: + return None + if until <= time.time(): + self._blocked_until.pop(key, None) + self._failures.pop(key, None) + return None + return until + + def attempts_left(self, key: str) -> int: + with self._lock: + return max(0, self.max_attempts - self._failures.get(key, 0)) + + def register_failure(self, key: str) -> None: + with self._lock: + failures = self._failures.get(key, 0) + 1 + if failures >= self.max_attempts: + self._blocked_until[key] = time.time() + self.lockout_seconds + self._failures[key] = 0 + else: + self._failures[key] = failures + + def reset(self, key: str) -> None: + with self._lock: + self._failures.pop(key, None) + self._blocked_until.pop(key, None) + + +class LoginBody(BaseModel): + login: str + password: str + + +class LinkBody(BaseModel): + link: str + + +class IntervalBody(BaseModel): + interval_minutes: int + + +def _client_ip(request: Request) -> str: + """Реальный адрес клиента: за nginx он приходит в X-Forwarded-For.""" + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + return forwarded.split(",")[0].strip() or "неизвестно" + return request.client.host if request.client else "неизвестно" + + +def _human_dt(iso: str | None) -> str: + if not iso: + return "—" + try: + return datetime.fromisoformat(iso).strftime("%d.%m.%Y %H:%M:%S") + except ValueError: + return iso + + +def create_admin_router( + cache: ScheduleCache, + settings: SettingsStore | None, + config: AdminConfig, +) -> APIRouter: + router = APIRouter(prefix="/api/admin", tags=["admin"]) + throttle = LoginThrottle(config.max_attempts, config.lockout_minutes) + + 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 _remember(ip: str, ok: bool) -> None: + if settings is not None: + settings.record_login(ip, ok) + + @router.post("/login") + def login(body: LoginBody, request: Request, response: Response) -> dict: + ip = _client_ip(request) + until = throttle.locked_until(ip) + if until is not None: + minutes = max(1, round((until - time.time()) / 60)) + log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes) + raise HTTPException( + status_code=429, + detail=f"Слишком много неудачных попыток. Вход заблокирован ещё на ~{minutes} мин.", + ) + ok_login = _same(body.login.strip(), config.login) + ok_password = _same(body.password, config.password) + if not (ok_login and ok_password): + throttle.register_failure(ip) + _remember(ip, False) + left = throttle.attempts_left(ip) + if throttle.locked_until(ip) is not None: + log.warning( + "Неудачная попытка входа (%s): исчерпаны попытки, пауза %d мин", + ip, + config.lockout_minutes, + ) + raise HTTPException( + status_code=429, + detail=f"Неверный логин или пароль. Попытки исчерпаны — вход заблокирован на {config.lockout_minutes} мин.", + ) + log.warning("Неудачная попытка входа (%s), осталось попыток: %d", ip, left) + raise HTTPException( + status_code=401, + detail=f"Неверный логин или пароль. Осталось попыток: {left}.", + ) + throttle.reset(ip) + previous = settings.last_attempt if settings is not None else None + _remember(ip, True) + if previous: + log.info( + "Успешный вход в админку (%s). Предыдущая попытка: %s, %s, %s", + ip, + _human_dt(previous.get("at")), + previous.get("ip"), + "успешная" if previous.get("ok") else "неудачная", + ) + else: + log.info("Успешный вход в админку (%s)", ip) + response.set_cookie( + COOKIE_NAME, + make_token(config.login, config.password), + max_age=SESSION_TTL_SECONDS, + httponly=True, + samesite="lax", + secure=_secure(request), + path="/", + ) + 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(request: Request) -> dict: + ip = _client_ip(request) + until = throttle.locked_until(ip) + payload = cache.state() + payload["throttle"] = { + "max_attempts": config.max_attempts, + "lockout_minutes": config.lockout_minutes, + "attempts_left": throttle.attempts_left(ip), + "locked_until": datetime.fromtimestamp(until).astimezone().isoformat() if until else None, + } + return payload + + @router.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 902e2ce..ed009bb 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -1,4 +1,9 @@ -"""API-приложение расписания (FastAPI).""" +"""API-приложение расписания (FastAPI). + +Кроме публичных данных здесь живёт админка (backend/admin.py): она включается +только если заданы ADMIN_LOGIN и ADMIN_PASSWORD, и открывается по секретному +пути ADMIN_PATH. Все параметры — переменные окружения, см. .env.example. +""" from __future__ import annotations @@ -12,15 +17,17 @@ import uvicorn from dotenv import load_dotenv 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") def _find_dotenv() -> Path | None: - """Ищем .env вверх от каталога модуля (локальная разработка).""" + """Ищем .env вверх от каталога модуля (общий .env в корне проекта).""" path = Path(__file__).resolve().parent for _ in range(5): env = path / ".env" @@ -36,36 +43,58 @@ _dotenv_path = _find_dotenv() if _dotenv_path: load_dotenv(_dotenv_path) -DEFAULT_INTERVAL_MINUTES = 15 + +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 "/" + + +def _int_env(name: str, default: int) -> int: + raw = (os.getenv(name) or "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + log.warning("%s=%r не число, беру %d", name, raw, default) + return default + + SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip() RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60")) +SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip() +ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH")) +ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip() +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or "" +ADMIN_MAX_ATTEMPTS = _int_env("ADMIN_MAX_ATTEMPTS", 3) +ADMIN_LOCKOUT_MINUTES = _int_env("ADMIN_LOCKOUT_MINUTES", 15) -def interval_seconds() -> float: - """Интервал обновления: SCHEDULE_INTERVAL_MINUTES, иначе старый SCHEDULE_CACHE_TTL (сек).""" - raw_minutes = (os.getenv("SCHEDULE_INTERVAL_MINUTES") or "").strip() - if raw_minutes: - try: - return max(1.0, float(raw_minutes)) * 60 - except ValueError: - log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру значение по умолчанию", raw_minutes) - raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip() - if raw_ttl: - try: - return max(1.0, float(raw_ttl)) - except ValueError: - log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl) - return DEFAULT_INTERVAL_MINUTES * 60 - - -def default_cache() -> ScheduleCache: - return ScheduleCache( +def default_settings() -> SettingsStore: + return SettingsStore( link=SCHEDULE_LINK, - ttl_seconds=interval_seconds(), - retry_after=RETRY_AFTER, + interval_minutes=interval_from_env(), + path=SETTINGS_FILE or None, ) +def default_cache(settings: SettingsStore | None = None) -> ScheduleCache: + settings = settings or default_settings() + if SCHEDULE_LINK and settings.link and settings.link != SCHEDULE_LINK: + log.warning( + "Ссылка в настройках (%s) отличается от SCHEDULE_LINK из окружения (%s); " + "работаем по ссылке из настроек — её видно и можно поменять в админке", + settings.link, + SCHEDULE_LINK, + ) + return ScheduleCache(settings=settings, retry_after=RETRY_AFTER) + + def _day_arg(day: str) -> str: normalized = day.strip().lower() if normalized not in parsing.DAYS: @@ -76,8 +105,21 @@ 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, + settings: SettingsStore | None = None, +) -> FastAPI: + if cache is None: + settings = settings or default_settings() + cache = ScheduleCache(settings=settings, retry_after=RETRY_AFTER) + admin = admin if admin is not None else AdminConfig( + path=ADMIN_PATH, + login=ADMIN_LOGIN, + password=ADMIN_PASSWORD, + max_attempts=ADMIN_MAX_ATTEMPTS, + lockout_minutes=ADMIN_LOCKOUT_MINUTES, + ) @asynccontextmanager async def lifespan(app: FastAPI): @@ -90,6 +132,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, settings, admin)) + if admin.path: + log.info("Админка включена (путь задан в ADMIN_PATH)") + 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 3f0d9d3..e7a6823 100644 --- a/src/backend/schedule/cache.py +++ b/src/backend/schedule/cache.py @@ -3,10 +3,13 @@ - однопоточная перезагрузка (lock): параллельные запросы во время загрузки ждут один download вместо N скачиваний; - интервал обновления: по его истечении сверяем modified таблицы — если файл - не менялся, скачивание не повторяется; + не менялся, скачивание не повторяется; принудительное обновление из админки + (force_reload) перечитывает таблицу всегда; - stale-while-error: если таблица перестала скачиваться, отдаём последнее удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд - (вместо скачивания на каждый запрос). + (вместо скачивания на каждый запрос); +- настройки администратора (ссылка, интервал) живут в SettingsStore и + применяются на лету: смена ссылки проверяется до сохранения. """ from __future__ import annotations @@ -19,12 +22,13 @@ 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 = "Ссылка на расписание не задана (SCHEDULE_LINK)" +NO_LINK_ERROR = "Ссылка на расписание не задана" class ScheduleCache: @@ -33,8 +37,15 @@ class ScheduleCache: link: str | None = None, ttl_seconds: float = 900.0, retry_after: float = 60.0, + settings: SettingsStore | None = None, ) -> None: + 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._ttl = float(ttl_seconds) self._retry_after = float(retry_after) self._lock = threading.RLock() @@ -64,6 +75,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: @@ -84,20 +100,21 @@ class ScheduleCache: return self._day_times def status(self) -> dict: + """Состояние кэша (публичный /status): без ссылки и без админских данных.""" with self._lock: - return { - "cache_ttl_seconds": self._ttl, - "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], - } + return self._status() + + def state(self) -> dict: + """Состояние для админки: то же плюс текущая ссылка и настройки.""" + with self._lock: + state = self._status() + state["link"] = self._link + if self._settings is not None: + state["settings_file"] = str(self._settings.path) if self._settings.path else None + state["settings_save_error"] = self._settings.save_error + state["last_attempt"] = self._settings.last_attempt + state["last_success"] = self._settings.last_success + return state def warmup(self) -> None: """Первая загрузка данных на старте приложения (ошибка не фатальна).""" @@ -106,6 +123,56 @@ class ScheduleCache: 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 @@ -114,6 +181,20 @@ class ScheduleCache: return None return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat() + def _status(self) -> dict: + return { + "cache_ttl_seconds": self._ttl, + "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: now = time.time() fresh = ( @@ -137,6 +218,9 @@ class ScheduleCache: if not self._link: self._fail(ScheduleError(NO_LINK_ERROR), now) return + # Обновление по интервалу: сначала сверяем modified — если файл не + # менялся, скачивание не повторяем. Принудительное обновление (кнопка + # в админке) идёт через force_reload и качает файл всегда. modified = fetch_table_modified(self._link) if ( self._data is not None diff --git a/src/backend/schedule/settings.py b/src/backend/schedule/settings.py new file mode 100644 index 0000000..d1da0e7 --- /dev/null +++ b/src/backend/schedule/settings.py @@ -0,0 +1,221 @@ +"""Настройки, которые правит администратор. + +Хранятся в JSON-файле (`SETTINGS_FILE`, по умолчанию `data/settings.json` в +корне проекта), поэтому переживают перезапуск службы: ссылка на таблицу, +интервал обновления и история входов в админку. Если файл недоступен для +записи — настройки живут в памяти, а сервис продолжает работать (об этом +пишется предупреждение в лог и в состояние админки). +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from dataclasses import dataclass +from datetime import datetime +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 + +def _default_settings_path() -> Path: + """`<корень проекта>/data/settings.json`. + + В репозитории это три уровня вверх от пакета, а в docker-образе файлы лежат + плоско (/app/...), поэтому ищем корень по маркеру — requirements.txt. + """ + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "requirements.txt").is_file(): + return parent / "data" / "settings.json" + return here.parent / "data" / "settings.json" + + +DEFAULT_SETTINGS_PATH = _default_settings_path() + + +@dataclass +class Settings: + link: str = "" + interval_minutes: int = DEFAULT_INTERVAL_MINUTES + last_attempt: dict | None = None # {"at": iso, "ip": str, "ok": bool} + last_success: dict | None = None # {"at": iso, "ip": str} + + def as_dict(self) -> dict: + return { + "link": self.link, + "interval_minutes": self.interval_minutes, + "last_attempt": self.last_attempt, + "last_success": self.last_success, + } + + +def clamp_interval(minutes: int) -> int: + return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes))) + + +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 settings_path_from_env() -> Path | None: + raw = (os.getenv("SETTINGS_FILE") or "").strip() + return Path(raw) if raw else DEFAULT_SETTINGS_PATH + + +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._save_error: str | None = None + self._settings = Settings( + link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes) + ) + self._load() + + # --- чтение ----------------------------------------------------------------- + + @property + def path(self) -> Path | None: + return self._path + + @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 + + @property + def last_attempt(self) -> dict | None: + with self._lock: + return dict(self._settings.last_attempt) if self._settings.last_attempt else None + + @property + def last_success(self) -> dict | None: + with self._lock: + return dict(self._settings.last_success) if self._settings.last_success else None + + @property + def save_error(self) -> str | None: + """Текст ошибки, если настройки не удаётся сохранить на диск.""" + with self._lock: + return self._save_error + + def as_dict(self) -> dict: + with self._lock: + return self._settings.as_dict() + + # --- запись ----------------------------------------------------------------- + + 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 record_login(self, ip: str, ok: bool) -> dict: + """Запомнить попытку входа: откуда, когда и чем закончилась.""" + now = datetime.now().astimezone().replace(microsecond=0) + attempt = {"at": now.isoformat(), "ip": ip or "неизвестно", "ok": bool(ok)} + with self._lock: + self._settings.last_attempt = attempt + if ok: + self._settings.last_success = {"at": attempt["at"], "ip": attempt["ip"]} + self._save() + return attempt + + # --- файл ------------------------------------------------------------------- + + def _load(self) -> None: + 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) + for key in ("last_attempt", "last_success"): + value = raw.get(key) + if isinstance(value, dict) and value.get("at"): + setattr(self._settings, key, value) + log.info( + "Настройки из %s: интервал %s мин, ссылка %s", + self._path, + 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) + self._save_error = None + except OSError as exc: + self._save_error = str(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..3aaca7b --- /dev/null +++ b/src/frontend/admin.html @@ -0,0 +1,356 @@ + + + + + + + Админка расписания + + + + + +
+
+ +
Админка расписания
+
+
Управление источником данных и обновлением. Страница доступна только по секретной ссылке.
+ + + + +
+ +
+ + + + diff --git a/src/frontend/main.py b/src/frontend/main.py index 590960e..2c87180 100644 --- a/src/frontend/main.py +++ b/src/frontend/main.py @@ -1,4 +1,8 @@ -"""Фронтенд расписания: статики + прокси к API бэкенда.""" +"""Фронтенд расписания: статики, прокси к API и страница админки. + +Страница админки отдаётся по секретному пути из ADMIN_PATH; сам API админки — +/api/admin/* (проксируется в backend). Если ADMIN_PATH не задан, страницы нет. +""" from __future__ import annotations @@ -9,7 +13,8 @@ from pathlib import Path import requests import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -39,7 +44,10 @@ if _dotenv_path: SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/") # Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше. 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="Расписание — фронт") @@ -65,6 +73,31 @@ def _proxy(path: str, params: dict) -> object: 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-маршруты ----------------------------------------------------------- @@ -118,7 +151,29 @@ 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 + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + headers["X-Forwarded-For"] = forwarded + forwarded_proto = request.headers.get("x-forwarded-proto") + if forwarded_proto: + headers["X-Forwarded-Proto"] = forwarded_proto + return await run_in_threadpool( + _proxy_admin, request.method, f"api/admin/{rest}", body, headers + ) + + +# --- статики и админка -------------------------------------------------------- @app.get("/") @@ -126,6 +181,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..da7d35b --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,227 @@ +"""Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/force.""" + +import json + +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, store): + app = main.create_app(cache, admin=ADMIN, settings=store) + 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 + assert state["throttle"]["max_attempts"] == ADMIN.max_attempts + + +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_lockout_after_configured_attempts(client): + """Разрешённые попытки тратятся, следующая попытка включает паузу.""" + for _ in range(ADMIN.max_attempts - 1): + resp = do_login(client, password="неверный") + assert resp.status_code == 401 + assert "Осталось попыток" in resp.json()["detail"] + + exhausted = do_login(client, password="неверный") + assert exhausted.status_code == 429 + assert "заблокирован" in exhausted.json()["detail"].lower() + # пауза действует и для верного пароля + assert do_login(client, password="s3cret").status_code == 429 + + +def test_successful_login_resets_failures(client): + assert do_login(client, password="неверный").status_code == 401 + assert do_login(client).status_code == 200 + # счётчик сброшен: ещё две неудачи не блокируют + assert do_login(client, password="неверный").status_code == 401 + assert do_login(client, password="неверный").status_code == 401 + assert do_login(client).status_code == 200 + + +# --- история входов --------------------------------------------------------------- + + +def test_login_history_visible_and_saved(client, store): + assert do_login(client, password="неверный").status_code == 401 + assert do_login(client).status_code == 200 + + state = client.get("/api/admin/state").json() + assert state["last_attempt"]["ok"] is True + assert state["last_attempt"]["ip"] # откуда + assert state["last_attempt"]["at"] # когда + assert state["last_success"]["ip"] == state["last_attempt"]["ip"] + + saved = json.loads(store.path.read_text(encoding="utf-8")) + assert saved["last_attempt"]["ok"] is True + assert saved["last_success"]["at"] + + +# --- интервал -------------------------------------------------------------------- + + +def test_set_interval(client, cache, store): + 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 + assert resp.json()["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, store): + app = main.create_app(cache, admin=AdminConfig(), settings=store) + 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 5a03f05..59af1a9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -254,11 +254,12 @@ def test_cache_without_link(): from fastapi.testclient import TestClient as TC from schedule import ScheduleCache + from schedule.settings import SettingsStore - cache = ScheduleCache(link="", retry_after=60) + 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_deploy.py b/tests/test_deploy.py index f2fb81c..d561a0c 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -28,13 +28,24 @@ def test_api_image_copies_every_backend_module(): assert "src/backend/schedule" in stage, "пакет schedule не копируется в образ api" -def test_frontend_image_copies_static(): +def test_frontend_image_copies_page_and_static(): stage = _stage("frontend") - for path in ("src/frontend/main.py", "src/frontend/static"): + 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_required_env(): compose = COMPOSE.read_text(encoding="utf-8") - for var in ("SCHEDULE_LINK", "SCHEDULE_INTERVAL_MINUTES", "SCHEDULE_API"): + for var in ( + "SCHEDULE_LINK", + "SCHEDULE_INTERVAL_MINUTES", + "SCHEDULE_API", + "ADMIN_PATH", + "ADMIN_LOGIN", + "ADMIN_PASSWORD", + "ADMIN_MAX_ATTEMPTS", + "ADMIN_LOCKOUT_MINUTES", + "SETTINGS_FILE", + ): assert var in compose, f"{var} не передаётся контейнерам" + assert "schedule_data:/data" in compose, "том для настроек админки не подключён" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..5254970 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,125 @@ +"""Тесты хранилища настроек админки: ссылка, интервал, история входов.""" + +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 + assert store.last_attempt is None + assert store.last_success is None + + +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_login_history_saved(tmp_path): + path = tmp_path / "settings.json" + store = SettingsStore(link="l", path=path) + + store.record_login("10.0.0.1", ok=False) + assert store.last_attempt["ip"] == "10.0.0.1" + assert store.last_attempt["ok"] is False + assert store.last_attempt["at"] + assert store.last_success is None # успешных ещё не было + + store.record_login("10.0.0.2", ok=True) + assert store.last_success["ip"] == "10.0.0.2" + + # история переживает перезапуск: читаем тем же путём + again = SettingsStore(path=path) + assert again.last_attempt["ip"] == "10.0.0.2" + assert again.last_attempt["ok"] is True + assert again.last_success["ip"] == "10.0.0.2" + + +def test_unwritable_path_marks_save_error(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 + assert store.save_error # и видно, что на диск не сохранилось + + +def test_default_settings_path_is_inside_project(): + """Путь по умолчанию вычисляется от корня проекта, а не от глубины файла. + + В docker-образе файлы лежат плоско (/app/...), поэтому глубину не считаем. + """ + from schedule.settings import DEFAULT_SETTINGS_PATH + + assert DEFAULT_SETTINGS_PATH.name == "settings.json" + assert DEFAULT_SETTINGS_PATH.parent.name == "data" + root = DEFAULT_SETTINGS_PATH.parent.parent + assert (root / "requirements.txt").is_file() # корень найден по маркеру + + +def test_clamp_interval(): + 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