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";
Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.
Административная сессия активна
+