feat: complete moderation history dashboard
This commit is contained in:
@@ -258,7 +258,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации.
|
В карточке внешнего наблюдения 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
|
## Эксплуатация production
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,29 @@ def admin_moderation_history(
|
|||||||
return history[offset:offset + limit]
|
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])
|
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||||
def admin_imports(
|
def admin_imports(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|||||||
@@ -2636,6 +2636,63 @@
|
|||||||
"summary": "Admin Moderation History"
|
"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": {
|
"/api/v1/baits": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "baits_api_v1_baits_get",
|
"operationId": "baits_api_v1_baits_get",
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
|
|||||||
assert history.status_code == 200
|
assert history.status_code == 200
|
||||||
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
|
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
|
||||||
assert forbidden not in history.text.lower()
|
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:
|
def test_spot_detail_and_catches() -> None:
|
||||||
|
|||||||
@@ -30,14 +30,24 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
if (!content) return;
|
if (!content) return;
|
||||||
error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||||
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
|
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 reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
|
||||||
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
||||||
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||||
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join("");
|
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join("");
|
||||||
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section></div>`;
|
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
|
||||||
|
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"};
|
||||||
|
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
|
||||||
|
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
||||||
}
|
}
|
||||||
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 : "Ошибка загрузки."); } });
|
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("Вы вышли из административной панели."));
|
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||||
|
content?.addEventListener("click", async event => {
|
||||||
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[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; }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -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. */
|
/* 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)}
|
.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}
|
||||||
|
|||||||
+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] **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 журнал объединяет решения по пользовательским и внешним записям без ников, 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`, если запись уже изменена другим модератором.
|
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
|
||||||
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user