From 1f63f71600d5bd3037a72afb7bace95e03f2dda6 Mon Sep 17 00:00:00 2001 From: IK Date: Tue, 8 Sep 2026 13:24:58 +0700 Subject: [PATCH] Fix review queue pagination and API fallback handling --- README.md | 4 +++ apps/api/app/main.py | 6 ++-- apps/api/tests/test_api.py | 26 +++++++++++++++ apps/web/src/lib/api.ts | 2 +- .../src/pages/admin/external-sources.astro | 32 +++++++++++++++++-- apps/web/src/pages/fish/[slug].astro | 5 +++ apps/web/src/pages/fish/index.astro | 5 +++ apps/web/src/pages/waterbodies/[slug].astro | 5 +++ .../src/pages/waterbodies/[slug]/[fish].astro | 5 +++ apps/web/src/pages/waterbodies/index.astro | 5 +++ docs/AUDIT_FIXES.md | 4 +++ 11 files changed, 93 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 419afda..690a154 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ RF4 Spotter — неофициальный сервис свежих точек ## Статус разработки +Очередь внешних наблюдений выбирает только ожидающие проверки записи на сервере и показывает их страницами по 50. Обработанные записи не скрывают более старые необработанные наблюдения. + +Запросы Astro к API ограничены таймаутом 8 секунд на запрос. При недоступности API каталоги рыб/водоёмов возвращают HTTP 503 с Retry-After, вместо успешного ответа со страницей ошибки. + В очереди внешних наблюдений доступна кнопка «Подсказать соответствия»: она показывает ранее подтверждённые рыбу и водоём. Значения формы не меняются автоматически; сопоставление и публикация подтверждаются отдельно. Повторный импорт изменённой опубликованной записи переводит её на ручную проверку и снимает прежний улов с активности (с учётом TTL кэша). После сопоставления и подтверждения обновляется тот же улов; дубликат не создаётся. Автоматическое обнаружение удалённых оригиналов пока не реализовано. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 31d4dfc..bf8c481 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -367,13 +367,15 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut: @app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut]) def admin_external_observations( db: Db, _: Annotated[str, Depends(_admin)], - status: Literal["staged", "mapped", "ready", "published", "rejected"] | None = None, + status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None, source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> list[ExternalObservationOut]: query = select(ExternalObservation).options( joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody), ) - if status: + if status == "review": + query = query.where(ExternalObservation.status.in_(["staged", "mapped", "ready"])) + elif status: query = query.where(ExternalObservation.status == status) if source_system: query = query.where(ExternalObservation.source_system == source_system) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 6bead1f..f8abfc4 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -57,6 +57,32 @@ def test_invalid_period_is_rejected() -> None: assert client.get("/api/v1/activity?sort=unknown").status_code == 422 +def test_review_queue_filters_before_pagination() -> None: + with Session(engine) as db: + stage_observations(db, [{ + "source_system": "rf4map", "source_external_id": f"queue-{i}", + "source_url": f"https://rf4map.ru/points/queue-{i}", + "fish": "Queue fish", "waterbody": "Queue water", + } for i in range(3)]) + rows = list(db.scalars(select(ExternalObservation).where(ExternalObservation.source_system == "rf4map"))) + for i, row in enumerate(rows): + row.status = "published" if i == 0 else "ready" + row.last_seen_at = datetime.now(timezone.utc) - timedelta(minutes=i) + db.commit() + try: + headers = {"Authorization": "Bearer change-me-in-production"} + url = "/api/v1/admin/external-observations?status=review&source_system=rf4map&limit=1" + first = client.get(url, headers=headers).json() + second = client.get(url + "&offset=1", headers=headers).json() + assert len(first) == len(second) == 1 + assert first[0]["status"] == second[0]["status"] == "ready" + assert first[0]["id"] != second[0]["id"] + finally: + for row in rows: + db.delete(row) + db.commit() + + def test_timeline_includes_more_than_catch_page_and_sitemap_includes_old_spots() -> None: with Session(engine) as db: fish = db.scalar(select(Fish).where(Fish.slug == "pike")) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6e6dbad..e9e3aa0 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -25,7 +25,7 @@ export class ApiError extends Error { } export async function api(path: string): Promise { - const response = await fetch(`${base}${path}`); + const response = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(8000) }); if (!response.ok) throw new ApiError(response.status); return response.json() as Promise; } diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 3bd2302..d6f0bdc 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -8,12 +8,22 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.

+