From 8e419c1832ec7dc37902189ee4d96dec63b6afca Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 21:37:10 +0700 Subject: [PATCH] feat: filter external moderation queue --- README.md | 2 +- apps/api/app/main.py | 27 +++++++++-- apps/api/openapi.json | 46 +++++++++++++++++++ apps/api/tests/test_api.py | 6 ++- .../src/pages/admin/external-sources.astro | 18 ++++++-- apps/web/src/styles/global.css | 2 + docs/ROADMAP.md | 2 +- 7 files changed, 93 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8bc7beb..2e6c2b9 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ curl -H "Authorization: Bearer change-me-in-production" \ В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью. -Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. +Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. ## Эксплуатация production diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 557cf7b..7c1c7d6 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -15,7 +15,7 @@ import httpx from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload @@ -178,7 +178,11 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut: def admin_external_observations( db: Db, _: Annotated[str, Depends(_admin)], 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), + source_system: str | None = None, + completeness: Literal["all", "complete", "incomplete"] = "all", + order: Literal["newest", "oldest"] = "newest", + q: str | None = Query(None, max_length=100), + 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), @@ -189,7 +193,24 @@ def admin_external_observations( query = query.where(ExternalObservation.status == status) if source_system: query = query.where(ExternalObservation.source_system == source_system) - items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit)) + if completeness == "complete": + query = query.where( + ExternalObservation.x.is_not(None), ExternalObservation.y.is_not(None), + ExternalObservation.weight_g.is_not(None), + ) + elif completeness == "incomplete": + query = query.where(or_( + ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), + ExternalObservation.weight_g.is_(None), + )) + if q and q.strip(): + term = q.strip() + query = query.where(or_( + ExternalObservation.fish_name.icontains(term, autoescape=True), + ExternalObservation.waterbody_name.icontains(term, autoescape=True), + )) + direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc() + items = db.scalars(query.order_by(direction, ExternalObservation.id.desc()).offset(offset).limit(limit)) return [_external_out(item) for item in items] diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 9a9800c..5453958 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1979,6 +1979,52 @@ "title": "Source System" } }, + { + "in": "query", + "name": "completeness", + "required": false, + "schema": { + "default": "all", + "enum": [ + "all", + "complete", + "incomplete" + ], + "title": "Completeness", + "type": "string" + } + }, + { + "in": "query", + "name": "order", + "required": false, + "schema": { + "default": "newest", + "enum": [ + "newest", + "oldest" + ], + "title": "Order", + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } + }, { "in": "query", "name": "limit", diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 0a9f482..1480dc0 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -339,8 +339,12 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() assert signal["source_system"] == "rf4db" assert signal["quality"] == "incomplete" assert signal["missing_fields"] == ["вес"] - assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"]) headers = {"Authorization": "Bearer change-me-in-production"} + incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers) + assert any(item["id"] == str(observation_id) for item in incomplete.json()) + complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers) + assert all(item["id"] != str(observation_id) for item in complete.json()) + assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"]) mapped = client.patch( f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, json={"fish_slug": "pike", "waterbody_slug": "test-lake"}, diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 9d5e1ba..5e1c797 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -8,6 +8,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

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

+