Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c216229c61 | ||
|
|
efd5f7b172 | ||
|
|
c14ae0250e |
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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]");
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user