feat: extend admin operations and media review
This commit is contained in:
@@ -29,6 +29,17 @@ def published_assets(entity_type: str | None = None) -> list[dict]:
|
||||
"image_url": f"/api/v1/media/assets/{item['sha256']}",
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
"variants": [
|
||||
{
|
||||
"role": variant.get("role"),
|
||||
"format": variant.get("format"),
|
||||
"width": variant.get("width"),
|
||||
"height": variant.get("height"),
|
||||
"url": f"/api/v1/media/assets/{variant['sha256']}",
|
||||
}
|
||||
for variant in item.get("derivatives", [])
|
||||
if variant.get("sha256") and variant.get("local_path")
|
||||
],
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
@@ -44,3 +55,51 @@ def published_file(digest: str) -> tuple[Path, str] | None:
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item["content_type"])
|
||||
|
||||
|
||||
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
result = []
|
||||
for item in manifest.get("assets", []):
|
||||
item_status = str(item.get("status") or "")
|
||||
if item_status not in {"approved", "upgrade_queued", "upgrade_stored"} or (status and item_status != status):
|
||||
continue
|
||||
if entity_type and item.get("entity_type") != entity_type:
|
||||
continue
|
||||
digest = str(item.get("sha256") or "")
|
||||
if len(digest) != 64 or not item.get("local_path"):
|
||||
continue
|
||||
source_page = str(item.get("source_page") or "")
|
||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||
result.append({
|
||||
"id": digest,
|
||||
"status": item_status,
|
||||
"entity_type": item.get("entity_type"),
|
||||
"entity_key": item.get("entity_key"),
|
||||
"label": item.get("label"),
|
||||
"width": item.get("width"),
|
||||
"height": item.get("height"),
|
||||
"content_type": item.get("content_type"),
|
||||
"image_url": f"/api/v1/admin/media/assets/{digest}",
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
"duplicate_of": item.get("duplicate_of"),
|
||||
"derivatives": [{
|
||||
"role": variant.get("role"), "format": variant.get("format"),
|
||||
"width": variant.get("width"), "height": variant.get("height"),
|
||||
} for variant in item.get("derivatives", [])],
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["status"]), str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
|
||||
def review_file(digest: str) -> tuple[Path, str] | None:
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
return None
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
item = next((row for row in manifest.get("assets", []) if row.get("sha256") == digest and row.get("status") in {"approved", "upgrade_queued", "upgrade_stored"}), None)
|
||||
if not item or not item.get("local_path"):
|
||||
return None
|
||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item.get("content_type") or "application/octet-stream")
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 ..media_catalog import 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, AdminModerationHistoryOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
@@ -29,6 +30,26 @@ def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header
|
||||
return verify_admin(request, db, authorization, settings)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/media/catalog")
|
||||
def admin_media_catalog(
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
|
||||
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[dict]:
|
||||
return review_assets(entity_type, status)[offset:offset + limit]
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
|
||||
def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileResponse:
|
||||
item = review_file(digest)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Media review asset not found")
|
||||
path, media_type = item
|
||||
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/diagnostics")
|
||||
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||
report_counts = {status.value: count for status, count in db.execute(
|
||||
@@ -131,6 +152,52 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
|
||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/source-status")
|
||||
def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[dict[str, object]]:
|
||||
"""Return safe operational details needed by the owner dashboard."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result: list[dict[str, object]] = []
|
||||
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
|
||||
runs = list(db.scalars(
|
||||
select(CommunityImportRun)
|
||||
.where(CommunityImportRun.source_system == source.key)
|
||||
.order_by(CommunityImportRun.started_at.desc()).limit(20)
|
||||
))
|
||||
latest = runs[0] if runs else None
|
||||
success = next((run for run in runs if run.status == "success"), None)
|
||||
recent_failures = sum(
|
||||
1 for run in runs
|
||||
if run.status == "failed" and aware(run.started_at) >= now - timedelta(hours=24)
|
||||
)
|
||||
next_allowed = (
|
||||
aware(latest.started_at) + timedelta(seconds=settings.community_import_interval_seconds)
|
||||
if latest else None
|
||||
)
|
||||
cooldown_seconds = max(0, int((next_allowed - now).total_seconds())) if next_allowed else 0
|
||||
if not source.enabled:
|
||||
state = "disabled"
|
||||
elif latest is None:
|
||||
state = "waiting"
|
||||
elif latest.status == "failed":
|
||||
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
|
||||
elif aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
|
||||
state = "stale"
|
||||
else:
|
||||
state = "healthy"
|
||||
result.append({
|
||||
"source_system": source.key,
|
||||
"name": source.name,
|
||||
"status": state,
|
||||
"last_started_at": latest.started_at if latest else None,
|
||||
"last_success_at": success.started_at if success else None,
|
||||
"next_allowed_at": next_allowed,
|
||||
"cooldown_seconds": cooldown_seconds,
|
||||
"recent_failures_24h": recent_failures,
|
||||
"backoff_recommended": recent_failures >= 5,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
||||
allowed_payload = {
|
||||
key: value for key, value in (item.payload or {}).items()
|
||||
|
||||
Reference in New Issue
Block a user