From fcc26a5b34a1ee330a1c23b56ff756986804e010 Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 21:52:25 +0700 Subject: [PATCH] feat: add moderation decision history --- README.md | 2 + apps/api/app/main.py | 31 ++++++++- apps/api/app/schemas.py | 9 +++ apps/api/openapi.json | 137 +++++++++++++++++++++++++++++++++++-- apps/api/tests/test_api.py | 5 ++ docs/ROADMAP.md | 2 +- 6 files changed, 179 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 65b484d..cc26a5f 100644 --- a/README.md +++ b/README.md @@ -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); diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 093c376..f4c280b 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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, diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 1d164a5..58f473f 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -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 diff --git a/apps/api/openapi.json b/apps/api/openapi.json index b312cca..5106a2b 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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", diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index e273bf2..4ee1d4a 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -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: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2bf1dc5..cbf1634 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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-сервис заранее.