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()
|
||||
|
||||
+370
-3
@@ -30,6 +30,17 @@
|
||||
"title": "Confidence Score",
|
||||
"type": "integer"
|
||||
},
|
||||
"coordinate_precision": {
|
||||
"title": "Coordinate Precision",
|
||||
"type": "string"
|
||||
},
|
||||
"coordinate_sources": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Coordinate Sources",
|
||||
"type": "array"
|
||||
},
|
||||
"explanation": {
|
||||
"title": "Explanation",
|
||||
"type": "string"
|
||||
@@ -101,7 +112,9 @@
|
||||
"activity_score",
|
||||
"confidence_score",
|
||||
"explanation",
|
||||
"sources"
|
||||
"sources",
|
||||
"coordinate_precision",
|
||||
"coordinate_sources"
|
||||
],
|
||||
"title": "ActivityOut",
|
||||
"type": "object"
|
||||
@@ -1610,6 +1623,17 @@
|
||||
"title": "Catches 7D",
|
||||
"type": "integer"
|
||||
},
|
||||
"coordinate_precision": {
|
||||
"title": "Coordinate Precision",
|
||||
"type": "string"
|
||||
},
|
||||
"coordinate_sources": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Coordinate Sources",
|
||||
"type": "array"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1660,7 +1684,9 @@
|
||||
"catches_24h",
|
||||
"catches_3d",
|
||||
"catches_7d",
|
||||
"top_baits"
|
||||
"top_baits",
|
||||
"coordinate_precision",
|
||||
"coordinate_sources"
|
||||
],
|
||||
"title": "SpotOut",
|
||||
"type": "object"
|
||||
@@ -1700,6 +1726,28 @@
|
||||
},
|
||||
"WaterbodyOut": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Description"
|
||||
},
|
||||
"fish_species_count": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Fish Species Count"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"title": "Id",
|
||||
@@ -1713,6 +1761,107 @@
|
||||
"title": "Slug",
|
||||
"type": "string"
|
||||
},
|
||||
"source_aliases": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Aliases"
|
||||
},
|
||||
"source_checked_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Checked At"
|
||||
},
|
||||
"source_external_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source External Id"
|
||||
},
|
||||
"source_fish_species": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Fish Species"
|
||||
},
|
||||
"source_image_urls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Image Urls"
|
||||
},
|
||||
"source_point_urls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Point Urls"
|
||||
},
|
||||
"source_system": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source System"
|
||||
},
|
||||
"source_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Url"
|
||||
},
|
||||
"unlock_level": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1729,7 +1878,17 @@
|
||||
"id",
|
||||
"slug",
|
||||
"name_ru",
|
||||
"unlock_level"
|
||||
"unlock_level",
|
||||
"fish_species_count",
|
||||
"source_system",
|
||||
"source_external_id",
|
||||
"source_url",
|
||||
"description",
|
||||
"source_aliases",
|
||||
"source_fish_species",
|
||||
"source_image_urls",
|
||||
"source_point_urls",
|
||||
"source_checked_at"
|
||||
],
|
||||
"title": "WaterbodyOut",
|
||||
"type": "object"
|
||||
@@ -2651,6 +2810,162 @@
|
||||
"summary": "Admin Start Official Import"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/media/assets/{digest}": {
|
||||
"get": {
|
||||
"operationId": "admin_media_asset_api_v1_admin_media_assets__digest__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "digest",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Digest",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Media Asset"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/media/catalog": {
|
||||
"get": {
|
||||
"operationId": "admin_media_catalog_api_v1_admin_media_catalog_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "entity_type",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(fish|waterbody|tackle|reference)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "status",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(approved|upgrade_queued|upgrade_stored)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 50,
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Media Catalog"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/moderation-history": {
|
||||
"get": {
|
||||
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
|
||||
@@ -2781,6 +3096,58 @@
|
||||
"summary": "Admin Moderation History Export"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/source-status": {
|
||||
"get": {
|
||||
"description": "Return safe operational details needed by the owner dashboard.",
|
||||
"operationId": "admin_source_status_api_v1_admin_source_status_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Admin Source Status Api V1 Admin Source Status Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Source Status"
|
||||
}
|
||||
},
|
||||
"/api/v1/baits": {
|
||||
"get": {
|
||||
"operationId": "baits_api_v1_baits_get",
|
||||
|
||||
@@ -56,6 +56,17 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
assert payload["items"][0]["sources"] == ["manual-import"]
|
||||
|
||||
|
||||
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
|
||||
response = client.get("/api/v1/waterbodies")
|
||||
assert response.status_code == 200
|
||||
item = next(row for row in response.json() if row["slug"] == "test-lake")
|
||||
assert item["source_system"] is None
|
||||
assert item["source_external_id"] is None
|
||||
assert item["source_url"] is None
|
||||
assert item["description"] is None
|
||||
assert item["source_checked_at"] is None
|
||||
|
||||
|
||||
def test_invalid_period_is_rejected() -> None:
|
||||
assert client.get("/api/v1/activity?hours=13").status_code == 422
|
||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||
@@ -151,6 +162,24 @@ def test_public_source_status_hides_internal_details() -> None:
|
||||
assert all("error_summary" not in item and "source_url" not in item for item in response.json())
|
||||
|
||||
|
||||
def test_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() -> None:
|
||||
assert client.get("/api/v1/admin/source-status").status_code == 401
|
||||
response = client.get("/api/v1/admin/source-status", headers={"Authorization": "Bearer change-me-in-production"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()
|
||||
assert all({"status", "cooldown_seconds", "recent_failures_24h", "backoff_recommended"} <= set(item) for item in response.json())
|
||||
assert all("error_summary" not in item and "base_url" not in item for item in response.json())
|
||||
|
||||
|
||||
def test_admin_media_review_requires_auth() -> None:
|
||||
assert client.get("/api/v1/admin/media/catalog").status_code == 401
|
||||
response = client.get("/api/v1/admin/media/catalog?status=approved&limit=2", headers={"Authorization": "Bearer change-me-in-production"})
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) <= 2
|
||||
if response.json():
|
||||
assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0])
|
||||
|
||||
|
||||
def test_liveness_does_not_probe_dependencies() -> None:
|
||||
response = client.get("/health?token=must-not-be-logged")
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
Reference in New Issue
Block a user