diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index 55b1d14a..c0f4685e 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -1254,6 +1254,14 @@ def trigger_ekburg_permits( class TriggerKnCatalogObjectsRequest(BaseModel): region_code: int = Field(default=66, ge=1, le=99) max_objects: int | None = Field(default=None, ge=1, le=2000) + force: bool = Field( + default=False, + description=( + "True — игнорировать фильтр 'уже сегодня обновлён' и грузить ВСЕ объекты " + "последнего snapshot ('Загрузить все'). По умолчанию пропускает то, " + "что уже скраплено сегодня." + ), + ) @router.post("/kn-catalog-objects") @@ -1268,12 +1276,16 @@ def trigger_kn_catalog_objects( запуска (smoke-тест после деплоя или повторный pass для свежесозданных объектов до next beat fire). - - max_objects=None → берёт из job_settings (default 300 для beat). + - max_objects=None → дефолтный лимит таска (300). - max_objects=3 → smoke-тест. + - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. """ from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects - kwargs: dict[str, Any] = {"region_code": payload.region_code} + kwargs: dict[str, Any] = { + "region_code": payload.region_code, + "force": payload.force, + } if payload.max_objects is not None: kwargs["max_objects"] = payload.max_objects result = scrape_kn_catalog_objects.apply_async(kwargs=kwargs) @@ -1282,5 +1294,6 @@ def trigger_kn_catalog_objects( "task_id": result.id, "region_code": payload.region_code, "max_objects": payload.max_objects, + "force": payload.force, "queued_at": "now", } diff --git a/backend/app/workers/tasks/scrape_kn_catalog_objects.py b/backend/app/workers/tasks/scrape_kn_catalog_objects.py index c3b96d21..b956e85b 100644 --- a/backend/app/workers/tasks/scrape_kn_catalog_objects.py +++ b/backend/app/workers/tasks/scrape_kn_catalog_objects.py @@ -3,8 +3,11 @@ Дополняет ~25 NULL колонок в domrf_kn_objects из SSR-страниц каталога. kn-API эти поля не возвращает — они только на публичных страницах объектов. -Выбирает объекты где catalog_scraped_at IS NULL или устарело (>30 дней). -Ограничивает batch per run чтобы не нагружать сайт. +Selector logic: + - catalog_scraped_at IS NULL — "новый" объект, всегда грузим + - DATE(catalog_scraped_at) < CURRENT_DATE — сегодня ещё не обновлялся, грузим + - DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем + - force=True — игнорирует фильтр, загружает все объекты последнего snapshot Beat schedule: вторник 04:00 UTC (в beat_schedule.py). """ @@ -23,19 +26,24 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) -# Запрос для выбора устаревших / ненаполненных объектов. +# Запрос для выбора объектов на скрап. # Возвращает obj_id + snapshot_date одним запросом, чтобы избежать race condition: # если между двумя запросами kn-scraper запишет новый snapshot — UPDATE по старой # snapshot_date не затронет ни одной строки. MAX subquery ограничена тем же # region_cd чтобы не захватить snapshot другого региона. -_SELECT_STALE_SQL = text( +# +# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня +# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today). +# При :force = true фильтр снимается — грузим все объекты последнего snapshot. +_SELECT_TARGETS_SQL = text( """ SELECT obj_id, snapshot_date FROM domrf_kn_objects WHERE region_cd = :region_code AND ( - catalog_scraped_at IS NULL - OR catalog_scraped_at < NOW() - INTERVAL '30 days' + CAST(:force AS boolean) + OR catalog_scraped_at IS NULL + OR DATE(catalog_scraped_at) < CURRENT_DATE ) AND snapshot_date = ( SELECT MAX(snapshot_date) @@ -60,15 +68,16 @@ def scrape_kn_catalog_objects( self: Any, region_code: int = 66, max_objects: int | None = None, + force: bool = False, ) -> dict[str, Any]: """Periodic catalog-object scrape. - Picks objects from domrf_kn_objects where catalog data missing OR stale - (catalog_scraped_at < NOW() - INTERVAL '30 days'). Limits per run. - Args: region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл. max_objects: Максимум объектов за один run. Default 300. + force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит + все объекты последнего snapshot (admin "Загрузить все"). По умолчанию + False — пропускает то, что уже скраплено сегодня. Returns: dict с ключами: region_code, snapshot_date, obj_ids_count, @@ -92,8 +101,8 @@ def scrape_kn_catalog_objects( try: rows = ( db.execute( - _SELECT_STALE_SQL, - {"region_code": region_code, "max_objects": limit}, + _SELECT_TARGETS_SQL, + {"region_code": region_code, "max_objects": limit, "force": force}, ) .mappings() .all() @@ -106,12 +115,14 @@ def scrape_kn_catalog_objects( if not obj_ids: logger.info( - "scrape_kn_catalog_objects: no stale objects for region=%d, nothing to do", + "scrape_kn_catalog_objects: nothing to do for region=%d (force=%s)", region_code, + force, ) db.close() return { "region_code": region_code, + "force": force, "obj_ids_count": 0, "processed": 0, "succeeded": 0, @@ -125,11 +136,12 @@ def scrape_kn_catalog_objects( snapshot_date_val: date = rows[0]["snapshot_date"] logger.info( - "scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d (limit=%d)", + "scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s", region_code, snapshot_date_val, len(obj_ids), limit, + force, ) try: @@ -149,6 +161,7 @@ def scrape_kn_catalog_objects( result: dict[str, Any] = { "region_code": region_code, + "force": force, "snapshot_date": str(snapshot_date_val), "obj_ids_count": len(obj_ids), **stats, diff --git a/backend/tests/workers/test_scrape_kn_catalog_objects.py b/backend/tests/workers/test_scrape_kn_catalog_objects.py new file mode 100644 index 00000000..e31cb8be --- /dev/null +++ b/backend/tests/workers/test_scrape_kn_catalog_objects.py @@ -0,0 +1,112 @@ +"""Тесты для backend/app/workers/tasks/scrape_kn_catalog_objects.py. + +Покрывают skip-today фильтр и force flag (admin "Загрузить все"). +""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from unittest.mock import MagicMock, patch + + +def _make_mock_db(rows: list[dict[str, Any]]) -> tuple[MagicMock, list[dict[str, Any]]]: + """Mock DB session. Возвращает (db, captured_params).""" + captured: list[dict[str, Any]] = [] + + def execute_router(stmt: Any, params: Any = None) -> MagicMock: + if params is not None: + captured.append(params) + result = MagicMock() + result.mappings.return_value.all.return_value = rows + return result + + db = MagicMock() + db.execute = execute_router + return db, captured + + +def test_skip_today_filter_active_by_default() -> None: + """force=False (default) → :force = False передаётся в SQL → SQL пропустит + объекты с catalog_scraped_at = сегодня (логика в WHERE).""" + from app.workers.tasks import scrape_kn_catalog_objects as task_mod + + db, captured = _make_mock_db(rows=[]) + + with patch.object(task_mod, "SessionLocal", return_value=db): + result = task_mod.scrape_kn_catalog_objects.run(region_code=66) + + # SQL вызван 1 раз с force=False + assert len(captured) == 1 + assert captured[0]["force"] is False + assert captured[0]["region_code"] == 66 + # Пустой результат → ранний выход + assert result["obj_ids_count"] == 0 + assert result["force"] is False + + +def test_force_true_passes_through_to_sql() -> None: + """force=True → :force = True передаётся в SQL → SQL грузит все объекты.""" + from app.workers.tasks import scrape_kn_catalog_objects as task_mod + + db, captured = _make_mock_db(rows=[]) + + with patch.object(task_mod, "SessionLocal", return_value=db): + result = task_mod.scrape_kn_catalog_objects.run(region_code=66, force=True) + + assert len(captured) == 1 + assert captured[0]["force"] is True + assert result["force"] is True + + +def test_max_objects_passed_as_limit() -> None: + """max_objects явный — должен прилететь в SQL bind как :max_objects.""" + from app.workers.tasks import scrape_kn_catalog_objects as task_mod + + db, captured = _make_mock_db(rows=[]) + + with patch.object(task_mod, "SessionLocal", return_value=db): + task_mod.scrape_kn_catalog_objects.run(region_code=66, max_objects=10) + + assert captured[0]["max_objects"] == 10 + + +def test_processes_rows_when_returned() -> None: + """Если SELECT вернул строки — вызывается scrape_catalog_objects, snapshot_date + из первой строки.""" + from app.workers.tasks import scrape_kn_catalog_objects as task_mod + + rows = [ + {"obj_id": 65136, "snapshot_date": date(2026, 5, 17)}, + {"obj_id": 65137, "snapshot_date": date(2026, 5, 17)}, + ] + db, _ = _make_mock_db(rows=rows) + + mock_stats = {"processed": 2, "succeeded": 2, "failed": 0, "skipped": 0} + with ( + patch.object(task_mod, "SessionLocal", return_value=db), + patch( + "app.services.scrapers.domrf_catalog_object.scrape_catalog_objects", + return_value=mock_stats, + ) as mock_scrape, + ): + result = task_mod.scrape_kn_catalog_objects.run(region_code=66) + + mock_scrape.assert_called_once() + call_kwargs = mock_scrape.call_args[1] + assert call_kwargs["obj_ids"] == [65136, 65137] + assert call_kwargs["snapshot_date"] == date(2026, 5, 17) + assert call_kwargs["region_code"] == 66 + assert result["obj_ids_count"] == 2 + assert result["snapshot_date"] == "2026-05-17" + + +def test_sql_contains_skip_today_predicate() -> None: + """Smoke: финальный SQL содержит DATE(catalog_scraped_at) < CURRENT_DATE и + CAST(:force AS boolean) — иначе фильтр не работает.""" + from app.workers.tasks.scrape_kn_catalog_objects import _SELECT_TARGETS_SQL + + sql = str(_SELECT_TARGETS_SQL).upper() + assert "DATE(CATALOG_SCRAPED_AT) < CURRENT_DATE" in sql + assert "CAST(:FORCE AS BOOLEAN)" in sql + assert "CATALOG_SCRAPED_AT IS NULL" in sql