From c216229c617db6ce6da0f891f183cdf9bf86fcf3 Mon Sep 17 00:00:00 2001 From: IK Date: Wed, 16 Sep 2026 20:09:41 +0700 Subject: [PATCH] fix: type admin API contracts --- apps/api/app/routers/admin.py | 34 ++--- apps/api/app/schemas.py | 35 +++++ apps/api/openapi.json | 264 +++++++++++++++++++++++++++++++++- apps/api/tests/test_api.py | 2 + apps/web/tests/smoke.spec.ts | 2 + 5 files changed, 316 insertions(+), 21 deletions(-) diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index 52d0d47..2905dd1 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -18,7 +18,7 @@ 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 +from ..schemas import AdminCatchReportOut, AdminMediaReviewOut, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate from ..storage import delete_screenshot, signed_screenshot_url from ..time_utils import aware @@ -30,14 +30,14 @@ 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") +@router.get("/api/v1/admin/media/catalog", response_model=list[AdminMediaReviewOut]) 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]: +) -> list[AdminMediaReviewOut]: return review_assets(entity_type, status)[offset:offset + limit] @@ -152,11 +152,11 @@ 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]]: +@router.get("/api/v1/admin/source-status", response_model=list[AdminSourceStatusOut]) +def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[AdminSourceStatusOut]: """Return safe operational details needed by the owner dashboard.""" now = datetime.now(timezone.utc) - result: list[dict[str, object]] = [] + result: list[AdminSourceStatusOut] = [] for source in db.scalars(select(DataSource).order_by(DataSource.name)): runs = list(db.scalars( select(CommunityImportRun) @@ -184,17 +184,17 @@ def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[dict 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, - }) + result.append(AdminSourceStatusOut( + 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 diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 35517e6..de92f78 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -287,3 +287,38 @@ class SourceStatusOut(BaseModel): last_started_at: datetime | None last_success_at: datetime | None observations: int + + +class AdminSourceStatusOut(BaseModel): + source_system: str + name: str + status: str + last_started_at: datetime | None + last_success_at: datetime | None + next_allowed_at: datetime | None + cooldown_seconds: int + recent_failures_24h: int + backoff_recommended: bool + + +class AdminMediaDerivativeOut(BaseModel): + role: str | None + format: str | None + width: int | None + height: int | None + + +class AdminMediaReviewOut(BaseModel): + id: str + status: str + entity_type: str | None + entity_key: str | None + label: str | None + width: int | None + height: int | None + content_type: str | None + image_url: str + source_system: str + source_url: str + duplicate_of: str | None + derivatives: list[AdminMediaDerivativeOut] diff --git a/apps/api/openapi.json b/apps/api/openapi.json index cdff32a..8314f5b 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -217,6 +217,187 @@ "title": "AdminCatchReportOut", "type": "object" }, + "AdminMediaDerivativeOut": { + "properties": { + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Height" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Role" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Width" + } + }, + "required": [ + "role", + "format", + "width", + "height" + ], + "title": "AdminMediaDerivativeOut", + "type": "object" + }, + "AdminMediaReviewOut": { + "properties": { + "content_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content Type" + }, + "derivatives": { + "items": { + "$ref": "#/components/schemas/AdminMediaDerivativeOut" + }, + "title": "Derivatives", + "type": "array" + }, + "duplicate_of": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Duplicate Of" + }, + "entity_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Entity Key" + }, + "entity_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Entity Type" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Height" + }, + "id": { + "title": "Id", + "type": "string" + }, + "image_url": { + "title": "Image Url", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "source_system": { + "title": "Source System", + "type": "string" + }, + "source_url": { + "title": "Source Url", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Width" + } + }, + "required": [ + "id", + "status", + "entity_type", + "entity_key", + "label", + "width", + "height", + "content_type", + "image_url", + "source_system", + "source_url", + "duplicate_of", + "derivatives" + ], + "title": "AdminMediaReviewOut", + "type": "object" + }, "AdminModerationHistoryOut": { "properties": { "action": { @@ -276,6 +457,83 @@ "title": "AdminModerationHistoryOut", "type": "object" }, + "AdminSourceStatusOut": { + "properties": { + "backoff_recommended": { + "title": "Backoff Recommended", + "type": "boolean" + }, + "cooldown_seconds": { + "title": "Cooldown Seconds", + "type": "integer" + }, + "last_started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Started At" + }, + "last_success_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "name": { + "title": "Name", + "type": "string" + }, + "next_allowed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Allowed At" + }, + "recent_failures_24h": { + "title": "Recent Failures 24H", + "type": "integer" + }, + "source_system": { + "title": "Source System", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "source_system", + "name", + "status", + "last_started_at", + "last_success_at", + "next_allowed_at", + "cooldown_seconds", + "recent_failures_24h", + "backoff_recommended" + ], + "title": "AdminSourceStatusOut", + "type": "object" + }, "BaitOut": { "properties": { "id": { @@ -2942,8 +3200,7 @@ "application/json": { "schema": { "items": { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/AdminMediaReviewOut" }, "title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get", "type": "array" @@ -3124,8 +3381,7 @@ "application/json": { "schema": { "items": { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/AdminSourceStatusOut" }, "title": "Response Admin Source Status Api V1 Admin Source Status Get", "type": "array" diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 94e629a..6fe1998 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -168,6 +168,7 @@ def test_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() -> 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({"source_system", "name", "last_started_at", "last_success_at", "next_allowed_at"} <= 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()) @@ -178,6 +179,7 @@ def test_admin_media_review_requires_auth() -> None: assert len(response.json()) <= 2 if response.json(): assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0]) + assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"]) def test_liveness_does_not_probe_dependencies() -> None: diff --git a/apps/web/tests/smoke.spec.ts b/apps/web/tests/smoke.spec.ts index 3ce3b65..1f18ba4 100644 --- a/apps/web/tests/smoke.spec.ts +++ b/apps/web/tests/smoke.spec.ts @@ -107,6 +107,8 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: " await page.getByRole("button", { name: "Открыть медиатеку" }).click(); await expect(page.locator(".media-library__card")).toContainText("Щука"); await expect(page.locator(".media-library__card")).toContainText("upgrade_stored"); + await expect(page.getByRole("navigation", { name: "Разделы админ-панели" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Медиатека" })).toHaveAttribute("aria-current", "page"); expect(mutations).toBe(0); const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth })); expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);