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}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user