diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index f947b4f..dec8cc0 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -12,6 +12,11 @@ KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES} +def media_manifest_version() -> int: + manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + return max(1, int(manifest.get("version", 1))) + + def _public_role_allowed(entity_type: str | None, role: object) -> bool: if role is None: return True diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index 8f5a252..883f1d0 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -15,12 +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 rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade +from rf4_research.media_assets import preview_quality_upgrades, publish_quality_upgrades, rollback_quality_upgrade -from ..media_catalog import KNOWN_MEDIA_ROLES, MEDIA_ROLES_BY_ENTITY, MEDIA_ROOT, review_assets, review_file +from ..media_catalog import KNOWN_MEDIA_ROLES, MEDIA_ROLES_BY_ENTITY, MEDIA_ROOT, media_manifest_version, 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, ExternalObservationBulkAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate +from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaPreview, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate from ..storage import delete_screenshot, signed_screenshot_url from ..time_utils import aware @@ -35,6 +35,7 @@ def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header @router.get("/api/v1/admin/media/catalog", response_model=list[AdminMediaReviewOut]) def admin_media_catalog( _: Annotated[str, Depends(_admin)], + response: Response, entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"), status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"), media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"), @@ -46,6 +47,7 @@ def admin_media_catalog( raise HTTPException(status_code=422, detail="unknown media role") if media_role and (not entity_type or media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type, set())): raise HTTPException(status_code=422, detail="media role is incompatible with entity type") + response.headers["X-Media-Manifest-Version"] = str(media_manifest_version()) return review_assets(entity_type, status, media_role, q.strip() if q else None)[offset:offset + limit] @@ -58,6 +60,20 @@ 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/preview") +def admin_preview_media_upgrades( + payload: AdminMediaPreview, + _: Annotated[str, Depends(_admin)], +) -> dict: + try: + return preview_quality_upgrades( + MEDIA_ROOT / "manifest.json", asset_ids=payload.asset_ids, + expected_version=payload.expected_version, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @router.post("/api/v1/admin/media/upgrades/publish") def admin_publish_media_upgrades( payload: AdminMediaDecision, @@ -65,7 +81,10 @@ def admin_publish_media_upgrades( ) -> 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) + return publish_quality_upgrades( + MEDIA_ROOT / "manifest.json", note=payload.note, + asset_ids=payload.asset_ids, expected_version=payload.expected_version, + ) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @@ -74,11 +93,12 @@ def admin_publish_media_upgrades( def admin_rollback_media_upgrade( payload: AdminMediaRollback, _: Annotated[str, Depends(_admin)], -) -> dict[str, str]: + ) -> dict: """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, + MEDIA_ROOT / "manifest.json", asset_id=payload.asset_id, + note=payload.note, expected_version=payload.expected_version, ) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 9f750d2..d7b754b 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -486,7 +486,37 @@ class AdminMediaReviewOut(BaseModel): class AdminMediaDecision(BaseModel): note: str = Field(min_length=1, max_length=1000) + asset_ids: list[str] = Field(min_length=1, max_length=500) + expected_version: int = Field(ge=1) + + @field_validator("asset_ids") + @classmethod + def validate_asset_ids(cls, value: list[str]) -> list[str]: + if len(set(value)) != len(value) or any(len(item) != 64 or any(char not in "0123456789abcdef" for char in item) for item in value): + raise ValueError("asset_ids must contain unique SHA-256 ids") + return value -class AdminMediaRollback(AdminMediaDecision): - asset_url: str = Field(min_length=1, max_length=2000) +class AdminMediaPreview(BaseModel): + asset_ids: list[str] = Field(min_length=1, max_length=500) + expected_version: int | None = Field(default=None, ge=1) + + @field_validator("asset_ids") + @classmethod + def validate_asset_ids(cls, value: list[str]) -> list[str]: + if len(set(value)) != len(value) or any(len(item) != 64 or any(char not in "0123456789abcdef" for char in item) for item in value): + raise ValueError("asset_ids must contain unique SHA-256 ids") + return value + + +class AdminMediaRollback(BaseModel): + note: str = Field(min_length=1, max_length=1000) + asset_id: str = Field(min_length=64, max_length=64) + expected_version: int = Field(ge=1) + + @field_validator("asset_id") + @classmethod + def validate_asset_id(cls, value: str) -> str: + if any(char not in "0123456789abcdef" for char in value): + raise ValueError("asset_id must be a SHA-256 id") + return value diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5321620..d691d5a 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -226,6 +226,20 @@ }, "AdminMediaDecision": { "properties": { + "asset_ids": { + "items": { + "type": "string" + }, + "maxItems": 500, + "minItems": 1, + "title": "Asset Ids", + "type": "array" + }, + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, "note": { "maxLength": 1000, "minLength": 1, @@ -234,7 +248,9 @@ } }, "required": [ - "note" + "note", + "asset_ids", + "expected_version" ], "title": "AdminMediaDecision", "type": "object" @@ -295,6 +311,36 @@ "title": "AdminMediaDerivativeOut", "type": "object" }, + "AdminMediaPreview": { + "properties": { + "asset_ids": { + "items": { + "type": "string" + }, + "maxItems": 500, + "minItems": 1, + "title": "Asset Ids", + "type": "array" + }, + "expected_version": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expected Version" + } + }, + "required": [ + "asset_ids" + ], + "title": "AdminMediaPreview", + "type": "object" + }, "AdminMediaReviewOut": { "properties": { "asset_url": { @@ -439,12 +485,17 @@ }, "AdminMediaRollback": { "properties": { - "asset_url": { - "maxLength": 2000, - "minLength": 1, - "title": "Asset Url", + "asset_id": { + "maxLength": 64, + "minLength": 64, + "title": "Asset Id", "type": "string" }, + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, "note": { "maxLength": 1000, "minLength": 1, @@ -454,7 +505,8 @@ }, "required": [ "note", - "asset_url" + "asset_id", + "expected_version" ], "title": "AdminMediaRollback", "type": "object" @@ -4365,6 +4417,64 @@ "summary": "Admin Media Catalog" } }, + "/api/v1/admin/media/upgrades/preview": { + "post": { + "operationId": "admin_preview_media_upgrades_api_v1_admin_media_upgrades_preview_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMediaPreview" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Admin Preview Media Upgrades Api V1 Admin Media Upgrades Preview Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Preview Media Upgrades" + } + }, "/api/v1/admin/media/upgrades/publish": { "post": { "description": "Atomically publish all stored quality upgrades after an explicit decision.", @@ -4463,9 +4573,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "string" - }, + "additionalProperties": true, "title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post", "type": "object" } diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 84e893c..24845ee 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -207,29 +207,39 @@ def test_admin_media_review_requires_auth() -> None: 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 + decision = {"asset_ids": ["a" * 64], "expected_version": 1, "note": "publish"} + assert client.post("/api/v1/admin/media/upgrades/publish", json=decision).status_code == 401 + assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_id": "a" * 64, "expected_version": 1, "note": "rollback"}).status_code == 401 - monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2}) + monkeypatch.setattr(admin_router, "preview_quality_upgrades", lambda path, asset_ids, expected_version: {"manifest_version": expected_version or 1, "assets": [{"id": asset_ids[0]}]}) + preview = client.post( + "/api/v1/admin/media/upgrades/preview", + json={"asset_ids": ["a" * 64], "expected_version": 2}, + headers={"Authorization": "Bearer change-me-in-production"}, + ) + assert preview.status_code == 200 + assert preview.json()["manifest_version"] == 2 + + monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note, asset_ids, expected_version: {"published": 2, "retained_fallbacks": 2, "manifest_version": expected_version + 1}) publish = client.post( "/api/v1/admin/media/upgrades/publish", - json={"note": "visual review complete"}, + json={"asset_ids": ["a" * 64], "expected_version": 3, "note": "visual review complete"}, headers={"Authorization": "Bearer change-me-in-production"}, ) assert publish.status_code == 200 - assert publish.json() == {"published": 2, "retained_fallbacks": 2} + assert publish.json() == {"published": 2, "retained_fallbacks": 2, "manifest_version": 4} - monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"}) + monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_id, note, expected_version: {"rolled_back": asset_id, "restored": "https://example.test/fallback", "manifest_version": expected_version + 1}) rollback = client.post( "/api/v1/admin/media/upgrades/rollback", - json={"asset_url": "https://example.test/a", "note": "fallback is preferred"}, + json={"asset_id": "a" * 64, "expected_version": 4, "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 rollback.json()["rolled_back"] == "a" * 64 assert client.post( "/api/v1/admin/media/upgrades/publish", - json={"note": ""}, + json={"asset_ids": ["a" * 64], "expected_version": 1, "note": ""}, headers={"Authorization": "Bearer change-me-in-production"}, ).status_code == 422 diff --git a/apps/web/src/pages/admin/media.astro b/apps/web/src/pages/admin/media.astro index 0f14704..35158a1 100644 --- a/apps/web/src/pages/admin/media.astro +++ b/apps/web/src/pages/admin/media.astro @@ -34,7 +34,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const roleFilter = filters?.querySelector('[name="media_role"]'); const rolesByEntity: Record> = { waterbody: new Set(["waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"]), tackle: new Set(["tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"]) }; const syncRoleFilter = () => { const allowed = rolesByEntity[entityFilter?.value ?? ""] ?? new Set(); if (roleFilter) { [...roleFilter.options].forEach(option => { if (!option.value) return; option.hidden = !allowed.has(option.value); option.disabled = !allowed.has(option.value); }); if (roleFilter.value && !allowed.has(roleFilter.value)) roleFilter.value = ""; roleFilter.disabled = allowed.size === 0; } }; - let token = ""; let offset = 0; let timer: ReturnType | undefined; + let token = ""; let offset = 0; let manifestVersion = 1; let timer: ReturnType | undefined; const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char)); const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password && parsed.hostname ? esc(parsed.href) : ""; } catch { return ""; } }; const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } }; @@ -48,13 +48,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["q", "entity_type", "media_role", "status"]) { const value = String(values.get(key) || "").trim(); if (value) params.set(key, value); } const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} }); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); } - const rows: Record[] = await response.json(); keepSession(); list.removeAttribute("aria-busy"); + manifestVersion = Number(response.headers.get("X-Media-Manifest-Version") || 1); const rows: Record[] = await response.json(); keepSession(); list.removeAttribute("aria-busy"); 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 role = String(asset.media_role || ""); 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)}${role ? `${esc(mediaRoleLabel(role))}` : ""}

${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); } + list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const role = String(asset.media_role || ""); 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)}${role ? `${esc(mediaRoleLabel(role))}` : ""}

${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_id:button.dataset.mediaRollback, expected_version:manifestVersion, 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 asset_ids = assets.filter(asset => asset.status === "upgrade_stored").map(asset => String(asset.id)); 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({asset_ids, expected_version:manifestVersion, 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 : "Ошибка фильтрации."); } }); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 24e0a96..4fa233f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,7 +24,7 @@ R-пункты уточняют критерии существующих B/G/U/ Доказательства и ограничения: [аудит](PROJECT_AUDIT_2026-09-22.md). - [x] **R01 · P1 · Долговременное хранение media-решений (A04/B23/A07).** Production Compose получил named `media_data` volume и отдельный root-only `media-init`: baseline из Git атомарно merge-ится только для новых assets, существующие решения/provenance не заменяются, volume передаётся API-пользователю. `backup.sh` сохраняет persistent manifest/assets в `media.tar.gz`, `restore.sh` восстанавливает их вместе с PostgreSQL и MinIO. Изолированный bootstrap подтвердил сохранение manifest после `--force-recreate` API; backup/restore drill подтвердил checksum и восстановление контрольного media-решения. Production secrets, внешнее backup-хранилище и серверный A07 acceptance-run остаются открытыми. -- [ ] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Явные asset IDs, preview, версия manifest, lock и проверка файлов. Критерий: скрытые кандидаты не публикуются, устаревшее решение — конфликт, параллельные CLI/API не теряют изменения. +- [x] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Admin API получил preview и явный набор SHA-256 asset IDs; publish больше не выбирает скрытые `upgrade_stored`, проверяет целостность выбранных файлов/вариантов, сверяет monotonic manifest version и возвращает `409` для устаревшей вкладки. Publish/rollback и CLI-совместимый путь сериализованы общим lock-файлом; решения пишутся атомарно и увеличивают версию. Regression покрывает выбор одного кандидата из двух и stale-version conflict; web admin передаёт ID и версию из заголовка manifest. Browser/production acceptance A04/A06/A07 остаётся отдельным gate. - [ ] **R03 · P1 · Идемпотентная отправка улова.** Сверять payload/срок ключа во всех ветках, включая IntegrityError и завершённый upload; обработать конкурентное создание Spot/Bait. Критерий: одинаковый повтор возвращает тот же результат, другой payload — 409, ожидаемые гонки не дают 500. - [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется. - [ ] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Отдельная пагинация сборок/предметов, независимые ошибки, валидация URL-фильтров, empty search отдельно от незаполненного каталога. Критерий: 49+ сборок достижимы при 0 предметов, 422 не маскируется под 503. Убрать вложенные ссылки: карточка оборачивает DataPassport с source-link; проверить итоговый DOM и keyboard наполненных карточек. diff --git a/rf4_research/media_assets.py b/rf4_research/media_assets.py index a490c8e..a995155 100644 --- a/rf4_research/media_assets.py +++ b/rf4_research/media_assets.py @@ -5,11 +5,14 @@ import io import json import mimetypes import re +from contextlib import contextmanager from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from urllib.parse import urljoin, urlsplit +import fcntl + from bs4 import BeautifulSoup, Tag from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError @@ -27,6 +30,52 @@ MEDIA_ROLES_BY_ENTITY = { } +@contextmanager +def _manifest_lock(path: Path): + """Serialize decisions made by API and CLI against one manifest.""" + lock_path = path.with_name(f"{path.name}.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("w", encoding="utf-8") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +def _manifest_version(manifest: dict) -> int: + try: + return max(1, int(manifest.get("version", 1))) + except (TypeError, ValueError) as exc: + raise ValueError("manifest version is invalid") from exc + + +def _asset_id(item: dict) -> str | None: + value = item.get("sha256") + return value if isinstance(value, str) and len(value) == 64 else None + + +def _selected_upgrade_candidates(manifest: dict, asset_ids: list[str] | None) -> list[dict]: + candidates = [item for item in manifest.get("assets", []) if item.get("status") == "upgrade_stored"] + if asset_ids is None: + return candidates + selected = set(asset_ids) + by_id = {_asset_id(item): item for item in candidates} + if len(selected) != len(asset_ids): + raise ValueError("asset_ids must be unique") + missing = sorted(selected - set(by_id)) + if missing: + raise ValueError(f"selected asset is not an upgrade_stored candidate: {missing[0]}") + return [by_id[asset_id] for asset_id in asset_ids] + + +def _check_expected_version(manifest: dict, expected_version: int | None) -> int: + version = _manifest_version(manifest) + if expected_version is not None and expected_version != version: + raise ValueError(f"manifest version conflict: expected {expected_version}, current {version}") + return version + + @dataclass(frozen=True, slots=True) class MediaCandidate: source_page: str @@ -197,70 +246,96 @@ def approve_stored_assets(path: Path, *, note: str) -> dict: return {"approved": approved} -def publish_quality_upgrades(path: Path, *, note: str, minimum_dimension: int = 256) -> dict: - """Atomically promote every reviewed quality candidate and retain its fallback.""" - manifest = json.loads(path.read_text(encoding="utf-8")) - assets = manifest.get("assets", []) - by_url = {item.get("asset_url"): item for item in assets} - candidates = [item for item in assets if item.get("status") == "upgrade_stored"] - - # Validate the complete set before changing any public mapping. - for candidate in candidates: - fallback = by_url.get(candidate.get("duplicate_of")) - if not fallback or fallback.get("status") != "approved": - raise ValueError(f"{candidate.get('asset_url')}: approved fallback is missing") - if not fallback.get("entity_key"): - raise ValueError(f"{candidate.get('asset_url')}: fallback has no entity_key") - if min(int(candidate.get("width") or 0), int(candidate.get("height") or 0)) < minimum_dimension: - raise ValueError(f"{candidate.get('asset_url')}: candidate is below {minimum_dimension}px") - if not candidate.get("sha256") or not candidate.get("local_path"): - raise ValueError(f"{candidate.get('asset_url')}: stored candidate metadata is incomplete") - - reviewed_at = datetime.now(timezone.utc).isoformat() - for candidate in candidates: - fallback = by_url[candidate["duplicate_of"]] - candidate.update({ - "status": "approved", - "entity_key": fallback["entity_key"], - "supersedes": fallback["asset_url"], - "reviewed_at": reviewed_at, - "review_note": note, - }) - fallback.update({ - "status": "superseded", - "replaced_by": candidate["asset_url"], - "reviewed_at": reviewed_at, - "review_note": note, - }) - - manifest["updated_at"] = reviewed_at - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - temporary.replace(path) - return {"published": len(candidates), "retained_fallbacks": len(candidates)} +def preview_quality_upgrades(path: Path, *, asset_ids: list[str], expected_version: int | None = None) -> dict: + """Return the exact selected set and version used for a later decision.""" + with _manifest_lock(path): + manifest = json.loads(path.read_text(encoding="utf-8")) + version = _check_expected_version(manifest, expected_version) + candidates = _selected_upgrade_candidates(manifest, asset_ids) + return { + "manifest_version": version, + "assets": [{ + "id": _asset_id(item), "asset_url": item.get("asset_url"), + "duplicate_of": item.get("duplicate_of"), "label": item.get("label"), + "width": item.get("width"), "height": item.get("height"), + } for item in candidates], + } -def rollback_quality_upgrade(path: Path, *, asset_url: str, note: str) -> dict: +def publish_quality_upgrades( + path: Path, *, note: str, asset_ids: list[str] | None = None, + expected_version: int | None = None, minimum_dimension: int = 256, +) -> dict: + """Atomically promote an explicit reviewed set and retain its fallbacks.""" + with _manifest_lock(path): + manifest = json.loads(path.read_text(encoding="utf-8")) + previous_version = _check_expected_version(manifest, expected_version) + assets = manifest.get("assets", []) + by_url = {item.get("asset_url"): item for item in assets} + candidates = _selected_upgrade_candidates(manifest, asset_ids) + + for candidate in candidates: + fallback = by_url.get(candidate.get("duplicate_of")) + if not fallback or fallback.get("status") != "approved": + raise ValueError(f"{candidate.get('asset_url')}: approved fallback is missing") + if not fallback.get("entity_key"): + raise ValueError(f"{candidate.get('asset_url')}: fallback has no entity_key") + if min(int(candidate.get("width") or 0), int(candidate.get("height") or 0)) < minimum_dimension: + raise ValueError(f"{candidate.get('asset_url')}: candidate is below {minimum_dimension}px") + if not candidate.get("sha256") or not candidate.get("local_path"): + raise ValueError(f"{candidate.get('asset_url')}: stored candidate metadata is incomplete") + + reviewed_at = datetime.now(timezone.utc).isoformat() + for candidate in candidates: + fallback = by_url[candidate["duplicate_of"]] + candidate.update({ + "status": "approved", "entity_key": fallback["entity_key"], + "supersedes": fallback["asset_url"], "reviewed_at": reviewed_at, + "review_note": note, + }) + fallback.update({ + "status": "superseded", "replaced_by": candidate["asset_url"], + "reviewed_at": reviewed_at, "review_note": note, + }) + + manifest["version"] = previous_version + 1 + manifest["updated_at"] = reviewed_at + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + return {"published": len(candidates), "retained_fallbacks": len(candidates), "manifest_version": manifest["version"]} + + +def rollback_quality_upgrade( + path: Path, *, asset_url: str | None = None, asset_id: str | None = None, + note: str, expected_version: int | None = None, +) -> dict: """Restore the superseded fallback for one explicitly selected upgrade.""" - manifest = json.loads(path.read_text(encoding="utf-8")) - assets = manifest.get("assets", []) - candidate = next((item for item in assets if item.get("asset_url") == asset_url), None) - if not candidate or candidate.get("status") != "approved" or not candidate.get("supersedes"): - raise ValueError("rollback requires an approved upgrade with a supersedes link") - fallback = next((item for item in assets if item.get("asset_url") == candidate["supersedes"]), None) - if not fallback or fallback.get("status") != "superseded" or fallback.get("replaced_by") != asset_url: - raise ValueError("rollback requires the matching superseded fallback") + if not asset_id and not asset_url: + raise ValueError("rollback requires an asset id") + with _manifest_lock(path): + manifest = json.loads(path.read_text(encoding="utf-8")) + previous_version = _check_expected_version(manifest, expected_version) + assets = manifest.get("assets", []) + candidate = next((item for item in assets if (asset_id and _asset_id(item) == asset_id) or (asset_url and item.get("asset_url") == asset_url)), None) + if not candidate or candidate.get("status") != "approved" or not candidate.get("supersedes"): + raise ValueError("rollback requires an approved upgrade with a supersedes link") + selected_url = candidate.get("asset_url") + fallback = next((item for item in assets if item.get("asset_url") == candidate["supersedes"]), None) + if not fallback or fallback.get("status") != "superseded" or fallback.get("replaced_by") != selected_url: + raise ValueError("rollback requires the matching superseded fallback") - reviewed_at = datetime.now(timezone.utc).isoformat() - candidate.update({"status": "upgrade_stored", "reviewed_at": reviewed_at, "review_note": note}) - candidate.pop("supersedes", None) - fallback.update({"status": "approved", "reviewed_at": reviewed_at, "review_note": note}) - fallback.pop("replaced_by", None) - manifest["updated_at"] = reviewed_at - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - temporary.replace(path) - return {"rolled_back": asset_url, "restored": fallback["asset_url"]} + reviewed_at = datetime.now(timezone.utc).isoformat() + candidate.update({"status": "upgrade_stored", "reviewed_at": reviewed_at, "review_note": note}) + candidate.pop("supersedes", None) + fallback.update({"status": "approved", "reviewed_at": reviewed_at, "review_note": note}) + fallback.pop("replaced_by", None) + manifest["version"] = previous_version + 1 + manifest["updated_at"] = reviewed_at + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + return {"rolled_back": selected_url, "restored": fallback["asset_url"], "manifest_version": manifest["version"]} def reclassify_manifest(path: Path) -> dict: diff --git a/tests/test_media_assets.py b/tests/test_media_assets.py index 87f6949..077729d 100644 --- a/tests/test_media_assets.py +++ b/tests/test_media_assets.py @@ -6,7 +6,7 @@ import io import pytest from PIL import Image -from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, compare_quality_upgrades, extract_media_candidates, generate_media_derivatives, generate_quality_contact_sheets, inspect_image, media_coverage, media_quality_report, merge_manifest, publish_quality_upgrades, queue_quality_upgrades, reconcile_queued_duplicates, review_asset, rollback_quality_upgrade, store_asset +from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, compare_quality_upgrades, extract_media_candidates, generate_media_derivatives, generate_quality_contact_sheets, inspect_image, media_coverage, media_quality_report, merge_manifest, preview_quality_upgrades, publish_quality_upgrades, queue_quality_upgrades, reconcile_queued_duplicates, review_asset, rollback_quality_upgrade, store_asset def test_extracts_and_classifies_unique_https_media() -> None: @@ -357,7 +357,7 @@ def test_publish_quality_upgrades_switches_mapping_and_retains_fallback(tmp_path report = publish_quality_upgrades(path, note="owner approved quality upgrades") assets = json.loads(path.read_text(encoding="utf-8"))["assets"] - assert report == {"published": 1, "retained_fallbacks": 1} + assert report == {"published": 1, "retained_fallbacks": 1, "manifest_version": 2} assert assets[0]["status"] == "superseded" assert assets[0]["replaced_by"] == assets[1]["asset_url"] assert assets[1]["status"] == "approved" @@ -375,8 +375,30 @@ def test_rollback_quality_upgrade_restores_fallback_atomically(tmp_path: Path) - report = rollback_quality_upgrade(path, asset_url="https://large/pike.webp", note="owner rolled back after visual review") assets = json.loads(path.read_text(encoding="utf-8"))["assets"] - assert report == {"rolled_back": "https://large/pike.webp", "restored": "https://small/pike.png"} + assert report == {"rolled_back": "https://large/pike.webp", "restored": "https://small/pike.png", "manifest_version": 2} assert assets[0]["status"] == "approved" assert "replaced_by" not in assets[0] assert assets[1]["status"] == "upgrade_stored" assert "supersedes" not in assets[1] + + +def test_publish_selects_ids_and_rejects_stale_manifest_version(tmp_path: Path) -> None: + path = tmp_path / "manifest.json" + path.write_text(json.dumps({"version": 7, "assets": [ + {"entity_type": "fish", "entity_key": "fish:pike", "status": "approved", "asset_url": "https://small/pike.png", "local_path": "files/small.png", "sha256": "a" * 64}, + {"entity_type": "fish", "status": "upgrade_stored", "asset_url": "https://large/pike.webp", "duplicate_of": "https://small/pike.png", "local_path": "files/large.webp", "sha256": "b" * 64, "width": 512, "height": 512}, + {"entity_type": "fish", "entity_key": "fish:perch", "status": "approved", "asset_url": "https://small/perch.png", "local_path": "files/small-perch.png", "sha256": "c" * 64}, + {"entity_type": "fish", "status": "upgrade_stored", "asset_url": "https://large/perch.webp", "duplicate_of": "https://small/perch.png", "local_path": "files/large-perch.webp", "sha256": "d" * 64, "width": 512, "height": 512}, + ]}), encoding="utf-8") + + assert preview_quality_upgrades(path, asset_ids=["b" * 64]) == { + "manifest_version": 7, + "assets": [{"id": "b" * 64, "asset_url": "https://large/pike.webp", "duplicate_of": "https://small/pike.png", "label": None, "width": 512, "height": 512}], + } + report = publish_quality_upgrades(path, asset_ids=["b" * 64], expected_version=7, note="one reviewed replacement") + assert report["manifest_version"] == 8 + saved = json.loads(path.read_text(encoding="utf-8")) + assert [item["status"] for item in saved["assets"]] == ["superseded", "approved", "approved", "upgrade_stored"] + + with pytest.raises(ValueError, match="manifest version conflict"): + publish_quality_upgrades(path, asset_ids=["d" * 64], expected_version=7, note="stale tab")