From c6ffded8d75ffebf8241391157eca34b524b52de Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 09:07:32 +0700 Subject: [PATCH] feat: complete moderation history dashboard --- README.md | 2 +- apps/api/app/main.py | 23 +++++++++++ apps/api/openapi.json | 57 ++++++++++++++++++++++++++++ apps/api/tests/test_api.py | 6 +++ apps/web/src/pages/admin/index.astro | 14 ++++++- apps/web/src/styles/global.css | 3 ++ docs/ROADMAP.md | 2 +- 7 files changed, 103 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ed72ae9..9ed4c2c 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ curl -H "Authorization: Bearer change-me-in-production" \ В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации. -`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload. +`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям; последние события видны на dashboard. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload. Dashboard выгружает отдельный `moderation-history-export`: в нём дополнительно исключены UUID, оператор и свободный текст причины, остаются только время, тип, действие и признак необходимости подтверждения. ## Эксплуатация production diff --git a/apps/api/app/main.py b/apps/api/app/main.py index f4c280b..7706da2 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -162,6 +162,29 @@ def admin_moderation_history( return history[offset:offset + limit] +@app.get("/api/v1/admin/moderation-history-export") +def admin_moderation_history_export( + db: Db, + _: Annotated[str, Depends(_admin)], + limit: int = Query(1000, ge=1, le=5000), +) -> JSONResponse: + """Return an anonymized, analysis-safe decision export.""" + events = admin_moderation_history(db, _, limit=limit, offset=0) + payload = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "count": len(events), + "events": [{ + "entity_type": event.entity_type, + "decided_at": event.decided_at.isoformat(), + "action": event.action, + "requires_confirmation": event.requires_confirmation, + } for event in events], + } + return JSONResponse(payload, headers={ + "Content-Disposition": "attachment; filename=rf4spotter-moderation-history.json", + }) + + @app.get("/api/v1/admin/imports", response_model=list[ImportRunOut]) def admin_imports( db: Db, diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5106a2b..fc465e8 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2636,6 +2636,63 @@ "summary": "Admin Moderation History" } }, + "/api/v1/admin/moderation-history-export": { + "get": { + "description": "Return an anonymized, analysis-safe decision export.", + "operationId": "admin_moderation_history_export_api_v1_admin_moderation_history_export_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 1000, + "maximum": 5000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Moderation History Export" + } + }, "/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 4ee1d4a..95b2af7 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -163,6 +163,12 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None: assert history.status_code == 200 for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"): assert forbidden not in history.text.lower() + export = client.get("/api/v1/admin/moderation-history-export", headers={"Authorization": "Bearer change-me-in-production"}) + assert export.status_code == 200 + assert export.headers["Content-Disposition"] == "attachment; filename=rf4spotter-moderation-history.json" + assert set(export.json()) == {"generated_at", "count", "events"} + for forbidden in ("entity_id", "moderator", "reason", "player_name", "source_url", "raw_payload"): + assert forbidden not in export.text.lower() def test_spot_detail_and_catches() -> None: diff --git a/apps/web/src/pages/admin/index.astro b/apps/web/src/pages/admin/index.astro index 687efc0..12a5a91 100644 --- a/apps/web/src/pages/admin/index.astro +++ b/apps/web/src/pages/admin/index.astro @@ -30,14 +30,24 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; if (!content) return; error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = ''; const diagnostics = await authorizedJson("/api/v1/admin/diagnostics"); - const [imports, sources] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status")]); + const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]); const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {}; const sourceLabels: Record = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"}; const sourceRows = (sources as Record[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `
  • ${esc(source.name)}${sourceLabels[safeState]}
  • `; }).join(""); const importRows = (imports as Record[]).map(run => `
  • ${esc(run.status)}
  • `).join(""); - content.removeAttribute("aria-busy"); content.innerHTML = `
    Уловы на проверке${esc(reports.pending ?? 0)}Открыть очередь →Наблюдения в staging${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}Проверить источники →
    Одобрено уловов${esc(reports.approved ?? 0)}Участвуют в статистике
    Источников включено${esc(diagnostics.counts?.enabled_data_sources ?? 0)}из ${esc(diagnostics.counts?.data_sources ?? 0)}

    Состояние источников

      ${sourceRows || "
    • Нет данных
    • "}
    Публичная страница состояния →

    Последние импорты

      ${importRows || "
    • Запусков пока нет
    • "}
    `; + const actionLabels: Record = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"}; + const typeLabels: Record = {catch_report:"Улов",external_observation:"Внешнее наблюдение"}; + const historyRows = (history as Record[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `
  • ${esc(typeLabels[type] ?? "Запись")} · ${esc(actionLabels[action] ?? action)}${event.reason ? `${esc(event.reason)}` : ""}
  • `; }).join(""); + content.removeAttribute("aria-busy"); content.innerHTML = `
    Уловы на проверке${esc(reports.pending ?? 0)}Открыть очередь →Наблюдения в staging${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}Проверить источники →
    Одобрено уловов${esc(reports.approved ?? 0)}Участвуют в статистике
    Источников включено${esc(diagnostics.counts?.enabled_data_sources ?? 0)}из ${esc(diagnostics.counts?.data_sources ?? 0)}

    Состояние источников

      ${sourceRows || "
    • Нет данных
    • "}
    Публичная страница состояния →

    Последние импорты

      ${importRows || "
    • Запусков пока нет
    • "}

    Последние решения

      ${historyRows || "
    • Решений пока нет
    • "}

    Экспорт обезличен: без UUID, модератора, причин и исходных данных.

    `; } login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); + content?.addEventListener("click", async event => { + const button = (event.target as HTMLElement).closest("[data-history-export]"); if (!button) return; + button.disabled = true; + try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) throw new Error(); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } + catch { fail("Не удалось выгрузить журнал решений."); } + finally { button.disabled = false; } + }); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 5058db1..c060384 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -63,3 +63,6 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32 /* Shared motion signature: short response for controls, calm lift for navigable cards. */ .spot-card,.catalog-grid>a{transition:transform var(--motion-base) var(--ease-out),border-color var(--motion-base) var(--ease-out),box-shadow var(--motion-base) var(--ease-out),background-color var(--motion-base) var(--ease-out)}.source-chip[href],.detail-head>a{transition:transform var(--motion-fast) var(--ease-out),box-shadow var(--motion-fast) var(--ease-out),background-color var(--motion-fast) var(--ease-out),color var(--motion-fast) var(--ease-out)} + +/* Moderation history spans the dashboard and keeps long reasons scannable. */ +.admin-history{grid-column:1/-1}.admin-history li span{display:block}.admin-history li small{display:block;margin-top:4px;color:var(--text-muted);font-weight:400}.admin-section-head{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-section-head h2{margin:0}.admin-section-head button{width:auto} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2fe472..fa83681 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 журнал объединяет решения по пользовательским и внешним записям без ников, URL и исходных payload. Далее: показать историю в dashboard и добавить обезличенный экспорт. +- [x] **M04 · Полный provenance и история решений.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; карточка показывает их перед публикацией. Единый read-only журнал объединяет решения по пользовательским и внешним записям без ников, URL и исходных payload и отображается на dashboard. Отдельный JSON-экспорт исключает также UUID сущностей, оператора и свободный текст причины; токен остаётся только в памяти вкладки. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.