Compare commits

..
3 Commits
Author SHA1 Message Date
ik c216229c61 fix: type admin API contracts
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:09:41 +07:00
ik efd5f7b172 test: cover admin media review smoke 2026-09-16 20:02:47 +07:00
ik c14ae0250e fix: avoid nested admin main landmarks 2026-09-16 20:00:59 +07:00
7 changed files with 342 additions and 26 deletions
+17 -17
View File
@@ -18,7 +18,7 @@ from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
from ..media_catalog import review_assets, review_file from ..media_catalog import review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache 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 ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware 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) 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( def admin_media_catalog(
_: Annotated[str, Depends(_admin)], _: Annotated[str, Depends(_admin)],
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"), entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"), status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
) -> list[dict]: ) -> list[AdminMediaReviewOut]:
return review_assets(entity_type, status)[offset:offset + limit] 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 raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
@router.get("/api/v1/admin/source-status") @router.get("/api/v1/admin/source-status", response_model=list[AdminSourceStatusOut])
def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[dict[str, object]]: def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[AdminSourceStatusOut]:
"""Return safe operational details needed by the owner dashboard.""" """Return safe operational details needed by the owner dashboard."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result: list[dict[str, object]] = [] result: list[AdminSourceStatusOut] = []
for source in db.scalars(select(DataSource).order_by(DataSource.name)): for source in db.scalars(select(DataSource).order_by(DataSource.name)):
runs = list(db.scalars( runs = list(db.scalars(
select(CommunityImportRun) select(CommunityImportRun)
@@ -184,17 +184,17 @@ def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[dict
state = "stale" state = "stale"
else: else:
state = "healthy" state = "healthy"
result.append({ result.append(AdminSourceStatusOut(
"source_system": source.key, source_system=source.key,
"name": source.name, name=source.name,
"status": state, status=state,
"last_started_at": latest.started_at if latest else None, last_started_at=latest.started_at if latest else None,
"last_success_at": success.started_at if success else None, last_success_at=success.started_at if success else None,
"next_allowed_at": next_allowed, next_allowed_at=next_allowed,
"cooldown_seconds": cooldown_seconds, cooldown_seconds=cooldown_seconds,
"recent_failures_24h": recent_failures, recent_failures_24h=recent_failures,
"backoff_recommended": recent_failures >= 5, backoff_recommended=recent_failures >= 5,
}) ))
return result return result
+35
View File
@@ -287,3 +287,38 @@ class SourceStatusOut(BaseModel):
last_started_at: datetime | None last_started_at: datetime | None
last_success_at: datetime | None last_success_at: datetime | None
observations: int 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]
+260 -4
View File
@@ -217,6 +217,187 @@
"title": "AdminCatchReportOut", "title": "AdminCatchReportOut",
"type": "object" "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": { "AdminModerationHistoryOut": {
"properties": { "properties": {
"action": { "action": {
@@ -276,6 +457,83 @@
"title": "AdminModerationHistoryOut", "title": "AdminModerationHistoryOut",
"type": "object" "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": { "BaitOut": {
"properties": { "properties": {
"id": { "id": {
@@ -2942,8 +3200,7 @@
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "items": {
"additionalProperties": true, "$ref": "#/components/schemas/AdminMediaReviewOut"
"type": "object"
}, },
"title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get", "title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get",
"type": "array" "type": "array"
@@ -3124,8 +3381,7 @@
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "items": {
"additionalProperties": true, "$ref": "#/components/schemas/AdminSourceStatusOut"
"type": "object"
}, },
"title": "Response Admin Source Status Api V1 Admin Source Status Get", "title": "Response Admin Source Status Api V1 Admin Source Status Get",
"type": "array" "type": "array"
+2
View File
@@ -168,6 +168,7 @@ def test_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() ->
assert response.status_code == 200 assert response.status_code == 200
assert response.json() assert response.json()
assert all({"status", "cooldown_seconds", "recent_failures_24h", "backoff_recommended"} <= set(item) for item in 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()) 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 assert len(response.json()) <= 2
if response.json(): if response.json():
assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0]) 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: def test_liveness_does_not_probe_dependencies() -> None:
+2 -2
View File
@@ -5,7 +5,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
--- ---
<Layout title="Административная панель — RF4 Spotter"> <Layout title="Административная панель — RF4 Spotter">
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Центр управления</span><h1>Панель<br/><em>модератора</em></h1></div><p>Очереди, состояние источников и последние импорты в одном безопасном обзоре.</p></section> <section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Центр управления</span><h1>Панель<br/><em>модератора</em></h1></div><p>Очереди, состояние источников и последние импорты в одном безопасном обзоре.</p></section>
<main class="admin-dashboard" data-api-url={apiUrl}> <section class="admin-dashboard" data-api-url={apiUrl}>
<AdminNav /> <AdminNav />
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть панель</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть панель</button></form>
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p> <p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p>
@@ -13,7 +13,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<div class="notice error" data-admin-error role="alert" hidden></div> <div class="notice error" data-admin-error role="alert" hidden></div>
<div class="notice success" data-admin-status role="status" hidden></div> <div class="notice success" data-admin-status role="status" hidden></div>
<section class="admin-dashboard-content" aria-live="polite"></section> <section class="admin-dashboard-content" aria-live="polite"></section>
</main> </section>
<script> <script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors"; import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
const root = document.querySelector<HTMLElement>(".admin-dashboard"); const root = document.querySelector<HTMLElement>(".admin-dashboard");
+3 -3
View File
@@ -5,7 +5,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
--- ---
<Layout title="Проверка медиа — RF4 Spotter" noindex> <Layout title="Проверка медиа — RF4 Spotter" noindex>
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Media review</span><h1>Проверка<br/><em>медиа</em></h1></div><p>Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.</p></section> <section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Media review</span><h1>Проверка<br/><em>медиа</em></h1></div><p>Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.</p></section>
<main class="moderation-app" data-api-url={apiUrl}> <section class="moderation-app" data-api-url={apiUrl}>
<AdminNav /> <AdminNav />
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
<p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p> <p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p>
@@ -13,10 +13,10 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form> <form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
<nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav> <nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
</main> </section>
<script> <script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors"; import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
const root = document.querySelector<HTMLElement>("main[data-api-url]"); const root = document.querySelector<HTMLElement>("[data-api-url]");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters"); const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
const list = document.querySelector<HTMLElement>("[data-media-list]"); const list = document.querySelector<HTMLElement>("[data-media-list]");
+23
View File
@@ -76,6 +76,7 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
const path = new URL(request.url()).pathname; const path = new URL(request.url()).pathname;
if (path.endsWith("/fishes")) return route.fulfill({ json: [{ slug: "pike", name_ru: "Щука" }] }); if (path.endsWith("/fishes")) return route.fulfill({ json: [{ slug: "pike", name_ru: "Щука" }] });
if (path.endsWith("/waterbodies")) return route.fulfill({ json: [{ slug: "kuori", name_ru: "Куори" }] }); if (path.endsWith("/waterbodies")) return route.fulfill({ json: [{ slug: "kuori", name_ru: "Куори" }] });
if (path.endsWith("/source-status")) return route.fulfill({ json: [{ source_system: "rf4db", name: "RF4DB", status: "healthy", last_started_at: null, last_success_at: null, observations: 1 }] });
if (path.endsWith("/admin/external-observations")) return route.fulfill({ json: [{ id: "10000000-0000-0000-0000-000000000001", source_system: "rf4db", source_external_id: "fixture-1", source_url: "https://rf4db.com/fixture-1", fish_name: "Pike", waterbody_name: "Kuori", x: 72, y: 84, weight_g: null, status: "staged", fish_slug: null, waterbody_slug: null, review_note: null }] }); if (path.endsWith("/admin/external-observations")) return route.fulfill({ json: [{ id: "10000000-0000-0000-0000-000000000001", source_system: "rf4db", source_external_id: "fixture-1", source_url: "https://rf4db.com/fixture-1", fish_name: "Pike", waterbody_name: "Kuori", x: 72, y: 84, weight_g: null, status: "staged", fish_slug: null, waterbody_slug: null, review_note: null }] });
return route.fulfill({ status: 404 }); return route.fulfill({ status: 404 });
}); });
@@ -90,6 +91,28 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth })); const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width); expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
}); });
test(`admin media review is read-only on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
let mutations = 0;
await page.route("**/api/v1/**", async route => {
const request = route.request();
if (request.method() !== "GET") mutations += 1;
const path = new URL(request.url()).pathname;
if (path.endsWith("/admin/media/catalog")) return route.fulfill({ json: [{ id: "a".repeat(64), status: "upgrade_stored", entity_type: "fish", entity_key: "fish:pike", label: "Щука", width: 1024, height: 1024, content_type: "image/webp", image_url: "/api/v1/admin/media/assets/" + "a".repeat(64), source_system: "rf4db", source_url: "https://rf4db.com/fish/pike", derivatives: [{ role: "card", format: "webp", width: 256, height: 256 }] }] });
return route.fulfill({ status: 404 });
});
await page.goto("/admin/media");
await page.getByLabel("Административный токен").fill("test-token-not-sent");
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);
});
} }
test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => { test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => {