diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index 9a4b7de..2461412 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -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 ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody 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 ..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) +@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) 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()) diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 244a1b0..47232f2 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -326,6 +326,28 @@ class ModerationUpdate(BaseModel): 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): id: UUID source_system: str diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index 78064b3..141bc71 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -10,7 +10,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

Токен хранится только в памяти страницы и не записывается в URL или localStorage.

-
+

A одобрить карточку с фокусом · отклонение и удаление — только кнопками

@@ -28,6 +28,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const pageNumber = document.querySelector("[data-page-number]"); const sessionBar = document.querySelector(".admin-session-bar"); const logout = document.querySelector("[data-admin-logout]"); + const bulk = document.querySelector("[data-bulk]"); let token = ""; let offset = 0; let sessionTimer: ReturnType | 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 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 syncBulk = () => { const cards = [...(list?.querySelectorAll("[data-bulk-select]:checked") ?? [])]; if (bulk) bulk.hidden = cards.length === 0; const count = bulk?.querySelector("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; }; + const hydrateBulk = () => { list?.querySelectorAll("[data-report-id]").forEach(card => { if (card.querySelector("[data-bulk-select]")) return; const label = document.createElement("label"); label.className = "moderation-select"; label.innerHTML = ``; card.prepend(label); }); syncBulk(); }; + window.setInterval(hydrateBulk, 1000); + async function runBulk(statusValue: "approved" | "rejected") { const selected = [...(list?.querySelectorAll("[data-bulk-select]:checked") ?? [])].map(input => input.closest("[data-report-id]")); const reason = bulk?.querySelector("[data-bulk-reason]")?.value.trim() || null; if (!selected.length) return; if (statusValue === "rejected" && !reason) { fail("Укажите причину отклонения."); return; } const buttons = bulk?.querySelectorAll("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("[data-bulk-approve]")?.addEventListener("click", () => runBulk("approved")); + bulk?.querySelector("[data-bulk-reject]")?.addEventListener("click", () => runBulk("rejected")); + list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-bulk-select]")) syncBulk(); }); async function loadQueue() { if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();