feat: add moderation decision history
This commit is contained in:
@@ -254,6 +254,8 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
||||
|
||||
В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации.
|
||||
|
||||
`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload.
|
||||
|
||||
## Эксплуатация production
|
||||
|
||||
- первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md);
|
||||
|
||||
+30
-1
@@ -31,8 +31,9 @@ from .routers.activity import router as activity_router
|
||||
from .routers.catalog import router as catalog_router
|
||||
from .routers.public_data import router as public_data_router
|
||||
from .routers.submissions import router as submissions_router
|
||||
from .time_utils import aware
|
||||
from .public_cache import public_cache
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, AdminModerationHistoryOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
from .submission_security import check_rate_limit
|
||||
from .submission_security import is_trusted_proxy as _is_trusted_proxy
|
||||
@@ -133,6 +134,34 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
|
||||
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/moderation-history", response_model=list[AdminModerationHistoryOut])
|
||||
def admin_moderation_history(
|
||||
db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[AdminModerationHistoryOut]:
|
||||
report_events = list(db.scalars(
|
||||
select(ModerationEvent).order_by(ModerationEvent.created_at.desc()).limit(limit + offset)
|
||||
))
|
||||
external_events = list(db.scalars(
|
||||
select(ExternalObservation).where(ExternalObservation.reviewed_at.is_not(None))
|
||||
.order_by(ExternalObservation.reviewed_at.desc()).limit(limit + offset)
|
||||
))
|
||||
history = [AdminModerationHistoryOut(
|
||||
entity_type="catch_report", entity_id=event.catch_report_id,
|
||||
decided_at=event.created_at, action=event.new_status.value,
|
||||
moderator=event.moderator, reason=event.reason,
|
||||
) for event in report_events]
|
||||
history.extend(AdminModerationHistoryOut(
|
||||
entity_type="external_observation", entity_id=observation.id,
|
||||
decided_at=observation.reviewed_at, action=observation.status,
|
||||
moderator=None, reason=observation.review_note,
|
||||
) for observation in external_events if observation.reviewed_at is not None)
|
||||
history.sort(key=lambda event: aware(event.decided_at), reverse=True)
|
||||
return history[offset:offset + limit]
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||
def admin_imports(
|
||||
db: Db,
|
||||
|
||||
@@ -247,6 +247,15 @@ class ExternalObservationMapping(BaseModel):
|
||||
class ExternalAliasSuggestionOut(BaseModel):
|
||||
fish_slug: str | None
|
||||
waterbody_slug: str | None
|
||||
|
||||
|
||||
class AdminModerationHistoryOut(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: UUID
|
||||
decided_at: datetime
|
||||
action: str
|
||||
moderator: str | None
|
||||
reason: str | None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
|
||||
+132
-5
@@ -199,6 +199,65 @@
|
||||
"title": "AdminCatchReportOut",
|
||||
"type": "object"
|
||||
},
|
||||
"AdminModerationHistoryOut": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"title": "Action",
|
||||
"type": "string"
|
||||
},
|
||||
"decided_at": {
|
||||
"format": "date-time",
|
||||
"title": "Decided At",
|
||||
"type": "string"
|
||||
},
|
||||
"entity_id": {
|
||||
"format": "uuid",
|
||||
"title": "Entity Id",
|
||||
"type": "string"
|
||||
},
|
||||
"entity_type": {
|
||||
"title": "Entity Type",
|
||||
"type": "string"
|
||||
},
|
||||
"moderator": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Moderator"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reason"
|
||||
},
|
||||
"requires_confirmation": {
|
||||
"default": true,
|
||||
"title": "Requires Confirmation",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"decided_at",
|
||||
"action",
|
||||
"moderator",
|
||||
"reason"
|
||||
],
|
||||
"title": "AdminModerationHistoryOut",
|
||||
"type": "object"
|
||||
},
|
||||
"BaitOut": {
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -565,11 +624,6 @@
|
||||
],
|
||||
"title": "Fish Slug"
|
||||
},
|
||||
"requires_confirmation": {
|
||||
"default": true,
|
||||
"title": "Requires Confirmation",
|
||||
"type": "boolean"
|
||||
},
|
||||
"waterbody_slug": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2509,6 +2563,79 @@
|
||||
"summary": "Admin Start Official Import"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/moderation-history": {
|
||||
"get": {
|
||||
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 50,
|
||||
"maximum": 200,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AdminModerationHistoryOut"
|
||||
},
|
||||
"title": "Response Admin Moderation History Api V1 Admin Moderation History Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Moderation History"
|
||||
}
|
||||
},
|
||||
"/api/v1/baits": {
|
||||
"get": {
|
||||
"operationId": "baits_api_v1_baits_get",
|
||||
|
||||
@@ -158,6 +158,11 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
|
||||
serialized = response.text.lower()
|
||||
for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"):
|
||||
assert forbidden not in serialized
|
||||
assert client.get("/api/v1/admin/moderation-history").status_code == 401
|
||||
history = client.get("/api/v1/admin/moderation-history", headers={"Authorization": "Bearer change-me-in-production"})
|
||||
assert history.status_code == 200
|
||||
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
|
||||
assert forbidden not in history.text.lower()
|
||||
|
||||
|
||||
def test_spot_detail_and_catches() -> None:
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@
|
||||
- [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] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках.
|
||||
- [ ] **M04 · Полный provenance и история решений — в работе.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; неизвестные ключи, вложенные структуры и служебные данные не раскрываются. Карточка показывает эти сведения в раскрываемом блоке перед публикацией и напоминает сверить их с первоисточником. Далее: read-only журнал решений и обезличенный экспорт.
|
||||
- [ ] **M04 · Полный provenance и история решений — в работе.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; карточка показывает их перед публикацией. Единый read-only журнал объединяет решения по пользовательским и внешним записям без ников, URL и исходных payload. Далее: показать историю в dashboard и добавить обезличенный экспорт.
|
||||
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
|
||||
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user