feat: add bulk moderation decisions
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<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="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>
|
||||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||||
</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 sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
const bulk = document.querySelector<HTMLElement>("[data-bulk]");
|
||||
let token = "";
|
||||
let offset = 0;
|
||||
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 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<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() {
|
||||
if (!root || !list) return;
|
||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||
|
||||
Reference in New Issue
Block a user