feat: add bulk moderation decisions

This commit is contained in:
ik
2026-09-21 20:25:17 +07:00
parent 2fc4b88a80
commit e74929e7f7
3 changed files with 53 additions and 2 deletions
+22 -1
View File
@@ -20,7 +20,7 @@ from rf4_research.media_assets import publish_quality_upgrades, rollback_quality
from ..media_catalog import MEDIA_ROOT, review_assets, review_file from ..media_catalog import MEDIA_ROOT, review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate
from ..storage import delete_screenshot, signed_screenshot_url from ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware from ..time_utils import aware
@@ -400,6 +400,27 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
@router.post("/api/v1/admin/catch-reports/bulk", response_model=ModerationBulkResult)
def moderate_reports_bulk(payload: ModerationBulkUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> ModerationBulkResult:
if payload.status == "rejected" and not (payload.reason or "").strip():
raise HTTPException(status_code=422, detail="rejection reason required")
ids = [item.id for item in payload.items]
reports = list(db.scalars(select(CatchReport).where(CatchReport.id.in_(ids)).with_for_update()))
by_id = {report.id: report for report in reports}
if len(by_id) != len(ids) or any(report.source_type != SourceType.user or report.deleted_at is not None or report.moderation_status != ModerationStatus.pending for report in reports):
raise HTTPException(status_code=409, detail="one or more reports are no longer pending")
expected = {item.id: item.expected_version for item in payload.items}
if any(report.moderation_version != expected[report.id] for report in reports):
raise HTTPException(status_code=409, detail="one or more reports changed; reload the queue")
for report in reports:
previous = report.moderation_status
report.moderation_status = ModerationStatus(payload.status)
report.moderation_version += 1
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
db.commit(); public_cache.invalidate()
return ModerationBulkResult(updated=len(reports))
@router.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response) @router.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response: def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response:
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update()) report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
+22
View File
@@ -326,6 +326,28 @@ class ModerationUpdate(BaseModel):
return value return value
class ModerationBulkItem(BaseModel):
id: UUID
expected_version: int = Field(ge=0)
class ModerationBulkUpdate(BaseModel):
status: str
reason: str | None = Field(default=None, max_length=1000)
items: list[ModerationBulkItem] = Field(min_length=1, max_length=50)
@field_validator("status")
@classmethod
def known_bulk_status(cls, value: str) -> str:
if value not in {"approved", "rejected", "pending"}:
raise ValueError("unknown moderation status")
return value
class ModerationBulkResult(BaseModel):
updated: int
class ExternalObservationOut(BaseModel): class ExternalObservationOut(BaseModel):
id: UUID id: UUID
source_system: str source_system: str
+9 -1
View File
@@ -10,7 +10,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p> <p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-bulk" data-bulk hidden><strong data-bulk-count>0 выбрано</strong><label>Причина отклонения<textarea data-bulk-reason rows="1" maxlength="1000"></textarea></label><button data-action="primary" type="button" data-bulk-approve>Одобрить выбранные</button><button data-action="danger" type="button" data-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav> <nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p> <p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
</section> </section>
@@ -28,6 +28,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]"); const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
const bulk = document.querySelector<HTMLElement>("[data-bulk]");
let token = ""; let token = "";
let offset = 0; let offset = 0;
let sessionTimer: ReturnType<typeof setTimeout> | undefined; let sessionTimer: ReturnType<typeof setTimeout> | undefined;
@@ -39,6 +40,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } }; const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const syncBulk = () => { const cards = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]:checked") ?? [])]; if (bulk) bulk.hidden = cards.length === 0; const count = bulk?.querySelector<HTMLElement>("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; };
const hydrateBulk = () => { list?.querySelectorAll<HTMLElement>("[data-report-id]").forEach(card => { if (card.querySelector("[data-bulk-select]")) return; const label = document.createElement("label"); label.className = "moderation-select"; label.innerHTML = `<input type="checkbox" data-bulk-select aria-label="Выбрать улов ${card.querySelector("h2")?.textContent ?? ""}">`; card.prepend(label); }); syncBulk(); };
window.setInterval(hydrateBulk, 1000);
async function runBulk(statusValue: "approved" | "rejected") { const selected = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]:checked") ?? [])].map(input => input.closest<HTMLElement>("[data-report-id]")); const reason = bulk?.querySelector<HTMLTextAreaElement>("[data-bulk-reason]")?.value.trim() || null; if (!selected.length) return; if (statusValue === "rejected" && !reason) { fail("Укажите причину отклонения."); return; } const buttons = bulk?.querySelectorAll<HTMLButtonElement>("button") ?? []; buttons.forEach(button => button.disabled = true); try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/catch-reports/bulk`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:statusValue,reason,items:selected.map(card => ({id:card?.dataset.reportId,expected_version:Number(card?.dataset.version)}))})}); if (response.status === 409) { await loadQueue(); throw new Error("Часть выбранных записей уже изменена. Очередь обновлена."); } if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось применить пакетное решение.")); keepSession(); await loadQueue(); succeed(`Обработано записей: ${selected.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного решения."); } finally { buttons.forEach(button => button.disabled = false); syncBulk(); } }
bulk?.querySelector<HTMLButtonElement>("[data-bulk-approve]")?.addEventListener("click", () => runBulk("approved"));
bulk?.querySelector<HTMLButtonElement>("[data-bulk-reject]")?.addEventListener("click", () => runBulk("rejected"));
list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-bulk-select]")) syncBulk(); });
async function loadQueue() { async function loadQueue() {
if (!root || !list) return; if (!root || !list) return;
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();