refactor(admin): extract _check_token into shared AdminTokenAuth dep (#133)

Per audit batch #127 P2 hygiene (issue #128).

## Problem

Identical `_check_token` function в 3 admin files:
- backend/app/api/v1/admin_scrape.py:37-44
- backend/app/api/v1/admin_leads.py:26-33
- backend/app/api/v1/admin_jobs.py:24-31

## Fix

New `backend/app/core/deps.py` с `verify_admin_token` + Annotated alias:
```python
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
```

Заменено в 27 endpoints (18 admin_scrape + 6 admin_leads + 3 admin_jobs):
- Removed local `_check_token` definitions
- `_: None = Depends(_check_token)` → `_: AdminTokenAuth`
- Removed unused imports (`Header`, `HTTPException`, `settings` где не используется)

## Semantics preserved

Все 3 original implementations были identical (503 if env missing + 401 if
mismatch, same header `X-Admin-Token`, same env `SCRAPE_ADMIN_TOKEN`).

Минор: 503 detail text unified to `"admin disabled — set SCRAPE_ADMIN_TOKEN"`
(в оригинале три разные строки — `"admin scrape disabled..."` etc).
Cosmetic — detail в HTTP 503 не парсится клиентским кодом.

## Tests

`uv run pytest tests/ -x -k admin` → 64/64 passed.

## Vault

`code/patterns/Pattern_Admin_Token_Dependency_May14.md` — created.

Closes #128
Refs: #127

Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
lekss361 2026-05-14 23:45:44 +03:00 committed by GitHub
parent 8647114b22
commit 389fd31fad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 63 additions and 102 deletions

View file

@ -11,33 +11,22 @@ from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.deps import AdminTokenAuth
from app.schemas.job_settings import JobSettingRead, JobSettingUpdate
router = APIRouter()
def _check_token(x_admin_token: str | None) -> None:
if not settings.scrape_admin_token:
raise HTTPException(
status_code=503,
detail="admin jobs disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=401, detail="invalid admin token")
@router.get("/settings", response_model=list[JobSettingRead])
def list_job_settings(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> Any:
"""Список всех job_settings (все job_type)."""
_check_token(x_admin_token)
from app.services.job_settings import get_all
return get_all(db)
@ -47,10 +36,9 @@ def list_job_settings(
def get_job_setting(
job_type: str,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> Any:
"""Настройки одного job_type. Возвращает fallback defaults если строки нет в БД."""
_check_token(x_admin_token)
from app.services.job_settings import get_one
return get_one(job_type, db)
@ -61,7 +49,7 @@ def update_job_setting(
job_type: str,
payload: JobSettingUpdate,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> Any:
"""PATCH-style update: передавать только изменяемые поля.
@ -71,7 +59,6 @@ def update_job_setting(
cron_schedule='' устанавливает NULL (manual-only режим).
rate_ms=0 устанавливает NULL (no throttle).
"""
_check_token(x_admin_token)
from app.services.job_settings import update
result = update(

View file

@ -12,31 +12,21 @@ from __future__ import annotations
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.deps import AdminTokenAuth
from app.services import analytics_queries as q
router = APIRouter()
def _check_token(x_admin_token: str | None) -> None:
if not settings.scrape_admin_token:
raise HTTPException(
status_code=503,
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=401, detail="invalid admin token")
@router.get("/")
def list_leads(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
status: Annotated[str | None, Query()] = None,
source: Annotated[str | None, Query()] = None,
converted: Annotated[bool | None, Query()] = None,
@ -48,7 +38,6 @@ def list_leads(
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> dict[str, Any]:
_check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": limit, "off": offset}
if status:
@ -140,11 +129,10 @@ def list_leads(
@router.get("/stats")
def leads_stats(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> dict[str, Any]:
"""KPI summary за последние N месяцев."""
_check_token(x_admin_token)
row = (
db.execute(
text(
@ -202,42 +190,38 @@ def leads_stats(
@router.get("/funnel/monthly")
def funnel_monthly(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (по source)."""
_check_token(x_admin_token)
return q.prinzip_funnel_monthly(db, months=months)
@router.get("/funnel/by-source")
def funnel_by_source(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
_check_token(x_admin_token)
return q.prinzip_funnel_by_source(db, months=months)
@router.get("/funnel/by-object")
def funnel_by_object(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
_check_token(x_admin_token)
return q.prinzip_funnel_by_object(db)
@router.get("/sources")
def list_sources(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> list[str]:
"""Distinct list of source values for filter dropdown."""
_check_token(x_admin_token)
rows = db.execute(
text(
"""

View file

@ -12,13 +12,14 @@ from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.deps import AdminTokenAuth
router = APIRouter()
@ -34,22 +35,11 @@ class TriggerKnRequest(BaseModel):
force: bool = False
def _check_token(x_admin_token: str | None) -> None:
if not settings.scrape_admin_token:
raise HTTPException(
status_code=503,
detail="admin scrape disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=401, detail="invalid admin token")
@router.post("/kn")
def trigger_kn_sweep(
payload: TriggerKnRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
_check_token(x_admin_token)
# Defer Celery import so the API works without a broker in dev.
from app.workers.tasks.scrape_kn import (
force_release_lock,
@ -82,7 +72,7 @@ def trigger_kn_sweep(
@router.get("/queue")
def queue_status(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Snapshot of Celery state, optimised for UI poll latency.
@ -96,7 +86,6 @@ def queue_status(
Worst-case latency 600 ms even if no worker is reachable, vs ~8 s with
the previous 4×2 s blocking inspect calls.
"""
_check_token(x_admin_token)
import concurrent.futures
import time
@ -228,11 +217,10 @@ class ReleaseLockRequest(BaseModel):
@router.post("/release-lock")
def release_lock(
payload: ReleaseLockRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Принудительно удалить Redis-lock для (region, devs). Использовать когда
зомби-worker оставил lock и новые задачи скипаются с reason='lock_held'."""
_check_token(x_admin_token)
from app.workers.tasks.scrape_kn import force_release_lock
released = force_release_lock(payload.region_code, payload.developers)
@ -251,10 +239,9 @@ class RevokeRequest(BaseModel):
@router.post("/revoke")
def revoke_task(
payload: RevokeRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Cancel a queued or running task by id."""
_check_token(x_admin_token)
from app.workers.celery_app import celery_app
celery_app.control.revoke(payload.task_id, terminate=payload.terminate, signal="SIGTERM")
@ -264,12 +251,11 @@ def revoke_task(
@router.get("/failures")
def list_failures(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
run_id: int | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
"""Per-request failure log for manual browser verification."""
_check_token(x_admin_token)
where = ""
params: dict[str, Any] = {"lim": min(limit, 200)}
if run_id is not None:
@ -311,14 +297,13 @@ def list_failures(
@router.get("/logs")
def list_logs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
run_id: int | None = None,
since_id: int | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Per-run progress events. Use since_id to poll incrementally:
pass the highest log_id seen returns only newer rows."""
_check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": min(limit, 1000)}
if run_id is not None:
@ -369,7 +354,7 @@ def list_logs(
@router.post("/noise-sync")
def trigger_noise_sync(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации OSM шумовых источников ЕКБ из Overpass API.
@ -377,7 +362,6 @@ def trigger_noise_sync(
Этот endpoint для ad-hoc запуска (например после первого деплоя или
после добавления миграции 84_osm_noise_sources_ekb).
"""
_check_token(x_admin_token)
from app.workers.tasks.noise_sync import sync_osm_noise_sources_ekb
result = sync_osm_noise_sources_ekb.apply_async()
@ -401,7 +385,7 @@ class HarvestQuarterRequest(BaseModel):
@router.post("/nspd/harvest-quarter")
def trigger_harvest_quarter(
payload: HarvestQuarterRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger одного quarter harvest (для testing / конкретного квартала).
@ -409,7 +393,6 @@ def trigger_harvest_quarter(
Для получения результата poll через Celery inspect или нождите строку
в nspd_quarter_dumps WHERE quarter_cad = payload.quarter_cad.
"""
_check_token(x_admin_token)
from app.workers.tasks.nspd_sync import harvest_quarter
task = harvest_quarter.apply_async(
@ -430,14 +413,13 @@ def trigger_harvest_quarter(
@router.post("/pzz-sync")
def trigger_pzz_sync(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для импорта ПЗЗ территориальных зон ЕКБ из Росреестр PKK6.
Запускать после деплоя миграции 85_pzz_zones_ekb.sql и при необходимости
обновить данные о зонировании (обычно раз в несколько месяцев).
"""
_check_token(x_admin_token)
from app.workers.tasks.pzz_sync import sync_pzz_zones_ekb
result = sync_pzz_zones_ekb.apply_async()
@ -446,7 +428,7 @@ def trigger_pzz_sync(
@router.post("/poi-sync")
def trigger_poi_sync(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации OSM POI ЕКБ из Overpass API.
@ -454,7 +436,6 @@ def trigger_poi_sync(
Этот endpoint для ad-hoc запуска (например после первого деплоя
или при создании нового тестового участка).
"""
_check_token(x_admin_token)
from app.workers.tasks.poi_sync import sync_osm_poi_ekb
result = sync_osm_poi_ekb.apply_async()
@ -471,7 +452,7 @@ class TriggerObjectiveEtlRequest(BaseModel):
@router.post("/objective")
def trigger_objective_etl(
payload: TriggerObjectiveEtlRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Запустить ETL Антоновского SQLite → наша PG.
@ -479,7 +460,6 @@ def trigger_objective_etl(
- на проде: смонтирован bind-mount-ом из /opt/gendesign/site-finder/
- локально: путь передать через payload.sqlite_path
"""
_check_token(x_admin_token)
from app.workers.tasks.objective_etl import import_anton_objective
result = import_anton_objective.apply_async(
@ -497,10 +477,9 @@ def trigger_objective_etl(
@router.get("/objective/runs")
def list_objective_runs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
limit: int = 20,
) -> list[dict[str, Any]]:
_check_token(x_admin_token)
rows = (
db.execute(
text(
@ -556,16 +535,15 @@ class TriggerObjectiveSyncRequest(BaseModel):
@router.post("/objective/sync-our")
def trigger_objective_sync_our(
payload: TriggerObjectiveSyncRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Запустить НАШ sync_all_groups (тратит limits Объектива).
В отличие от ETL (POST /objective) это вызовы api.objctv.ru напрямую,
с парсингом payload через app.workers.tasks.scrape_objective.
Полезно когда нужно расширить покрытие за пределы Антоновского ЕКБ
Полезно когда нужно расширить покрытие за пределов Антоновского ЕКБ
(Свердл.обл целиком + Челябинск + Тюмень + Пермь).
"""
_check_token(x_admin_token)
if not settings.objective_api_key:
raise HTTPException(
status_code=503,
@ -606,10 +584,9 @@ class ObjectiveConfigUpdateRequest(BaseModel):
@router.get("/objective/config")
def get_objective_config(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Текущая динамическая конфигурация Objective sync (single row)."""
_check_token(x_admin_token)
from app.services.objective_sync_config import get_config
return get_config(db).to_dict()
@ -619,11 +596,10 @@ def get_objective_config(
def update_objective_config(
payload: ObjectiveConfigUpdateRequest,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""PATCH-style update. После изменения cron_schedule нужен restart beat,
остальные поля применяются на следующем sync-вызове автоматически."""
_check_token(x_admin_token)
from app.services.objective_sync_config import update_config
cfg = update_config(
@ -650,14 +626,13 @@ def update_objective_config(
@router.get("/objective/coverage")
def objective_coverage(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Что у нас в БД сейчас + что лежит в SQLite Антона (size, last modified).
Помогает понять стоит ли запускать ETL: если SQLite старше последнего
успешного run'а — данные не изменятся.
"""
_check_token(x_admin_token)
from app.services.objective_etl import get_sqlite_info
pg_row = (
@ -718,7 +693,7 @@ class EnqueueGeoJobRequest(BaseModel):
def enqueue_geo_job(
payload: EnqueueGeoJobRequest,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Создать bulk geo-job и поставить в очередь.
@ -728,7 +703,6 @@ def enqueue_geo_job(
которых ещё нет в cad_quarters_geom (для region_codes если задано)
- region_bbox: TODO (для будущего spatial-bulk)
"""
_check_token(x_admin_token)
from app.workers.tasks.nspd_geo import enqueue_geo_job as enqueue_helper
from app.workers.tasks.nspd_geo import process_nspd_geo_job
@ -900,7 +874,7 @@ def _collect_all_in_region(
def bulk_enqueue_geo(
payload: BulkGeoEnqueueRequest,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Разбить pending cad-номера на N чанков и запустить N×len(thematic_ids) geo-jobs.
@ -909,7 +883,6 @@ def bulk_enqueue_geo(
source=all_in_region: UNION из rosreestr_deals + cad_buildings + complexes один
набор для всех thematic_ids (каждый thematic_id обрабатывает весь список).
"""
_check_token(x_admin_token)
from app.services.job_settings import get_setting_value
from app.workers.tasks.nspd_geo import enqueue_geo_job as enqueue_helper
from app.workers.tasks.nspd_geo import process_nspd_geo_job
@ -993,11 +966,10 @@ def bulk_enqueue_geo(
@router.get("/geo/jobs")
def list_geo_jobs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
limit: int = 30,
) -> list[dict[str, Any]]:
"""Список последних geo-jobs (для UI dashboard)."""
_check_token(x_admin_token)
rows = (
db.execute(
text(
@ -1049,10 +1021,9 @@ def list_geo_jobs(
def cancel_geo_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Пометить job как cancelled. Worker увидит при следующей итерации."""
_check_token(x_admin_token)
db.execute(
text(
"""
@ -1071,10 +1042,9 @@ def cancel_geo_job(
def resume_geo_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Re-enqueue paused/failed job. Resume idempotent через pending targets."""
_check_token(x_admin_token)
from app.services.job_settings import get_setting_value
from app.workers.tasks.nspd_geo import process_nspd_geo_job
@ -1094,7 +1064,7 @@ def resume_geo_job(
@router.get("/all/runs")
def list_all_runs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
scraper_type: str | None = None,
limit: int = 30,
) -> list[dict[str, Any]]:
@ -1103,7 +1073,6 @@ def list_all_runs(
Поверх v_scrape_runs_unified. Если scraper_type не задан все 3.
Используется главной dashboard /admin/scrape/all.
"""
_check_token(x_admin_token)
where = "" if not scraper_type else "WHERE scraper_type = :st"
params: dict[str, Any] = {"lim": min(limit, 200)}
if scraper_type:
@ -1153,13 +1122,12 @@ def list_all_runs(
@router.get("/all/logs")
def list_all_logs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
scraper_type: str | None = None,
run_id: int | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
_check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": min(limit, 1000)}
if scraper_type:
@ -1206,10 +1174,9 @@ def list_all_logs(
@router.get("/runs")
def list_runs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
_: AdminTokenAuth,
limit: int = 20,
) -> list[dict[str, Any]]:
_check_token(x_admin_token)
rows = (
db.execute(
text(

23
backend/app/core/deps.py Normal file
View file

@ -0,0 +1,23 @@
"""Shared FastAPI dependencies."""
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from app.core.config import settings
def verify_admin_token(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> None:
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
if not settings.scrape_admin_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]