From 85d81aa9965da0d23d213e482f93afaa05b7f2f2 Mon Sep 17 00:00:00 2001 From: IK Date: Wed, 16 Sep 2026 20:28:45 +0700 Subject: [PATCH] feat: add admin media upgrade decisions --- apps/api/app/media_catalog.py | 2 ++ apps/api/app/routers/admin.py | 32 ++++++++++++++++++++++++++-- apps/api/app/schemas.py | 10 +++++++++ apps/api/tests/test_api.py | 29 +++++++++++++++++++++++++ apps/web/src/pages/admin/media.astro | 6 ++++-- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index 5652e5a..79cd037 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -81,9 +81,11 @@ def review_assets(entity_type: str | None = None, status: str | None = None) -> "height": item.get("height"), "content_type": item.get("content_type"), "image_url": f"/api/v1/admin/media/assets/{digest}", + "asset_url": item.get("asset_url", ""), "source_system": source, "source_url": source_page, "duplicate_of": item.get("duplicate_of"), + "supersedes": item.get("supersedes"), "derivatives": [{ "role": variant.get("role"), "format": variant.get("format"), "width": variant.get("width"), "height": variant.get("height"), diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index 2905dd1..9a4b7de 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -15,10 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs from ..config import settings from ..dependencies import Db from ..importer import ImportAlreadyRunning, ImportSourceError, import_records -from ..media_catalog import review_assets, review_file +from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade + +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, AdminMediaReviewOut, 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, ModerationUpdate from ..storage import delete_screenshot, signed_screenshot_url from ..time_utils import aware @@ -50,6 +52,32 @@ def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileRe return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"}) +@router.post("/api/v1/admin/media/upgrades/publish") +def admin_publish_media_upgrades( + payload: AdminMediaDecision, + _: Annotated[str, Depends(_admin)], +) -> dict[str, int]: + """Atomically publish all stored quality upgrades after an explicit decision.""" + try: + return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post("/api/v1/admin/media/upgrades/rollback") +def admin_rollback_media_upgrade( + payload: AdminMediaRollback, + _: Annotated[str, Depends(_admin)], +) -> dict[str, str]: + """Restore one superseded fallback while retaining the reviewed candidate.""" + try: + return rollback_quality_upgrade( + MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @router.get("/api/v1/admin/diagnostics") def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse: report_counts = {status.value: count for status, count in db.execute( diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index de92f78..615f018 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -318,7 +318,17 @@ class AdminMediaReviewOut(BaseModel): height: int | None content_type: str | None image_url: str + asset_url: str source_system: str source_url: str duplicate_of: str | None + supersedes: str | None derivatives: list[AdminMediaDerivativeOut] + + +class AdminMediaDecision(BaseModel): + note: str = Field(min_length=1, max_length=1000) + + +class AdminMediaRollback(AdminMediaDecision): + asset_url: str = Field(min_length=1, max_length=2000) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 6fe1998..935bb19 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -13,6 +13,7 @@ from app.community_importer import stage_observations from app.importer import ImportAlreadyRunning from app.main import app from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody +from app.routers import admin as admin_router engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) @@ -182,6 +183,34 @@ def test_admin_media_review_requires_auth() -> None: assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"]) +def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> None: + assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401 + assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401 + + monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2}) + publish = client.post( + "/api/v1/admin/media/upgrades/publish", + json={"note": "visual review complete"}, + headers={"Authorization": "Bearer change-me-in-production"}, + ) + assert publish.status_code == 200 + assert publish.json() == {"published": 2, "retained_fallbacks": 2} + + monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"}) + rollback = client.post( + "/api/v1/admin/media/upgrades/rollback", + json={"asset_url": "https://example.test/a", "note": "fallback is preferred"}, + headers={"Authorization": "Bearer change-me-in-production"}, + ) + assert rollback.status_code == 200 + assert rollback.json()["rolled_back"] == "https://example.test/a" + assert client.post( + "/api/v1/admin/media/upgrades/publish", + json={"note": ""}, + headers={"Authorization": "Bearer change-me-in-production"}, + ).status_code == 422 + + def test_liveness_does_not_probe_dependencies() -> None: response = client.get("/health?token=must-not-be-logged") assert response.json() == {"status": "ok"} diff --git a/apps/web/src/pages/admin/media.astro b/apps/web/src/pages/admin/media.astro index 6fd897c..2cec59b 100644 --- a/apps/web/src/pages/admin/media.astro +++ b/apps/web/src/pages/admin/media.astro @@ -8,7 +8,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
-

Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.

+

Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.

@@ -47,7 +47,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; if (!rows.length && offset > 0) { offset = 0; return load(); } const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`; if (!assets.length) { list.innerHTML = '

Кандидатов нет

Для выбранных фильтров нет approved или upgrade_queued файлов.

'; return; } - list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `
${image ? `${esc(asset.label)}` : ""}

${esc(asset.label)}

${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}

${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}

${variants ? `Производные: ${variants}` : "Производных нет"}${source ? `Первоисточник →` : ""}
`; }).join(""); + list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `` : ""; return `
${image ? `${esc(asset.label)}` : ""}

${esc(asset.label)}

${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}

${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}

${variants ? `Производные: ${variants}` : "Производных нет"}${source ? `Первоисточник →` : ""}${rollback}
`; }).join(""); + list.querySelectorAll("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } })); + if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); } } login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });