feat(#104): add POST /admin/scrape/ekburg-permits for manual trigger

Wraps refresh_all / refresh_year Celery tasks behind the existing
AdminTokenAuth gate so the table can be populated on demand after
first deploy instead of waiting for the monthly beat (1st of month).

- TriggerEkburgPermitsRequest: year int|None, ge=2022 le=2030
- year=None  -> refresh_all.apply_async()  (scope all_years_2022_2026)
- year=N     -> refresh_year.apply_async(args=[N])  (scope year_N)
- 4 smoke tests: all/year/invalid_year/no_token
This commit is contained in:
lekss361 2026-05-17 08:13:32 +03:00
parent 56bf28758c
commit 0135f3c7c4
2 changed files with 122 additions and 0 deletions

View file

@ -1209,3 +1209,43 @@ def list_runs(
} }
for r in rows for r in rows
] ]
# ── Ekburg construction permits (РНС/РВЭ ЕКБ) ────────────────────────────────
class TriggerEkburgPermitsRequest(BaseModel):
year: int | None = Field(
default=None,
ge=2022,
le=2030,
description=(
"Если None — refresh_all (все 5 лет 2022-2026). "
"Иначе refresh_year(year) для конкретного года."
),
)
@router.post("/ekburg-permits")
def trigger_ekburg_permits(
payload: TriggerEkburgPermitsRequest,
_: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации РНС/РВЭ ЕКБ из екатеринбург.рф (xlsx).
Обычно запускается раз в месяц через beat (1-го числа 02:00 UTC).
Этот endpoint для ad-hoc запуска (после первого деплоя или для smoke-теста).
- year=None refresh_all: скачивает 5 xlsx (2022-2026), ~3-8 мин.
- year=2026 refresh_year(2026): один файл, ~1 мин.
"""
from app.workers.tasks.ekburg_permits_sync import refresh_all, refresh_year
if payload.year is None:
result = refresh_all.apply_async()
scope = "all_years_2022_2026"
else:
result = refresh_year.apply_async(args=[payload.year])
scope = f"year_{payload.year}"
return {"task_id": result.id, "scope": scope, "queued_at": "now"}

View file

@ -0,0 +1,82 @@
"""Тесты для POST /admin/scrape/ekburg-permits.
Проверяет:
- валидный запрос без year scope all_years_2022_2026, task_id в ответе
- валидный запрос с year=2026 scope year_2026
- year < 2022 или > 2030 422
- отсутствие X-Admin-Token 401/503
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
ADMIN_TOKEN = "test-admin-token"
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
result = MagicMock()
result.id = task_id
return result
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_refresh_all_returns_task_id() -> None:
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
mock_result = _mock_task("task-all-001")
with (
patch("app.workers.tasks.ekburg_permits_sync.refresh_all") as mock_refresh_all,
patch("app.workers.tasks.ekburg_permits_sync.refresh_year"),
):
mock_refresh_all.apply_async.return_value = mock_result
client = TestClient(app)
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
assert response.status_code == 200, response.text
body = response.json()
assert body["task_id"] == "task-all-001"
assert body["scope"] == "all_years_2022_2026"
assert "queued_at" in body
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_refresh_year_returns_task_id() -> None:
"""POST year=2026 → refresh_year queued, scope=year_2026."""
mock_result = _mock_task("task-year-002")
with (
patch("app.workers.tasks.ekburg_permits_sync.refresh_year") as mock_refresh_year,
patch("app.workers.tasks.ekburg_permits_sync.refresh_all"),
):
mock_refresh_year.apply_async.return_value = mock_result
client = TestClient(app)
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
assert response.status_code == 200, response.text
body = response.json()
assert body["task_id"] == "task-year-002"
assert body["scope"] == "year_2026"
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
client = TestClient(app)
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
def test_trigger_no_token_returns_401_or_503() -> None:
"""Без X-Admin-Token → 401 или 503."""
client = TestClient(app)
response = client.post(ENDPOINT, json={})
assert response.status_code in (401, 503), response.text