feat: prioritize risky moderation records

This commit is contained in:
ik
2026-09-12 21:45:29 +07:00
parent ba4c42f8f6
commit 0f078ab482
6 changed files with 25 additions and 8 deletions
+1 -1
View File
@@ -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 HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Во время решения вся карточка блокируется; при ошибке введённая причина остаётся на месте, а после успеха интерфейс сообщает результат и переводит фокус к следующей записи. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести либо риску до применения пагинации; риск поднимает неполные и несопоставленные записи. Во время решения вся карточка блокируется; при ошибке введённая причина остаётся на месте, а после успеха интерфейс сообщает результат и переводит фокус к следующей записи. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
## Эксплуатация production ## Эксплуатация production
+17 -4
View File
@@ -15,7 +15,7 @@ import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import func, or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -180,7 +180,7 @@ def admin_external_observations(
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None, status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
source_system: str | None = None, source_system: str | None = None,
completeness: Literal["all", "complete", "incomplete"] = "all", completeness: Literal["all", "complete", "incomplete"] = "all",
order: Literal["newest", "oldest"] = "newest", order: Literal["newest", "oldest", "risk"] = "newest",
q: str | None = Query(None, max_length=100), q: str | None = Query(None, max_length=100),
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
) -> list[ExternalObservationOut]: ) -> list[ExternalObservationOut]:
@@ -209,8 +209,21 @@ def admin_external_observations(
ExternalObservation.fish_name.icontains(term, autoescape=True), ExternalObservation.fish_name.icontains(term, autoescape=True),
ExternalObservation.waterbody_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() if order == "risk":
items = db.scalars(query.order_by(direction, ExternalObservation.id.desc()).offset(offset).limit(limit)) incomplete = case(
(or_(ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None)), 0),
else_=1,
)
workflow = case(
(ExternalObservation.status == "staged", 0),
(ExternalObservation.status == "mapped", 1),
else_=2,
)
ordering = (incomplete, workflow, ExternalObservation.last_seen_at.asc(), ExternalObservation.id.desc())
else:
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
ordering = (direction, ExternalObservation.id.desc())
items = db.scalars(query.order_by(*ordering).offset(offset).limit(limit))
return [_external_out(item) for item in items] return [_external_out(item) for item in items]
+2 -1
View File
@@ -2002,7 +2002,8 @@
"default": "newest", "default": "newest",
"enum": [ "enum": [
"newest", "newest",
"oldest" "oldest",
"risk"
], ],
"title": "Order", "title": "Order",
"type": "string" "type": "string"
+3
View File
@@ -344,6 +344,9 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
assert any(item["id"] == str(observation_id) for item in incomplete.json()) 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) 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["id"] != str(observation_id) for item in complete.json())
prioritized = client.get("/api/v1/admin/external-observations?status=review&order=risk", headers=headers)
assert prioritized.status_code == 200
assert prioritized.json()[0]["weight_g"] is None
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"]) assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
mapped = client.patch( mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
@@ -12,7 +12,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label> <label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label>
<label>Источник<select name="source"><option value="">Все источники</option></select></label> <label>Источник<select name="source"><option value="">Все источники</option></select></label>
<label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label> <label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label>
<label>Порядок<select name="order"><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label> <label>Порядок<select name="order"><option value="risk">Сначала требуют внимания</option><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
<button data-action="primary" type="submit">Применить</button> <button data-action="primary" type="submit">Применить</button>
</form> </form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
+1 -1
View File
@@ -64,7 +64,7 @@
- [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage. - [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage.
- [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений. - [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
- [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Далее: приоритет риска и доступные горячие клавиши. - [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Остались доступные горячие клавиши.
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных. - [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных.
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.