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:
parent
56bf28758c
commit
0135f3c7c4
2 changed files with 122 additions and 0 deletions
|
|
@ -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"}
|
||||||
|
|
|
||||||
82
backend/tests/api/v1/test_admin_ekburg_permits.py
Normal file
82
backend/tests/api/v1/test_admin_ekburg_permits.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue