gendesign/backend/app/api/v1/admin_cadastre.py
lekss361 5da66bbc93
feat(cadastre): bulk_harvest worker + grid-walker + admin API (#168 PR3/5) (#171)
* feat(cadastre): bulk_harvest_quarter Celery task + grid-walker + saga state (#168 PR3/5)

Add bulk cadastre harvest pipeline:
- services/cadastre/bulk_harvest.py: async harvest_quarter() orchestrator (4 phases)
  + 7 upsert helpers (parcels/buildings/constructions/oncs/enks/zouit/quarter_stats)
  using CAST(:x AS jsonb) pattern, begin_nested() SAVEPOINT per grid-walk upsert
- services/cadastre/grid_geometry.py: quarter_bbox_3857 (PostGIS ST_Extent)
  + generate_grid_click_points (15x15 = 225 sub-bbox grid)
- workers/tasks/scrape_cadastre.py: bulk_harvest_quarter_task (acks_late=True,
  dont_autoretry_for NspdBulkWafError), enqueue_cadastre_harvest, cleanup_zombies
- api/v1/admin_cadastre.py: 5 endpoints behind AdminTokenAuth — create/list/get/cancel/resume
- Tests: 13 service unit + 8 API tests

* fixup(cadastre): drop importorskip (PR2 merged) — imports now top-level (#168 PR3)

* fixup(cadastre): tile_size unified param + register slow marker (#168 PR3)

Blocker #1: rename tile_width/tile_height → tile_size in generate_grid_click_points.
Callers in bulk_harvest.py and test_cadastre_bulk.py used tile_size; def had
tile_width+tile_height → TypeError at runtime.

Blocker #2 (10 failures in test_admin_cadastre.py) was side-effect of #1:
TypeError at import chain (app.main → admin_cadastre → bulk_harvest)
broke FastAPI app load → dependency_overrides AttributeError. Now resolved.

Also register `slow` pytest marker in pyproject.toml to suppress
PytestUnknownMarkWarning.

* fixup(cadastre): use importlib.util.find_spec instead of import (#168 PR3)

Root cause Blocker #2: `import app.workers.tasks.scrape_cadastre` at module
top-level REBOUND `app` from FastAPI instance (line 14) to the Python package.
All subsequent `app.dependency_overrides[get_db] = ...` failed with
AttributeError because `app` was now the namespace module.

Fix: probe module availability with importlib.util.find_spec — does not
import or rebind names. FastAPI `app` instance stays intact.

All 28 cadastre tests pass locally.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 13:31:32 +03:00

355 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Admin API для bulk cadastre harvest (GH issue #168, PR 3/5).
Endpoints:
POST /api/v1/admin/cadastre/jobs — создать job + enqueue
GET /api/v1/admin/cadastre/jobs — список jobs
GET /api/v1/admin/cadastre/jobs/{id} — детали job
POST /api/v1/admin/cadastre/jobs/{id}/cancel — отменить job
POST /api/v1/admin/cadastre/jobs/{id}/resume — возобновить paused job
Scope variants:
pilot — первые 50 кварталов ЕКБ (66:41:%)
ekb_full — все ~2408 кварталов ЕКБ
manual_list — явный список quarters в body.quarters
"""
from __future__ import annotations
import json
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.core.deps import AdminTokenAuth
logger = logging.getLogger(__name__)
router = APIRouter()
# Ограничение пилота
PILOT_LIMIT = 50
# Регион по умолчанию — ЕКБ (66:41:%)
EKB_PREFIX = "66:41:%"
# ── Request / response schemas ───────────────────────────────────────────────
class CreateCadastreJobRequest(BaseModel):
"""Параметры создания bulk cadastre job."""
scope: str = Field(
...,
pattern="^(pilot|ekb_full|manual_list)$",
description="pilot=50 кварталов ЕКБ, ekb_full=все 2408, manual_list=явный список",
)
quarters: list[str] | None = Field(
default=None,
max_length=5000,
description="Список кварталов для scope=manual_list",
)
limit: int | None = Field(
default=None,
ge=1,
le=10000,
description="Ограничить количество кварталов (override для pilot/ekb_full)",
)
name: str | None = Field(default=None, max_length=200)
rate_ms: int = Field(
default=333,
ge=50,
le=10000,
description="Задержка между запросами в мс (333мс = ~3 req/s)",
)
# ── Helpers ──────────────────────────────────────────────────────────────────
def _resolve_quarters(
db: Session,
scope: str,
quarters: list[str] | None,
limit: int | None,
) -> list[str]:
"""Собрать список кварталов согласно scope."""
if scope == "manual_list":
if not quarters:
raise HTTPException(
status_code=400,
detail="scope=manual_list требует непустой список quarters",
)
return [q.strip() for q in quarters if q.strip()]
cap = limit or (PILOT_LIMIT if scope == "pilot" else 100000)
rows = db.execute(
text("""
SELECT cad_number
FROM cad_quarters_geom
WHERE cad_number LIKE :prefix
ORDER BY cad_number
LIMIT :cap
"""),
{"prefix": EKB_PREFIX, "cap": cap},
).all()
if not rows:
raise HTTPException(
status_code=400,
detail=(
f"Нет кварталов с префиксом {EKB_PREFIX!r} в cad_quarters_geom. "
"Убедитесь что geo-backfill кварталов завершён."
),
)
return [str(r[0]) for r in rows]
def _create_job(
db: Session,
*,
name: str | None,
scope: str,
quarters: list[str],
rate_ms: int,
triggered_by: str = "manual",
) -> int:
"""INSERT в cadastre_jobs, вернуть job_id."""
job_id = db.execute(
text("""
INSERT INTO cadastre_jobs (
name, job_kind, scope, status,
rate_ms, triggered_by,
targets_total, raw_targets
) VALUES (
:name, :job_kind, :scope, 'queued',
:rate_ms, :triggered_by,
:targets_total, CAST(:raw_targets AS jsonb)
)
RETURNING job_id
"""),
{
"name": name,
"job_kind": scope,
"scope": scope,
"rate_ms": rate_ms,
"triggered_by": triggered_by,
"targets_total": len(quarters),
"raw_targets": json.dumps({"quarters": quarters}, ensure_ascii=False),
},
).scalar_one()
db.commit()
return int(job_id)
def _serialize_job(row: Any) -> dict[str, Any]:
"""Сериализовать строку cadastre_jobs в dict для API response."""
targets_total = row["targets_total"] or 0
targets_done = row["targets_done"] or 0
return {
"job_id": row["job_id"],
"name": row["name"],
"job_kind": row["job_kind"],
"scope": row["scope"],
"status": row["status"],
"triggered_by": row["triggered_by"],
"rate_ms": row["rate_ms"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"finished_at": row["finished_at"].isoformat() if row["finished_at"] else None,
"heartbeat_at": row["heartbeat_at"].isoformat() if row["heartbeat_at"] else None,
"targets_total": targets_total,
"targets_done": targets_done,
"targets_failed": row["targets_failed"] or 0,
"targets_skipped": row["targets_skipped"] or 0,
"requests_count": row["requests_count"] or 0,
"waf_blocked_count": row["waf_blocked_count"] or 0,
"error": row["error"],
"phase_state": row["phase_state"],
"progress_pct": round(100.0 * targets_done / max(targets_total, 1), 1),
"estimate_minutes": round(targets_total * (row["rate_ms"] or 333) / 1000 / 60, 1),
}
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.post("/jobs")
def create_cadastre_job(
body: CreateCadastreJobRequest,
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Создать bulk cadastre harvest job и поставить в очередь.
scope=pilot → первые 50 ЕКБ кварталов из cad_quarters_geom
scope=ekb_full → все ~2408 ЕКБ кварталов
scope=manual_list → body.quarters (явный список)
Возвращает: {job_id, targets_total, estimate_minutes}
"""
from app.workers.tasks.scrape_cadastre import enqueue_cadastre_harvest
quarters = _resolve_quarters(db, body.scope, body.quarters, body.limit)
job_id = _create_job(
db,
name=body.name or f"cadastre_{body.scope}",
scope=body.scope,
quarters=quarters,
rate_ms=body.rate_ms,
)
# Enqueue таск который в свою очередь запустит per-quarter tasks
enqueue_cadastre_harvest.apply_async(
args=[job_id],
queue="geo",
)
estimate_minutes = round(len(quarters) * body.rate_ms / 1000 / 60, 1)
logger.info(
"create_cadastre_job: job_id=%d scope=%s quarters=%d estimate=%.1fmin",
job_id,
body.scope,
len(quarters),
estimate_minutes,
)
return {
"job_id": job_id,
"scope": body.scope,
"targets_total": len(quarters),
"estimate_minutes": estimate_minutes,
}
@router.get("/jobs")
def list_cadastre_jobs(
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
limit: int = 30,
) -> list[dict[str, Any]]:
"""Список последних cadastre harvest jobs."""
rows = (
db.execute(
text("""
SELECT job_id, name, job_kind, scope, status, triggered_by,
rate_ms, created_at, started_at, finished_at, heartbeat_at,
targets_total, targets_done, targets_failed, targets_skipped,
requests_count, waf_blocked_count,
error, phase_state
FROM cadastre_jobs
ORDER BY created_at DESC
LIMIT :lim
"""),
{"lim": min(limit, 200)},
)
.mappings()
.all()
)
return [_serialize_job(r) for r in rows]
@router.get("/jobs/{job_id}")
def get_cadastre_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Детали одного cadastre harvest job."""
row = (
db.execute(
text("""
SELECT job_id, name, job_kind, scope, status, triggered_by,
rate_ms, created_at, started_at, finished_at, heartbeat_at,
targets_total, targets_done, targets_failed, targets_skipped,
requests_count, waf_blocked_count,
error, phase_state, raw_targets
FROM cadastre_jobs
WHERE job_id = :id
"""),
{"id": job_id},
)
.mappings()
.first()
)
if not row:
raise HTTPException(status_code=404, detail=f"cadastre_job {job_id} не найден")
result = _serialize_job(row)
# Добавить сводку по raw_targets (количество + первые 10 для UI)
raw_targets = row["raw_targets"] or {}
quarters = raw_targets.get("quarters", [])
result["raw_targets_count"] = len(quarters)
result["raw_targets_preview"] = quarters[:10]
return result
@router.post("/jobs/{job_id}/cancel")
def cancel_cadastre_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Отменить job. Воркеры увидят status='cancelled' при следующей итерации и skip."""
result = db.execute(
text("""
UPDATE cadastre_jobs
SET status = 'cancelled',
finished_at = COALESCE(finished_at, NOW()),
error = COALESCE(error, 'cancelled by admin')
WHERE job_id = :id
AND status IN ('queued', 'running', 'paused')
RETURNING job_id
"""),
{"id": job_id},
).first()
db.commit()
if not result:
raise HTTPException(
status_code=404,
detail=f"cadastre_job {job_id} не найден или уже завершён/отменён",
)
logger.info("cancel_cadastre_job: job_id=%d cancelled", job_id)
return {"job_id": job_id, "cancelled": True}
@router.post("/jobs/{job_id}/resume")
def resume_cadastre_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Возобновить paused/failed job. Re-enqueue enqueue_cadastre_harvest."""
from app.workers.tasks.scrape_cadastre import enqueue_cadastre_harvest
result = db.execute(
text("""
UPDATE cadastre_jobs
SET status = 'queued', error = NULL
WHERE job_id = :id
AND status IN ('paused', 'failed')
RETURNING job_id
"""),
{"id": job_id},
).first()
db.commit()
if not result:
raise HTTPException(
status_code=404,
detail=f"cadastre_job {job_id} не найден или не в состоянии paused/failed",
)
enqueue_cadastre_harvest.apply_async(args=[job_id], queue="geo")
logger.info("resume_cadastre_job: job_id=%d re-enqueued", job_id)
return {"job_id": job_id, "resumed": True}