fix: serialize media publishing decisions

This commit is contained in:
ik
2026-09-22 07:58:38 +07:00
parent f3fb5868df
commit 0eb151f5a7
9 changed files with 365 additions and 95 deletions
+5
View File
@@ -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} 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: def _public_role_allowed(entity_type: str | None, role: object) -> bool:
if role is None: if role is None:
return True return True
+26 -6
View File
@@ -15,12 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records 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 ..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, 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 ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware 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]) @router.get("/api/v1/admin/media/catalog", response_model=list[AdminMediaReviewOut])
def admin_media_catalog( def admin_media_catalog(
_: Annotated[str, Depends(_admin)], _: Annotated[str, Depends(_admin)],
response: Response,
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"), entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"), 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)$"), 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") 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())): 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") 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] 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"}) 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") @router.post("/api/v1/admin/media/upgrades/publish")
def admin_publish_media_upgrades( def admin_publish_media_upgrades(
payload: AdminMediaDecision, payload: AdminMediaDecision,
@@ -65,7 +81,10 @@ def admin_publish_media_upgrades(
) -> dict[str, int]: ) -> dict[str, int]:
"""Atomically publish all stored quality upgrades after an explicit decision.""" """Atomically publish all stored quality upgrades after an explicit decision."""
try: 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: except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from 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( def admin_rollback_media_upgrade(
payload: AdminMediaRollback, payload: AdminMediaRollback,
_: Annotated[str, Depends(_admin)], _: Annotated[str, Depends(_admin)],
) -> dict[str, str]: ) -> dict:
"""Restore one superseded fallback while retaining the reviewed candidate.""" """Restore one superseded fallback while retaining the reviewed candidate."""
try: try:
return rollback_quality_upgrade( 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: except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
+32 -2
View File
@@ -486,7 +486,37 @@ class AdminMediaReviewOut(BaseModel):
class AdminMediaDecision(BaseModel): class AdminMediaDecision(BaseModel):
note: str = Field(min_length=1, max_length=1000) 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): class AdminMediaPreview(BaseModel):
asset_url: str = Field(min_length=1, max_length=2000) 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
+117 -9
View File
@@ -226,6 +226,20 @@
}, },
"AdminMediaDecision": { "AdminMediaDecision": {
"properties": { "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": { "note": {
"maxLength": 1000, "maxLength": 1000,
"minLength": 1, "minLength": 1,
@@ -234,7 +248,9 @@
} }
}, },
"required": [ "required": [
"note" "note",
"asset_ids",
"expected_version"
], ],
"title": "AdminMediaDecision", "title": "AdminMediaDecision",
"type": "object" "type": "object"
@@ -295,6 +311,36 @@
"title": "AdminMediaDerivativeOut", "title": "AdminMediaDerivativeOut",
"type": "object" "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": { "AdminMediaReviewOut": {
"properties": { "properties": {
"asset_url": { "asset_url": {
@@ -439,12 +485,17 @@
}, },
"AdminMediaRollback": { "AdminMediaRollback": {
"properties": { "properties": {
"asset_url": { "asset_id": {
"maxLength": 2000, "maxLength": 64,
"minLength": 1, "minLength": 64,
"title": "Asset Url", "title": "Asset Id",
"type": "string" "type": "string"
}, },
"expected_version": {
"minimum": 1.0,
"title": "Expected Version",
"type": "integer"
},
"note": { "note": {
"maxLength": 1000, "maxLength": 1000,
"minLength": 1, "minLength": 1,
@@ -454,7 +505,8 @@
}, },
"required": [ "required": [
"note", "note",
"asset_url" "asset_id",
"expected_version"
], ],
"title": "AdminMediaRollback", "title": "AdminMediaRollback",
"type": "object" "type": "object"
@@ -4365,6 +4417,64 @@
"summary": "Admin Media Catalog" "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": { "/api/v1/admin/media/upgrades/publish": {
"post": { "post": {
"description": "Atomically publish all stored quality upgrades after an explicit decision.", "description": "Atomically publish all stored quality upgrades after an explicit decision.",
@@ -4463,9 +4573,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"additionalProperties": { "additionalProperties": true,
"type": "string"
},
"title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post", "title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post",
"type": "object" "type": "object"
} }
+19 -9
View File
@@ -207,29 +207,39 @@ def test_admin_media_review_requires_auth() -> None:
def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> 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 decision = {"asset_ids": ["a" * 64], "expected_version": 1, "note": "publish"}
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401 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( publish = client.post(
"/api/v1/admin/media/upgrades/publish", "/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"}, headers={"Authorization": "Bearer change-me-in-production"},
) )
assert publish.status_code == 200 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( rollback = client.post(
"/api/v1/admin/media/upgrades/rollback", "/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"}, headers={"Authorization": "Bearer change-me-in-production"},
) )
assert rollback.status_code == 200 assert rollback.status_code == 200
assert rollback.json()["rolled_back"] == "https://example.test/a" assert rollback.json()["rolled_back"] == "a" * 64
assert client.post( assert client.post(
"/api/v1/admin/media/upgrades/publish", "/api/v1/admin/media/upgrades/publish",
json={"note": ""}, json={"asset_ids": ["a" * 64], "expected_version": 1, "note": ""},
headers={"Authorization": "Bearer change-me-in-production"}, headers={"Authorization": "Bearer change-me-in-production"},
).status_code == 422 ).status_code == 422
+5 -5
View File
@@ -34,7 +34,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const roleFilter = filters?.querySelector<HTMLSelectElement>('[name="media_role"]'); const roleFilter = filters?.querySelector<HTMLSelectElement>('[name="media_role"]');
const rolesByEntity: Record<string, Set<string>> = { waterbody: new Set(["waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"]), tackle: new Set(["tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"]) }; const rolesByEntity: Record<string, Set<string>> = { 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<string>(); 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; } }; const syncRoleFilter = () => { const allowed = rolesByEntity[entityFilter?.value ?? ""] ?? new Set<string>(); 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<typeof setTimeout> | undefined; let token = ""; let offset = 0; let manifestVersion = 1; let timer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[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 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; } }; 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 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}`} }); 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, "Не удалось загрузить медиатеку.")); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy"); manifestVersion = Number(response.headers.get("X-Media-Manifest-Version") || 1); const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
if (!rows.length && offset > 0) { offset = 0; return load(); } 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}`; 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 = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; } if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; 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<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"${role ? ` data-media-role="${esc(role)}"` : ""}><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span>${role ? `<small>${esc(mediaRoleLabel(role))}</small>` : ""}<p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join(""); 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<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.id)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"${role ? ` data-media-role="${esc(role)}"` : ""}><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span>${role ? `<small>${esc(mediaRoleLabel(role))}</small>` : ""}<p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
list.querySelectorAll<HTMLButtonElement>("[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; } })); list.querySelectorAll<HTMLButtonElement>("[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 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); } 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 : "Ошибка загрузки."); } }); 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 : "Ошибка фильтрации."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
+1 -1
View File
@@ -24,7 +24,7 @@ R-пункты уточняют критерии существующих B/G/U/
Доказательства и ограничения: [аудит](PROJECT_AUDIT_2026-09-22.md). Доказательства и ограничения: [аудит](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 остаются открытыми. - [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. - [ ] **R03 · P1 · Идемпотентная отправка улова.** Сверять payload/срок ключа во всех ветках, включая IntegrityError и завершённый upload; обработать конкурентное создание Spot/Bait. Критерий: одинаковый повтор возвращает тот же результат, другой payload — 409, ожидаемые гонки не дают 500.
- [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется. - [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется.
- [ ] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Отдельная пагинация сборок/предметов, независимые ошибки, валидация URL-фильтров, empty search отдельно от незаполненного каталога. Критерий: 49+ сборок достижимы при 0 предметов, 422 не маскируется под 503. Убрать вложенные ссылки: карточка оборачивает DataPassport с source-link; проверить итоговый DOM и keyboard наполненных карточек. - [ ] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Отдельная пагинация сборок/предметов, независимые ошибки, валидация URL-фильтров, empty search отдельно от незаполненного каталога. Критерий: 49+ сборок достижимы при 0 предметов, 422 не маскируется под 503. Убрать вложенные ссылки: карточка оборачивает DataPassport с source-link; проверить итоговый DOM и keyboard наполненных карточек.
+135 -60
View File
@@ -5,11 +5,14 @@ import io
import json import json
import mimetypes import mimetypes
import re import re
from contextlib import contextmanager
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin, urlsplit from urllib.parse import urljoin, urlsplit
import fcntl
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError 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) @dataclass(frozen=True, slots=True)
class MediaCandidate: class MediaCandidate:
source_page: str source_page: str
@@ -197,70 +246,96 @@ def approve_stored_assets(path: Path, *, note: str) -> dict:
return {"approved": approved} return {"approved": approved}
def publish_quality_upgrades(path: Path, *, note: str, minimum_dimension: int = 256) -> dict: def preview_quality_upgrades(path: Path, *, asset_ids: list[str], expected_version: int | None = None) -> dict:
"""Atomically promote every reviewed quality candidate and retain its fallback.""" """Return the exact selected set and version used for a later decision."""
manifest = json.loads(path.read_text(encoding="utf-8")) with _manifest_lock(path):
assets = manifest.get("assets", []) manifest = json.loads(path.read_text(encoding="utf-8"))
by_url = {item.get("asset_url"): item for item in assets} version = _check_expected_version(manifest, expected_version)
candidates = [item for item in assets if item.get("status") == "upgrade_stored"] candidates = _selected_upgrade_candidates(manifest, asset_ids)
return {
# Validate the complete set before changing any public mapping. "manifest_version": version,
for candidate in candidates: "assets": [{
fallback = by_url.get(candidate.get("duplicate_of")) "id": _asset_id(item), "asset_url": item.get("asset_url"),
if not fallback or fallback.get("status") != "approved": "duplicate_of": item.get("duplicate_of"), "label": item.get("label"),
raise ValueError(f"{candidate.get('asset_url')}: approved fallback is missing") "width": item.get("width"), "height": item.get("height"),
if not fallback.get("entity_key"): } for item in candidates],
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 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.""" """Restore the superseded fallback for one explicitly selected upgrade."""
manifest = json.loads(path.read_text(encoding="utf-8")) if not asset_id and not asset_url:
assets = manifest.get("assets", []) raise ValueError("rollback requires an asset id")
candidate = next((item for item in assets if item.get("asset_url") == asset_url), None) with _manifest_lock(path):
if not candidate or candidate.get("status") != "approved" or not candidate.get("supersedes"): manifest = json.loads(path.read_text(encoding="utf-8"))
raise ValueError("rollback requires an approved upgrade with a supersedes link") previous_version = _check_expected_version(manifest, expected_version)
fallback = next((item for item in assets if item.get("asset_url") == candidate["supersedes"]), None) assets = manifest.get("assets", [])
if not fallback or fallback.get("status") != "superseded" or fallback.get("replaced_by") != asset_url: 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)
raise ValueError("rollback requires the matching superseded fallback") 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() reviewed_at = datetime.now(timezone.utc).isoformat()
candidate.update({"status": "upgrade_stored", "reviewed_at": reviewed_at, "review_note": note}) candidate.update({"status": "upgrade_stored", "reviewed_at": reviewed_at, "review_note": note})
candidate.pop("supersedes", None) candidate.pop("supersedes", None)
fallback.update({"status": "approved", "reviewed_at": reviewed_at, "review_note": note}) fallback.update({"status": "approved", "reviewed_at": reviewed_at, "review_note": note})
fallback.pop("replaced_by", None) fallback.pop("replaced_by", None)
manifest["updated_at"] = reviewed_at manifest["version"] = previous_version + 1
temporary = path.with_suffix(path.suffix + ".tmp") manifest["updated_at"] = reviewed_at
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") temporary = path.with_suffix(path.suffix + ".tmp")
temporary.replace(path) temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {"rolled_back": asset_url, "restored": fallback["asset_url"]} temporary.replace(path)
return {"rolled_back": selected_url, "restored": fallback["asset_url"], "manifest_version": manifest["version"]}
def reclassify_manifest(path: Path) -> dict: def reclassify_manifest(path: Path) -> dict:
+25 -3
View File
@@ -6,7 +6,7 @@ import io
import pytest import pytest
from PIL import Image 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: 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") report = publish_quality_upgrades(path, note="owner approved quality upgrades")
assets = json.loads(path.read_text(encoding="utf-8"))["assets"] 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]["status"] == "superseded"
assert assets[0]["replaced_by"] == assets[1]["asset_url"] assert assets[0]["replaced_by"] == assets[1]["asset_url"]
assert assets[1]["status"] == "approved" 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") 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"] 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 assets[0]["status"] == "approved"
assert "replaced_by" not in assets[0] assert "replaced_by" not in assets[0]
assert assets[1]["status"] == "upgrade_stored" assert assets[1]["status"] == "upgrade_stored"
assert "supersedes" not in assets[1] 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")