feat: prioritize risky moderation records
This commit is contained in:
@@ -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
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2002,7 +2002,8 @@
|
|||||||
"default": "newest",
|
"default": "newest",
|
||||||
"enum": [
|
"enum": [
|
||||||
"newest",
|
"newest",
|
||||||
"oldest"
|
"oldest",
|
||||||
|
"risk"
|
||||||
],
|
],
|
||||||
"title": "Order",
|
"title": "Order",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -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
@@ -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-сервис заранее.
|
||||||
|
|||||||
Reference in New Issue
Block a user