refactor(security): убрать X-Admin-Token (Caddy basic_auth достаточен) (#437)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m20s
Deploy / build-worker (push) Successful in 2m21s
Deploy / deploy (push) Successful in 1m7s

This commit is contained in:
lekss361 2026-05-23 10:41:22 +00:00
parent 0e294641a1
commit b233bf91cc
7 changed files with 22 additions and 67 deletions

View file

@ -27,6 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800
SCRAPE_KN_DEFAULT_REGIONS=66 SCRAPE_KN_DEFAULT_REGIONS=66
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state). # Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
SCRAPE_KN_STATE_PATH=data/playwright_state.json SCRAPE_KN_STATE_PATH=data/playwright_state.json
# Токен для POST /api/v1/admin/scrape/kn (заголовок X-Admin-Token). # DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен).
# Пустая строка = endpoint disabled (503). Сгенерировать: openssl rand -hex 32 # Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep.
# Переменная сохранена в core/deps.py для быстрого rollback.
SCRAPE_ADMIN_TOKEN= SCRAPE_ADMIN_TOKEN=

View file

@ -11,6 +11,9 @@ Scope variants:
pilot первые 50 кварталов ЕКБ (66:41:%) pilot первые 50 кварталов ЕКБ (66:41:%)
ekb_full все ~2408 кварталов ЕКБ ekb_full все ~2408 кварталов ЕКБ
manual_list явный список quarters в body.quarters manual_list явный список quarters в body.quarters
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
""" """
from __future__ import annotations from __future__ import annotations
@ -25,7 +28,6 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import AdminTokenAuth
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -185,7 +187,6 @@ def _serialize_job(row: Any) -> dict[str, Any]:
def create_cadastre_job( def create_cadastre_job(
body: CreateCadastreJobRequest, body: CreateCadastreJobRequest,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Создать bulk cadastre harvest job и поставить в очередь. """Создать bulk cadastre harvest job и поставить в очередь.
@ -234,7 +235,6 @@ def create_cadastre_job(
@router.get("/jobs") @router.get("/jobs")
def list_cadastre_jobs( def list_cadastre_jobs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
limit: int = 30, limit: int = 30,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Список последних cadastre harvest jobs.""" """Список последних cadastre harvest jobs."""
@ -262,7 +262,6 @@ def list_cadastre_jobs(
def get_cadastre_job( def get_cadastre_job(
job_id: int, job_id: int,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Детали одного cadastre harvest job.""" """Детали одного cadastre harvest job."""
row = ( row = (
@ -297,7 +296,6 @@ def get_cadastre_job(
def cancel_cadastre_job( def cancel_cadastre_job(
job_id: int, job_id: int,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Отменить job. Воркеры увидят status='cancelled' при следующей итерации и skip.""" """Отменить job. Воркеры увидят status='cancelled' при следующей итерации и skip."""
result = db.execute( result = db.execute(
@ -327,7 +325,6 @@ def cancel_cadastre_job(
def resume_cadastre_job( def resume_cadastre_job(
job_id: int, job_id: int,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Возобновить paused/failed job. Re-enqueue enqueue_cadastre_harvest.""" """Возобновить paused/failed job. Re-enqueue enqueue_cadastre_harvest."""
from app.workers.tasks.scrape_cadastre import enqueue_cadastre_harvest from app.workers.tasks.scrape_cadastre import enqueue_cadastre_harvest

View file

@ -9,7 +9,8 @@ POST /api/v1/admin/etl/objective-backfill
POST /api/v1/admin/etl/nspd-denorm-backfill POST /api/v1/admin/etl/nspd-denorm-backfill
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps. Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
Header: X-Admin-Token: <SCRAPE_ADMIN_TOKEN> Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
""" """
from __future__ import annotations from __future__ import annotations
@ -21,7 +22,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import AdminTokenAuth
from app.services.etl.objective_backfill import ( from app.services.etl.objective_backfill import (
AUTO_ACCEPT_THRESHOLD, AUTO_ACCEPT_THRESHOLD,
AUTO_ACCEPT_THRESHOLD_V2, AUTO_ACCEPT_THRESHOLD_V2,
@ -38,7 +38,6 @@ router = APIRouter()
@router.post("/objective-backfill") @router.post("/objective-backfill")
def run_objective_backfill( def run_objective_backfill(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
dry_run: Annotated[bool, Query(description="Preview без insertions")] = False, dry_run: Annotated[bool, Query(description="Preview без insertions")] = False,
refresh_mv: Annotated[ refresh_mv: Annotated[
bool, Query(description="REFRESH mv_layout_velocity после backfill") bool, Query(description="REFRESH mv_layout_velocity после backfill")
@ -107,7 +106,6 @@ def run_objective_backfill(
@router.post("/nspd-denorm-backfill") @router.post("/nspd-denorm-backfill")
def run_nspd_denorm_backfill( def run_nspd_denorm_backfill(
_: AdminTokenAuth,
limit: Annotated[ limit: Annotated[
int | None, int | None,
Query(description="Максимум кварталов для обработки (None = все)"), Query(description="Максимум кварталов для обработки (None = все)"),

View file

@ -4,7 +4,8 @@ GET /api/v1/admin/jobs/settings — список всех job_settings
GET /api/v1/admin/jobs/settings/{type} одна строка по job_type GET /api/v1/admin/jobs/settings/{type} одна строка по job_type
PUT /api/v1/admin/jobs/settings/{type} PATCH-style update PUT /api/v1/admin/jobs/settings/{type} PATCH-style update
Auth: X-Admin-Token header (тот же токен что у admin_scrape endpoints). Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
""" """
from __future__ import annotations from __future__ import annotations
@ -15,7 +16,6 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import AdminTokenAuth
from app.schemas.job_settings import JobSettingRead, JobSettingUpdate from app.schemas.job_settings import JobSettingRead, JobSettingUpdate
router = APIRouter() router = APIRouter()
@ -24,7 +24,6 @@ router = APIRouter()
@router.get("/settings", response_model=list[JobSettingRead]) @router.get("/settings", response_model=list[JobSettingRead])
def list_job_settings( def list_job_settings(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> Any: ) -> Any:
"""Список всех job_settings (все job_type).""" """Список всех job_settings (все job_type)."""
from app.services.job_settings import get_all from app.services.job_settings import get_all
@ -36,7 +35,6 @@ def list_job_settings(
def get_job_setting( def get_job_setting(
job_type: str, job_type: str,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> Any: ) -> Any:
"""Настройки одного job_type. Возвращает fallback defaults если строки нет в БД.""" """Настройки одного job_type. Возвращает fallback defaults если строки нет в БД."""
from app.services.job_settings import get_one from app.services.job_settings import get_one
@ -49,7 +47,6 @@ def update_job_setting(
job_type: str, job_type: str,
payload: JobSettingUpdate, payload: JobSettingUpdate,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> Any: ) -> Any:
"""PATCH-style update: передавать только изменяемые поля. """PATCH-style update: передавать только изменяемые поля.

View file

@ -3,7 +3,8 @@
GET /api/v1/admin/leads list with filters + pagination GET /api/v1/admin/leads list with filters + pagination
GET /api/v1/admin/leads/stats KPI summary (total/conversion/revenue) GET /api/v1/admin/leads/stats KPI summary (total/conversion/revenue)
Auth: same X-Admin-Token as /admin/scrape (settings.scrape_admin_token). Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
PII compliance: only phone_last4 and email_hash exposed; raw phone/email PII compliance: only phone_last4 and email_hash exposed; raw phone/email
were never persisted (см. data/sql/52_import_prinzip_crm.py). were never persisted (см. data/sql/52_import_prinzip_crm.py).
""" """
@ -17,7 +18,6 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import AdminTokenAuth
from app.services import analytics_queries as q from app.services import analytics_queries as q
router = APIRouter() router = APIRouter()
@ -26,7 +26,6 @@ router = APIRouter()
@router.get("/") @router.get("/")
def list_leads( def list_leads(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
status: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None,
source: Annotated[str | None, Query()] = None, source: Annotated[str | None, Query()] = None,
converted: Annotated[bool | None, Query()] = None, converted: Annotated[bool | None, Query()] = None,
@ -129,7 +128,6 @@ def list_leads(
@router.get("/stats") @router.get("/stats")
def leads_stats( def leads_stats(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 12, months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""KPI summary за последние N месяцев.""" """KPI summary за последние N месяцев."""
@ -190,7 +188,6 @@ def leads_stats(
@router.get("/funnel/monthly") @router.get("/funnel/monthly")
def funnel_monthly( def funnel_monthly(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 24, months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (по source).""" """Воронка по месяцам: leads → engaged → converted (по source)."""
@ -200,7 +197,6 @@ def funnel_monthly(
@router.get("/funnel/by-source") @router.get("/funnel/by-source")
def funnel_by_source( def funnel_by_source(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 12, months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев.""" """Splitting по source: кто конвертит лучше за последние N месяцев."""
@ -210,7 +206,6 @@ def funnel_by_source(
@router.get("/funnel/by-object") @router.get("/funnel/by-object")
def funnel_by_object( def funnel_by_object(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue.""" """Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
return q.prinzip_funnel_by_object(db) return q.prinzip_funnel_by_object(db)
@ -219,7 +214,6 @@ def funnel_by_object(
@router.get("/sources") @router.get("/sources")
def list_sources( def list_sources(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> list[str]: ) -> list[str]:
"""Distinct list of source values for filter dropdown.""" """Distinct list of source values for filter dropdown."""
rows = db.execute( rows = db.execute(

View file

@ -2,9 +2,10 @@
POST /api/v1/admin/scrape/kn POST /api/v1/admin/scrape/kn
Body: {"region_code": 66, "developers": ["6208_0"]?, "fetch_flats": true?} Body: {"region_code": 66, "developers": ["6208_0"]?, "fetch_flats": true?}
Header: X-Admin-Token: <SCRAPE_ADMIN_TOKEN env var>
Disabled if SCRAPE_ADMIN_TOKEN is empty (default). Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-side progress. Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-side progress.
""" """
@ -19,7 +20,6 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import AdminTokenAuth
router = APIRouter() router = APIRouter()
@ -38,7 +38,6 @@ class TriggerKnRequest(BaseModel):
@router.post("/kn") @router.post("/kn")
def trigger_kn_sweep( def trigger_kn_sweep(
payload: TriggerKnRequest, payload: TriggerKnRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
# Defer Celery import so the API works without a broker in dev. # Defer Celery import so the API works without a broker in dev.
from app.workers.tasks.scrape_kn import ( from app.workers.tasks.scrape_kn import (
@ -72,7 +71,6 @@ def trigger_kn_sweep(
@router.get("/queue") @router.get("/queue")
def queue_status( def queue_status(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Snapshot of Celery state, optimised for UI poll latency. """Snapshot of Celery state, optimised for UI poll latency.
@ -217,7 +215,6 @@ class ReleaseLockRequest(BaseModel):
@router.post("/release-lock") @router.post("/release-lock")
def release_lock( def release_lock(
payload: ReleaseLockRequest, payload: ReleaseLockRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Принудительно удалить Redis-lock для (region, devs). Использовать когда """Принудительно удалить Redis-lock для (region, devs). Использовать когда
зомби-worker оставил lock и новые задачи скипаются с reason='lock_held'.""" зомби-worker оставил lock и новые задачи скипаются с reason='lock_held'."""
@ -239,7 +236,6 @@ class RevokeRequest(BaseModel):
@router.post("/revoke") @router.post("/revoke")
def revoke_task( def revoke_task(
payload: RevokeRequest, payload: RevokeRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Cancel a queued or running task by id.""" """Cancel a queued or running task by id."""
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app
@ -251,7 +247,6 @@ def revoke_task(
@router.get("/failures") @router.get("/failures")
def list_failures( def list_failures(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
run_id: int | None = None, run_id: int | None = None,
limit: int = 50, limit: int = 50,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -297,7 +292,6 @@ def list_failures(
@router.get("/logs") @router.get("/logs")
def list_logs( def list_logs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
run_id: int | None = None, run_id: int | None = None,
since_id: int | None = None, since_id: int | None = None,
limit: int = 200, limit: int = 200,
@ -353,9 +347,7 @@ def list_logs(
@router.post("/noise-sync") @router.post("/noise-sync")
def trigger_noise_sync( def trigger_noise_sync() -> dict[str, Any]:
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации OSM шумовых источников ЕКБ из Overpass API. """Manual trigger для синхронизации OSM шумовых источников ЕКБ из Overpass API.
Обычно запускается еженедельно через beat (понед 3:30 МСК). Обычно запускается еженедельно через beat (понед 3:30 МСК).
@ -385,7 +377,6 @@ class HarvestQuarterRequest(BaseModel):
@router.post("/nspd/harvest-quarter") @router.post("/nspd/harvest-quarter")
def trigger_harvest_quarter( def trigger_harvest_quarter(
payload: HarvestQuarterRequest, payload: HarvestQuarterRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Manual trigger одного quarter harvest (для testing / конкретного квартала). """Manual trigger одного quarter harvest (для testing / конкретного квартала).
@ -412,9 +403,7 @@ def trigger_harvest_quarter(
@router.post("/pzz-sync") @router.post("/pzz-sync")
def trigger_pzz_sync( def trigger_pzz_sync() -> dict[str, Any]:
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для импорта ПЗЗ территориальных зон ЕКБ из Росреестр PKK6. """Manual trigger для импорта ПЗЗ территориальных зон ЕКБ из Росреестр PKK6.
Запускать после деплоя миграции 85_pzz_zones_ekb.sql и при необходимости Запускать после деплоя миграции 85_pzz_zones_ekb.sql и при необходимости
@ -427,9 +416,7 @@ def trigger_pzz_sync(
@router.post("/poi-sync") @router.post("/poi-sync")
def trigger_poi_sync( def trigger_poi_sync() -> dict[str, Any]:
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации OSM POI ЕКБ из Overpass API. """Manual trigger для синхронизации OSM POI ЕКБ из Overpass API.
Обычно запускается еженедельно через beat (понед 3:00 МСК). Обычно запускается еженедельно через beat (понед 3:00 МСК).
@ -452,7 +439,6 @@ class TriggerObjectiveEtlRequest(BaseModel):
@router.post("/objective") @router.post("/objective")
def trigger_objective_etl( def trigger_objective_etl(
payload: TriggerObjectiveEtlRequest, payload: TriggerObjectiveEtlRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Запустить ETL Антоновского SQLite → наша PG. """Запустить ETL Антоновского SQLite → наша PG.
@ -477,7 +463,6 @@ def trigger_objective_etl(
@router.get("/objective/runs") @router.get("/objective/runs")
def list_objective_runs( def list_objective_runs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
limit: int = 20, limit: int = 20,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = ( rows = (
@ -535,7 +520,6 @@ class TriggerObjectiveSyncRequest(BaseModel):
@router.post("/objective/sync-our") @router.post("/objective/sync-our")
def trigger_objective_sync_our( def trigger_objective_sync_our(
payload: TriggerObjectiveSyncRequest, payload: TriggerObjectiveSyncRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Запустить НАШ sync_all_groups (тратит limits Объектива). """Запустить НАШ sync_all_groups (тратит limits Объектива).
@ -584,7 +568,6 @@ class ObjectiveConfigUpdateRequest(BaseModel):
@router.get("/objective/config") @router.get("/objective/config")
def get_objective_config( def get_objective_config(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Текущая динамическая конфигурация Objective sync (single row).""" """Текущая динамическая конфигурация Objective sync (single row)."""
from app.services.objective_sync_config import get_config from app.services.objective_sync_config import get_config
@ -596,7 +579,6 @@ def get_objective_config(
def update_objective_config( def update_objective_config(
payload: ObjectiveConfigUpdateRequest, payload: ObjectiveConfigUpdateRequest,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""PATCH-style update. После изменения cron_schedule нужен restart beat, """PATCH-style update. После изменения cron_schedule нужен restart beat,
остальные поля применяются на следующем sync-вызове автоматически.""" остальные поля применяются на следующем sync-вызове автоматически."""
@ -626,7 +608,6 @@ def update_objective_config(
@router.get("/objective/coverage") @router.get("/objective/coverage")
def objective_coverage( def objective_coverage(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Что у нас в БД сейчас + что лежит в SQLite Антона (size, last modified). """Что у нас в БД сейчас + что лежит в SQLite Антона (size, last modified).
@ -693,7 +674,6 @@ class EnqueueGeoJobRequest(BaseModel):
def enqueue_geo_job( def enqueue_geo_job(
payload: EnqueueGeoJobRequest, payload: EnqueueGeoJobRequest,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Создать bulk geo-job и поставить в очередь. """Создать bulk geo-job и поставить в очередь.
@ -874,7 +854,6 @@ def _collect_all_in_region(
def bulk_enqueue_geo( def bulk_enqueue_geo(
payload: BulkGeoEnqueueRequest, payload: BulkGeoEnqueueRequest,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Разбить pending cad-номера на N чанков и запустить N×len(thematic_ids) geo-jobs. """Разбить pending cad-номера на N чанков и запустить N×len(thematic_ids) geo-jobs.
@ -966,7 +945,6 @@ def bulk_enqueue_geo(
@router.get("/geo/jobs") @router.get("/geo/jobs")
def list_geo_jobs( def list_geo_jobs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
limit: int = 30, limit: int = 30,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Список последних geo-jobs (для UI dashboard).""" """Список последних geo-jobs (для UI dashboard)."""
@ -1021,7 +999,6 @@ def list_geo_jobs(
def cancel_geo_job( def cancel_geo_job(
job_id: int, job_id: int,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Пометить job как cancelled. Worker увидит при следующей итерации.""" """Пометить job как cancelled. Worker увидит при следующей итерации."""
db.execute( db.execute(
@ -1042,7 +1019,6 @@ def cancel_geo_job(
def resume_geo_job( def resume_geo_job(
job_id: int, job_id: int,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Re-enqueue paused/failed job. Resume idempotent через pending targets.""" """Re-enqueue paused/failed job. Resume idempotent через pending targets."""
from app.services.job_settings import get_setting_value from app.services.job_settings import get_setting_value
@ -1064,7 +1040,6 @@ def resume_geo_job(
@router.get("/all/runs") @router.get("/all/runs")
def list_all_runs( def list_all_runs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
scraper_type: str | None = None, scraper_type: str | None = None,
limit: int = 30, limit: int = 30,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -1122,7 +1097,6 @@ def list_all_runs(
@router.get("/all/logs") @router.get("/all/logs")
def list_all_logs( def list_all_logs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
scraper_type: str | None = None, scraper_type: str | None = None,
run_id: int | None = None, run_id: int | None = None,
limit: int = 200, limit: int = 200,
@ -1174,7 +1148,6 @@ def list_all_logs(
@router.get("/runs") @router.get("/runs")
def list_runs( def list_runs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
limit: int = 20, limit: int = 20,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = ( rows = (
@ -1229,7 +1202,6 @@ class TriggerEkburgPermitsRequest(BaseModel):
@router.post("/ekburg-permits") @router.post("/ekburg-permits")
def trigger_ekburg_permits( def trigger_ekburg_permits(
payload: TriggerEkburgPermitsRequest, payload: TriggerEkburgPermitsRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Manual trigger для синхронизации РНС/РВЭ ЕКБ из екатеринбург.рф (xlsx). """Manual trigger для синхронизации РНС/РВЭ ЕКБ из екатеринбург.рф (xlsx).
@ -1267,7 +1239,6 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
@router.post("/kn-catalog-objects") @router.post("/kn-catalog-objects")
def trigger_kn_catalog_objects( def trigger_kn_catalog_objects(
payload: TriggerKnCatalogObjectsRequest, payload: TriggerKnCatalogObjectsRequest,
_: AdminTokenAuth,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, """Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).

View file

@ -1,6 +1,9 @@
"""Admin endpoints для управления weight profiles. """Admin endpoints для управления weight profiles.
Per #114 (Макс feedback: садики ≠ Мегамарт). Endpoints под X-Admin-Token. Per #114 (Макс feedback: садики ≠ Мегамарт).
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
GET /api/v1/admin/site-finder/weight-profiles?user_id=... list GET /api/v1/admin/site-finder/weight-profiles?user_id=... list
POST /api/v1/admin/site-finder/weight-profiles create POST /api/v1/admin/site-finder/weight-profiles create
@ -17,7 +20,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import AdminTokenAuth
from app.services.site_finder.weight_profiles import ( from app.services.site_finder.weight_profiles import (
WeightProfile, WeightProfile,
WeightProfileCreate, WeightProfileCreate,
@ -37,7 +39,6 @@ router = APIRouter()
def list_user_profiles( def list_user_profiles(
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")], user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
include_system: Annotated[ include_system: Annotated[
bool, bool,
Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"), Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"),
@ -57,7 +58,6 @@ def list_user_profiles(
def create_user_profile( def create_user_profile(
payload: WeightProfileCreate, payload: WeightProfileCreate,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> Any: ) -> Any:
"""Создать новый weight profile. """Создать новый weight profile.
@ -72,7 +72,6 @@ def get_user_profile(
profile_id: int, profile_id: int,
user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")], user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> Any: ) -> Any:
"""Получить один weight profile по id (scoped к user_id).""" """Получить один weight profile по id (scoped к user_id)."""
profile = get_profile(db, user_id, profile_id) profile = get_profile(db, user_id, profile_id)
@ -87,7 +86,6 @@ def update_user_profile(
user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")], user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")],
payload: WeightProfileUpdate, payload: WeightProfileUpdate,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> Any: ) -> Any:
"""PATCH-style обновление weight profile. """PATCH-style обновление weight profile.
@ -105,7 +103,6 @@ def delete_user_profile(
profile_id: int, profile_id: int,
user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")], user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> None: ) -> None:
"""Удалить weight profile. 404 если не найден.""" """Удалить weight profile. 404 если не найден."""
deleted = delete_profile(db, user_id, profile_id) deleted = delete_profile(db, user_id, profile_id)