feat: add moderation decision history

This commit is contained in:
ik
2026-09-12 21:52:25 +07:00
parent 9681bdbd36
commit fcc26a5b34
6 changed files with 179 additions and 7 deletions
+2
View File
@@ -254,6 +254,8 @@ curl -H "Authorization: Bearer change-me-in-production" \
В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации. В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации.
`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload.
## Эксплуатация production ## Эксплуатация production
- первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md); - первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md);
+30 -1
View File
@@ -31,8 +31,9 @@ from .routers.activity import router as activity_router
from .routers.catalog import router as catalog_router from .routers.catalog import router as catalog_router
from .routers.public_data import router as public_data_router from .routers.public_data import router as public_data_router
from .routers.submissions import router as submissions_router from .routers.submissions import router as submissions_router
from .time_utils import aware
from .public_cache import public_cache 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 .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 check_rate_limit
from .submission_security import is_trusted_proxy as _is_trusted_proxy 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"}) 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]) @app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
def admin_imports( def admin_imports(
db: Db, db: Db,
+9
View File
@@ -247,6 +247,15 @@ class ExternalObservationMapping(BaseModel):
class ExternalAliasSuggestionOut(BaseModel): class ExternalAliasSuggestionOut(BaseModel):
fish_slug: str | None fish_slug: str | None
waterbody_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 requires_confirmation: bool = True
+132 -5
View File
@@ -199,6 +199,65 @@
"title": "AdminCatchReportOut", "title": "AdminCatchReportOut",
"type": "object" "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": { "BaitOut": {
"properties": { "properties": {
"id": { "id": {
@@ -565,11 +624,6 @@
], ],
"title": "Fish Slug" "title": "Fish Slug"
}, },
"requires_confirmation": {
"default": true,
"title": "Requires Confirmation",
"type": "boolean"
},
"waterbody_slug": { "waterbody_slug": {
"anyOf": [ "anyOf": [
{ {
@@ -2509,6 +2563,79 @@
"summary": "Admin Start Official Import" "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": { "/api/v1/baits": {
"get": { "get": {
"operationId": "baits_api_v1_baits_get", "operationId": "baits_api_v1_baits_get",
+5
View File
@@ -158,6 +158,11 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
serialized = response.text.lower() serialized = response.text.lower()
for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"): for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"):
assert forbidden not in serialized 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: def test_spot_detail_and_catches() -> None:
+1 -1
View File
@@ -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] **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 и полные тексты исключений.
- [x] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках. - [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`, если запись уже изменена другим модератором. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.