fix: serialize media publishing decisions
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
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 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
@@ -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 наполненных карточек.
|
||||||
|
|||||||
@@ -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,14 +246,34 @@ 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."""
|
||||||
|
with _manifest_lock(path):
|
||||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
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 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", [])
|
assets = manifest.get("assets", [])
|
||||||
by_url = {item.get("asset_url"): item for item in assets}
|
by_url = {item.get("asset_url"): item for item in assets}
|
||||||
candidates = [item for item in assets if item.get("status") == "upgrade_stored"]
|
candidates = _selected_upgrade_candidates(manifest, asset_ids)
|
||||||
|
|
||||||
# Validate the complete set before changing any public mapping.
|
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
fallback = by_url.get(candidate.get("duplicate_of"))
|
fallback = by_url.get(candidate.get("duplicate_of"))
|
||||||
if not fallback or fallback.get("status") != "approved":
|
if not fallback or fallback.get("status") != "approved":
|
||||||
@@ -220,35 +289,40 @@ def publish_quality_upgrades(path: Path, *, note: str, minimum_dimension: int =
|
|||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
fallback = by_url[candidate["duplicate_of"]]
|
fallback = by_url[candidate["duplicate_of"]]
|
||||||
candidate.update({
|
candidate.update({
|
||||||
"status": "approved",
|
"status": "approved", "entity_key": fallback["entity_key"],
|
||||||
"entity_key": fallback["entity_key"],
|
"supersedes": fallback["asset_url"], "reviewed_at": reviewed_at,
|
||||||
"supersedes": fallback["asset_url"],
|
|
||||||
"reviewed_at": reviewed_at,
|
|
||||||
"review_note": note,
|
"review_note": note,
|
||||||
})
|
})
|
||||||
fallback.update({
|
fallback.update({
|
||||||
"status": "superseded",
|
"status": "superseded", "replaced_by": candidate["asset_url"],
|
||||||
"replaced_by": candidate["asset_url"],
|
"reviewed_at": reviewed_at, "review_note": note,
|
||||||
"reviewed_at": reviewed_at,
|
|
||||||
"review_note": note,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
manifest["version"] = previous_version + 1
|
||||||
manifest["updated_at"] = reviewed_at
|
manifest["updated_at"] = reviewed_at
|
||||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
temporary.replace(path)
|
temporary.replace(path)
|
||||||
return {"published": len(candidates), "retained_fallbacks": len(candidates)}
|
return {"published": len(candidates), "retained_fallbacks": len(candidates), "manifest_version": manifest["version"]}
|
||||||
|
|
||||||
|
|
||||||
def rollback_quality_upgrade(path: Path, *, asset_url: str, note: str) -> dict:
|
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."""
|
||||||
|
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"))
|
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
previous_version = _check_expected_version(manifest, expected_version)
|
||||||
assets = manifest.get("assets", [])
|
assets = manifest.get("assets", [])
|
||||||
candidate = next((item for item in assets if item.get("asset_url") == asset_url), None)
|
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"):
|
if not candidate or candidate.get("status") != "approved" or not candidate.get("supersedes"):
|
||||||
raise ValueError("rollback requires an approved upgrade with a supersedes link")
|
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)
|
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:
|
if not fallback or fallback.get("status") != "superseded" or fallback.get("replaced_by") != selected_url:
|
||||||
raise ValueError("rollback requires the matching superseded fallback")
|
raise ValueError("rollback requires the matching superseded fallback")
|
||||||
|
|
||||||
reviewed_at = datetime.now(timezone.utc).isoformat()
|
reviewed_at = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -256,11 +330,12 @@ def rollback_quality_upgrade(path: Path, *, asset_url: str, note: str) -> dict:
|
|||||||
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["version"] = previous_version + 1
|
||||||
manifest["updated_at"] = reviewed_at
|
manifest["updated_at"] = reviewed_at
|
||||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
temporary.replace(path)
|
temporary.replace(path)
|
||||||
return {"rolled_back": asset_url, "restored": fallback["asset_url"]}
|
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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user