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}
def media_manifest_version() -> int:
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
return max(1, int(manifest.get("version", 1)))
def _public_role_allowed(entity_type: str | None, role: object) -> bool:
if role is None:
return True
+26 -6
View File
@@ -15,12 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings
from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
from rf4_research.media_assets import preview_quality_upgrades, publish_quality_upgrades, rollback_quality_upgrade
from ..media_catalog import KNOWN_MEDIA_ROLES, MEDIA_ROLES_BY_ENTITY, MEDIA_ROOT, review_assets, review_file
from ..media_catalog import KNOWN_MEDIA_ROLES, MEDIA_ROLES_BY_ENTITY, MEDIA_ROOT, media_manifest_version, review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaPreview, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate
from ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware
@@ -35,6 +35,7 @@ def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header
@router.get("/api/v1/admin/media/catalog", response_model=list[AdminMediaReviewOut])
def admin_media_catalog(
_: Annotated[str, Depends(_admin)],
response: Response,
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"),
@@ -46,6 +47,7 @@ def admin_media_catalog(
raise HTTPException(status_code=422, detail="unknown media role")
if media_role and (not entity_type or media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type, set())):
raise HTTPException(status_code=422, detail="media role is incompatible with entity type")
response.headers["X-Media-Manifest-Version"] = str(media_manifest_version())
return review_assets(entity_type, status, media_role, q.strip() if q else None)[offset:offset + limit]
@@ -58,6 +60,20 @@ def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileRe
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
@router.post("/api/v1/admin/media/upgrades/preview")
def admin_preview_media_upgrades(
payload: AdminMediaPreview,
_: Annotated[str, Depends(_admin)],
) -> dict:
try:
return preview_quality_upgrades(
MEDIA_ROOT / "manifest.json", asset_ids=payload.asset_ids,
expected_version=payload.expected_version,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/api/v1/admin/media/upgrades/publish")
def admin_publish_media_upgrades(
payload: AdminMediaDecision,
@@ -65,7 +81,10 @@ def admin_publish_media_upgrades(
) -> dict[str, int]:
"""Atomically publish all stored quality upgrades after an explicit decision."""
try:
return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note)
return publish_quality_upgrades(
MEDIA_ROOT / "manifest.json", note=payload.note,
asset_ids=payload.asset_ids, expected_version=payload.expected_version,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -74,11 +93,12 @@ def admin_publish_media_upgrades(
def admin_rollback_media_upgrade(
payload: AdminMediaRollback,
_: Annotated[str, Depends(_admin)],
) -> dict[str, str]:
) -> dict:
"""Restore one superseded fallback while retaining the reviewed candidate."""
try:
return rollback_quality_upgrade(
MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note,
MEDIA_ROOT / "manifest.json", asset_id=payload.asset_id,
note=payload.note, expected_version=payload.expected_version,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
+32 -2
View File
@@ -486,7 +486,37 @@ class AdminMediaReviewOut(BaseModel):
class AdminMediaDecision(BaseModel):
note: str = Field(min_length=1, max_length=1000)
asset_ids: list[str] = Field(min_length=1, max_length=500)
expected_version: int = Field(ge=1)
@field_validator("asset_ids")
@classmethod
def validate_asset_ids(cls, value: list[str]) -> list[str]:
if len(set(value)) != len(value) or any(len(item) != 64 or any(char not in "0123456789abcdef" for char in item) for item in value):
raise ValueError("asset_ids must contain unique SHA-256 ids")
return value
class AdminMediaRollback(AdminMediaDecision):
asset_url: str = Field(min_length=1, max_length=2000)
class AdminMediaPreview(BaseModel):
asset_ids: list[str] = Field(min_length=1, max_length=500)
expected_version: int | None = Field(default=None, ge=1)
@field_validator("asset_ids")
@classmethod
def validate_asset_ids(cls, value: list[str]) -> list[str]:
if len(set(value)) != len(value) or any(len(item) != 64 or any(char not in "0123456789abcdef" for char in item) for item in value):
raise ValueError("asset_ids must contain unique SHA-256 ids")
return value
class AdminMediaRollback(BaseModel):
note: str = Field(min_length=1, max_length=1000)
asset_id: str = Field(min_length=64, max_length=64)
expected_version: int = Field(ge=1)
@field_validator("asset_id")
@classmethod
def validate_asset_id(cls, value: str) -> str:
if any(char not in "0123456789abcdef" for char in value):
raise ValueError("asset_id must be a SHA-256 id")
return value
+117 -9
View File
@@ -226,6 +226,20 @@
},
"AdminMediaDecision": {
"properties": {
"asset_ids": {
"items": {
"type": "string"
},
"maxItems": 500,
"minItems": 1,
"title": "Asset Ids",
"type": "array"
},
"expected_version": {
"minimum": 1.0,
"title": "Expected Version",
"type": "integer"
},
"note": {
"maxLength": 1000,
"minLength": 1,
@@ -234,7 +248,9 @@
}
},
"required": [
"note"
"note",
"asset_ids",
"expected_version"
],
"title": "AdminMediaDecision",
"type": "object"
@@ -295,6 +311,36 @@
"title": "AdminMediaDerivativeOut",
"type": "object"
},
"AdminMediaPreview": {
"properties": {
"asset_ids": {
"items": {
"type": "string"
},
"maxItems": 500,
"minItems": 1,
"title": "Asset Ids",
"type": "array"
},
"expected_version": {
"anyOf": [
{
"minimum": 1.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Expected Version"
}
},
"required": [
"asset_ids"
],
"title": "AdminMediaPreview",
"type": "object"
},
"AdminMediaReviewOut": {
"properties": {
"asset_url": {
@@ -439,12 +485,17 @@
},
"AdminMediaRollback": {
"properties": {
"asset_url": {
"maxLength": 2000,
"minLength": 1,
"title": "Asset Url",
"asset_id": {
"maxLength": 64,
"minLength": 64,
"title": "Asset Id",
"type": "string"
},
"expected_version": {
"minimum": 1.0,
"title": "Expected Version",
"type": "integer"
},
"note": {
"maxLength": 1000,
"minLength": 1,
@@ -454,7 +505,8 @@
},
"required": [
"note",
"asset_url"
"asset_id",
"expected_version"
],
"title": "AdminMediaRollback",
"type": "object"
@@ -4365,6 +4417,64 @@
"summary": "Admin Media Catalog"
}
},
"/api/v1/admin/media/upgrades/preview": {
"post": {
"operationId": "admin_preview_media_upgrades_api_v1_admin_media_upgrades_preview_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminMediaPreview"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"title": "Response Admin Preview Media Upgrades Api V1 Admin Media Upgrades Preview Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Preview Media Upgrades"
}
},
"/api/v1/admin/media/upgrades/publish": {
"post": {
"description": "Atomically publish all stored quality upgrades after an explicit decision.",
@@ -4463,9 +4573,7 @@
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"additionalProperties": true,
"title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post",
"type": "object"
}
+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:
assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401
decision = {"asset_ids": ["a" * 64], "expected_version": 1, "note": "publish"}
assert client.post("/api/v1/admin/media/upgrades/publish", json=decision).status_code == 401
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_id": "a" * 64, "expected_version": 1, "note": "rollback"}).status_code == 401
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2})
monkeypatch.setattr(admin_router, "preview_quality_upgrades", lambda path, asset_ids, expected_version: {"manifest_version": expected_version or 1, "assets": [{"id": asset_ids[0]}]})
preview = client.post(
"/api/v1/admin/media/upgrades/preview",
json={"asset_ids": ["a" * 64], "expected_version": 2},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert preview.status_code == 200
assert preview.json()["manifest_version"] == 2
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note, asset_ids, expected_version: {"published": 2, "retained_fallbacks": 2, "manifest_version": expected_version + 1})
publish = client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": "visual review complete"},
json={"asset_ids": ["a" * 64], "expected_version": 3, "note": "visual review complete"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert publish.status_code == 200
assert publish.json() == {"published": 2, "retained_fallbacks": 2}
assert publish.json() == {"published": 2, "retained_fallbacks": 2, "manifest_version": 4}
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"})
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_id, note, expected_version: {"rolled_back": asset_id, "restored": "https://example.test/fallback", "manifest_version": expected_version + 1})
rollback = client.post(
"/api/v1/admin/media/upgrades/rollback",
json={"asset_url": "https://example.test/a", "note": "fallback is preferred"},
json={"asset_id": "a" * 64, "expected_version": 4, "note": "fallback is preferred"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert rollback.status_code == 200
assert rollback.json()["rolled_back"] == "https://example.test/a"
assert rollback.json()["rolled_back"] == "a" * 64
assert client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": ""},
json={"asset_ids": ["a" * 64], "expected_version": 1, "note": ""},
headers={"Authorization": "Bearer change-me-in-production"},
).status_code == 422