Compare commits
6
Commits
4c75db1f74
...
2e34fb20b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e34fb20b5 | ||
|
|
74ff49062f | ||
|
|
4c7051bc97 | ||
|
|
d9f58fb90e | ||
|
|
eb4d6ccdb0 | ||
|
|
9bcb019d3a |
@@ -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"}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
const path = Astro.url.pathname;
|
||||
const links = [
|
||||
["/admin", "Обзор"],
|
||||
["/admin/moderation", "Уловы"],
|
||||
["/admin/external-sources", "Источники"],
|
||||
["/admin/media", "Медиа"],
|
||||
] as const;
|
||||
---
|
||||
<nav class="admin-nav" aria-label="Разделы админ-панели">
|
||||
{links.map(([href, label]) => <a class={path === href ? "active" : undefined} aria-current={path === href ? "page" : undefined} href={href}>{label}</a>)}
|
||||
</nav>
|
||||
@@ -11,6 +11,7 @@ import "../styles/tackle-glyph.css";
|
||||
import "../styles/empty-states.css";
|
||||
import "../styles/alpha-banner.css";
|
||||
import "../styles/signal-pagination.css";
|
||||
import "../styles/pagination.css";
|
||||
import "../styles/dashboard-polish.css";
|
||||
import "../styles/loading-states.css";
|
||||
import "../styles/theme.css";
|
||||
@@ -31,7 +32,12 @@ const theme = storedTheme === "light" || storedTheme === "dark" ? storedTheme :
|
||||
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
|
||||
const canonical = new URL(path, siteUrl).toString();
|
||||
const socialImage = new URL(image, siteUrl).toString();
|
||||
const preventIndexing = noindex || path.startsWith("/admin/");
|
||||
const isAdminPath = path === "/admin" || path.startsWith("/admin/");
|
||||
const preventIndexing = noindex || isAdminPath;
|
||||
if (isAdminPath) {
|
||||
Astro.response.headers.set("Cache-Control", "private, no-store");
|
||||
Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
}
|
||||
const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
|
||||
// A08: Skip structuredData on error pages (explicit errorPage prop)
|
||||
// Don't infer error from noindex alone — main page can have noindex on 422
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function adminErrorMessage(status: number, fallback: string): string {
|
||||
if (status === 401) return "Неверный или истёкший административный токен.";
|
||||
if (status === 409) return "Операция конфликтует с изменением в другой вкладке.";
|
||||
if (status === 429) return "Слишком много попыток. Повторите позже.";
|
||||
if (status >= 500) return "Сервис временно недоступен. Проверьте состояние и повторите позже.";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function adminEndsSession(status: number): boolean {
|
||||
return status === 401 || status === 429;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<Layout title="Внешние источники — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Staging</span><h1>Внешние<br/><em>наблюдения</em></h1></div><p>Сопоставьте названия с каноническим каталогом. Опубликовать можно только запись с координатами и весом.</p></section>
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<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>
|
||||
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
@@ -19,11 +21,12 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<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-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button>
|
||||
</nav>
|
||||
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
|
||||
</section>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const list = document.querySelector<HTMLElement>("[data-external-list]");
|
||||
@@ -32,6 +35,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
@@ -48,7 +52,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
||||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, `Запрос завершился ошибкой ${response.status}.`)); } if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||||
async function loadQueue() {
|
||||
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||
if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); }
|
||||
@@ -89,6 +93,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
}
|
||||
previous?.addEventListener("click", () => changePage(-50));
|
||||
next?.addEventListener("click", () => changePage(50));
|
||||
refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } });
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<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>
|
||||
<main class="admin-dashboard" data-api-url={apiUrl}>
|
||||
<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>
|
||||
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div>
|
||||
<div class="notice success" data-admin-status role="status" hidden></div>
|
||||
<section class="admin-dashboard-content" aria-live="polite"></section>
|
||||
</main>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>(".admin-dashboard");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
|
||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
let token = "";
|
||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
|
||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); }
|
||||
async function publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
|
||||
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
|
||||
async function loadDashboard() {
|
||||
if (!content) return;
|
||||
error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
|
||||
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
|
||||
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
|
||||
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
|
||||
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
||||
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join("");
|
||||
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; const success = source.last_success_at ? ` · успех ${new Date(String(source.last_success_at)).toLocaleString("ru-RU")}` : " · успешных запусков нет"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail + success)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
|
||||
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; }).join("");
|
||||
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
|
||||
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"};
|
||||
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
|
||||
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
||||
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
content?.addEventListener("click", async event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export]"); if (!button) return;
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
||||
button.disabled = true;
|
||||
try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) throw new Error(); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); }
|
||||
catch { fail("Не удалось выгрузить журнал решений."); }
|
||||
try { if (button.hasAttribute("data-refresh")) { await loadDashboard(); succeed("Данные обновлены."); } else if (button.hasAttribute("data-official-import")) { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт.")); } keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); } else { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось выгрузить журнал решений.")); } const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } }
|
||||
catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<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>
|
||||
<main class="moderation-app" data-api-url={apiUrl}>
|
||||
<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>
|
||||
<p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
<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>
|
||||
<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>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>("main[data-api-url]");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||||
const list = document.querySelector<HTMLElement>("[data-media-list]");
|
||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.href) : ""; } catch { return ""; } };
|
||||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||
const endSession = (message?: string) => { token = ""; if (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||
const keepSession = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
const loading = () => '<div class="loading-card" aria-hidden="true"></div><span class="sr-only">Загружаем медиа</span>';
|
||||
async function load() {
|
||||
if (!root || !list || !filters) return;
|
||||
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
||||
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
|
||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
|
||||
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
|
||||
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
||||
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
|
||||
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}</article>`; }).join("");
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||||
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
|
||||
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
</script>
|
||||
</Layout>
|
||||
@@ -1,25 +1,35 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<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="moderation-app" data-api-url={apiUrl}>
|
||||
<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>
|
||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></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="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
<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>
|
||||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||||
</section>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
let token = "";
|
||||
let offset = 0;
|
||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
||||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||||
@@ -32,16 +42,25 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
async function loadQueue() {
|
||||
if (!root || !list) return;
|
||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
|
||||
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
|
||||
if (!response.ok) throw new Error("Не удалось загрузить очередь.");
|
||||
const reports: Record<string, unknown>[] = await response.json();
|
||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить очередь.")); }
|
||||
const rows: Record<string, unknown>[] = await response.json();
|
||||
keepSession();
|
||||
setLoading(false);
|
||||
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
||||
const reports = rows.slice(0, 50);
|
||||
if (pages) pages.hidden = !reports.length;
|
||||
if (previous) previous.disabled = offset === 0;
|
||||
if (next) next.disabled = rows.length <= 50;
|
||||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
||||
list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}" data-version="${esc(report.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`; }).join("");
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
async function changePage(delta: number) { const oldOffset = offset; offset = Math.max(0, offset + delta); if (previous) previous.disabled = true; if (next) next.disabled = true; try { await loadQueue(); } catch { offset = oldOffset; setLoading(false); fail("Не удалось загрузить страницу очереди."); } }
|
||||
previous?.addEventListener("click", () => changePage(-50));
|
||||
next?.addEventListener("click", () => changePage(50));
|
||||
refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } });
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
list?.addEventListener("click", async event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
||||
@@ -49,7 +68,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
||||
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
||||
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
|
||||
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||||
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось сохранить решение.")); } keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||||
});
|
||||
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
|
||||
</script>
|
||||
|
||||
@@ -59,6 +59,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
/* One semantic treatment for empty, unavailable and invalid public states. */
|
||||
.state.unavailable-state{border-style:solid;border-color:color-mix(in srgb,var(--warning) 35%,var(--border));background:color-mix(in srgb,var(--warning-soft) 35%,var(--surface))}.state.error-state{background:color-mix(in srgb,var(--danger-soft) 28%,var(--surface))}.state>a{margin-top:8px;font-weight:750;text-underline-offset:4px}
|
||||
|
||||
/* Admin navigation keeps all operational surfaces reachable by keyboard. */
|
||||
.admin-nav{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px}.admin-nav a{padding:9px 13px;border:1px solid var(--line);border-radius:999px;color:var(--text-muted);font-size:12px;text-decoration:none}.admin-nav a:hover,.admin-nav a.active{border-color:var(--focus);color:var(--deep);background:var(--lime)}
|
||||
|
||||
/* Action hierarchy stays semantic across public and admin surfaces. */
|
||||
[data-action]{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:0 18px;border:1px solid transparent;border-radius:10px;font-weight:750;text-decoration:none;transition:background-color var(--motion-fast) var(--ease-out),border-color var(--motion-fast) var(--ease-out),color var(--motion-fast) var(--ease-out),transform var(--motion-fast) var(--ease-out)}[data-action]:hover:not(:disabled){transform:translateY(-1px)}[data-action]:active:not(:disabled){transform:translateY(0)}[data-action="primary"]{background:var(--lime);color:var(--deep)}[data-action="secondary"]{border-color:var(--border);background:var(--surface-soft);color:var(--text-muted)}[data-action="inverse"]{border-color:#ffffff38;background:var(--deep);color:var(--white)}[data-action="danger"]{background:var(--danger-soft);color:var(--danger)}[data-action="quiet-danger"]{border-color:color-mix(in srgb,var(--danger) 35%,transparent);background:transparent;color:var(--danger)}[data-action="quiet"]{background:transparent;color:#d5dfdc;text-decoration:underline;text-underline-offset:4px}[data-action]:disabled{opacity:.55;cursor:not-allowed}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { activityLevel, plural } from "../../src/lib/presentation.ts";
|
||||
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
||||
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
||||
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
||||
import { pageHref, pageWindow } from "../../src/lib/pagination.ts";
|
||||
import { adminEndsSession, adminErrorMessage } from "../../src/lib/admin-errors.ts";
|
||||
|
||||
test("activity levels share one complete 0-100 scale", () => {
|
||||
assert.deepEqual(
|
||||
@@ -46,3 +48,31 @@ test("waterbody fingerprints are stable, bounded and entity-specific", () => {
|
||||
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
|
||||
assert.match(kuori.code, /^[0-9A-Z]{2}$/);
|
||||
});
|
||||
|
||||
test("45 records are reachable across three stable pages", () => {
|
||||
const pages = [pageWindow(45, 20, 0), pageWindow(45, 20, 20), pageWindow(45, 20, 40)];
|
||||
assert.deepEqual(pages.map(page => [page.start, page.end]), [[1, 20], [21, 40], [41, 45]]);
|
||||
assert.deepEqual(pages.map(page => [page.previousOffset, page.nextOffset]), [[null, 20], [0, 40], [20, null]]);
|
||||
assert.deepEqual(pages.map(page => page.page), [1, 2, 3]);
|
||||
const reached = pages.flatMap(page => Array.from({ length: page.end - page.start + 1 }, (_, index) => page.start + index));
|
||||
assert.deepEqual(reached, Array.from({ length: 45 }, (_, index) => index + 1));
|
||||
});
|
||||
|
||||
test("pagination links preserve filters and replace offset", () => {
|
||||
const params = new URLSearchParams("fish=pike&waterbody=kuori&offset=20&offset=999");
|
||||
const next = pageHref("/records", params, 40);
|
||||
const first = pageHref("/records", params, 0, "#results");
|
||||
assert.equal(next, "/records?fish=pike&waterbody=kuori&offset=40");
|
||||
assert.equal(first, "/records?fish=pike&waterbody=kuori#results");
|
||||
});
|
||||
|
||||
test("admin errors keep auth and operational responses consistent", () => {
|
||||
assert.equal(adminErrorMessage(401, "fallback"), "Неверный или истёкший административный токен.");
|
||||
assert.equal(adminErrorMessage(409, "fallback"), "Операция конфликтует с изменением в другой вкладке.");
|
||||
assert.equal(adminErrorMessage(429, "fallback"), "Слишком много попыток. Повторите позже.");
|
||||
assert.equal(adminErrorMessage(503, "fallback"), "Сервис временно недоступен. Проверьте состояние и повторите позже.");
|
||||
assert.equal(adminErrorMessage(422, "fallback"), "fallback");
|
||||
assert.equal(adminEndsSession(401), true);
|
||||
assert.equal(adminEndsSession(429), true);
|
||||
assert.equal(adminEndsSession(409), false);
|
||||
});
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
@manifest path /site.webmanifest
|
||||
header @manifest Cache-Control "public, max-age=3600"
|
||||
|
||||
@admin path /admin/*
|
||||
@admin path /admin /admin/*
|
||||
handle @admin {
|
||||
basic_auth {
|
||||
{$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH}
|
||||
|
||||
@@ -27,8 +27,14 @@ def probe(path, expected, authorization=None, method="GET", data=None):
|
||||
if path.startswith("/api/v1/admin/") and expected == 200:
|
||||
assert "no-store" in response.headers.get("Cache-Control", "")
|
||||
assert isinstance(json.load(response), dict)
|
||||
if path == "/admin" or path.startswith("/admin/"):
|
||||
if expected == 200:
|
||||
assert "no-store" in response.headers.get("Cache-Control", "")
|
||||
assert "noindex" in response.headers.get("X-Robots-Tag", "")
|
||||
|
||||
|
||||
probe("/admin", 401)
|
||||
probe("/admin", 200, basic)
|
||||
probe("/admin/moderation", 401)
|
||||
probe("/admin/moderation", 200, basic)
|
||||
probe("/admin/moderation", 401, "Bearer " + token)
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
# Полный аудит проекта RF4 Spotter — 10 сентября 2026
|
||||
|
||||
База: `13e04e6`. Проверены: архитектура, backend, frontend, security, performance, testing, deployment, documentation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура и высокоуровневый обзор
|
||||
|
||||
### Стек
|
||||
| Слой | Технология | Статус |
|
||||
|------|-----------|--------|
|
||||
| **Proxy** | Caddy 2.10 | Production-ready, TLS завершение, Basic Auth на admin |
|
||||
| **Frontend** | Astro 7 SSR (Node) | Сборка 0 errors, адаптивный дизайн |
|
||||
| **Backend** | FastAPI + SQLAlchemy 2 + PostgreSQL 17 | 130 тестов, 1 skipped |
|
||||
| **Хранилище** | PostgreSQL 17 + MinIO/S3 | Volumes, backup/restore |
|
||||
| **CI** | Gitea Actions | Python, Astro, Compose E2E, pip-audit |
|
||||
| **Scheduler** | Official import + Community scheduler | Опциональный профиль |
|
||||
|
||||
### Оценка архитектуры: ✅ Хорошо (8/10)
|
||||
- Чёткое разделение: Caddy → Astro SSR → FastAPI → Postgres/MinIO
|
||||
- Нет точки отказа в виде единого контейнера
|
||||
- Profiles в docker-compose для scheduler/importer
|
||||
- Production compose отдельно от dev
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend (FastAPI) — детальный разбор
|
||||
|
||||
### 2.1 `main.py` (~1100 строк) — 🔴 КРИТИЧЕСКАЯ ПРОБЛЕМА
|
||||
|
||||
**Проблемы:**
|
||||
- Все endpoints в одном файле — нарушение SRP
|
||||
- `create_catch_report` — 50+ строк бизнес-логики inline
|
||||
- `_check_rate_limit` и `_is_trusted_proxy` — утилиты должны быть в отдельном модуле
|
||||
- `_admin`, `_spot_or_404`, `_report_source` — общие функции смешаны с endpoints
|
||||
- Нет APIRouter — всё на `app.get/post`
|
||||
|
||||
**Рекомендация:** Разделить на `routes/` с `APIRouter`:
|
||||
```
|
||||
apps/api/app/routes/
|
||||
├── activity.py # /api/v1/activity, /spots/*
|
||||
├── submissions.py # catch-reports, screenshots
|
||||
├── admin.py # /admin/*
|
||||
├── community.py # observations, source-status
|
||||
├── records.py # /records, /imports
|
||||
└── catalog.py # fishes, waterbodies, baits
|
||||
```
|
||||
|
||||
### 2.2 `activity.py` — ⚠️ Есть риски при scale
|
||||
|
||||
**Плюсы:**
|
||||
- Детерминированная формула, покрыта тестами
|
||||
- Cap confidence для 1-2 игроков (A07)
|
||||
- Exponential decay freshness (18h half-life)
|
||||
|
||||
**Риски:**
|
||||
- Нет индекса на `(spot_id, fish_id)` — `groups` агрегирует в памяти
|
||||
- При 10k+ approved reports за 72h — полный load + sort в памяти
|
||||
- `total = len(rows)` — нет `COUNT` запроса, полная выборка для pagination
|
||||
|
||||
**Рекомендация:**
|
||||
1. Добавить composite index: `ix_catch_report_spot_fish_approved`
|
||||
2. Для activity endpoint — materialized view или pre-aggregation table
|
||||
3. Для total — отдельный `COUNT` query вместо `len(rows)`
|
||||
|
||||
### 2.3 `models.py` — ✅ Хорошо структурирован
|
||||
|
||||
**Плюсы:**
|
||||
- Чёткие enum для SourceType, ModerationStatus, ImportStatus
|
||||
- Unique constraints на slug, external_id
|
||||
- JSON поля для raw_payload, changes, provenance
|
||||
- Foreign keys с relationships
|
||||
|
||||
**Проблемы:**
|
||||
- `source_external_id` unique=True — конфликт при повторном импорте разных sources с одинаковым ID
|
||||
- `SubmissionAttempt` — нет cleanup cron, растёт бесконечно (хотя есть cleanup в `_check_rate_limit`)
|
||||
- `ImportRecordEvent` — нет индекса на `(catch_report_id, created_at)` для history query
|
||||
|
||||
### 2.4 `importer.py` — ✅ Хорошо, но есть edge cases
|
||||
|
||||
**Плюсы:**
|
||||
- Advisory lock для cross-process mutex
|
||||
- ETag/Last-Modified conditional requests
|
||||
- Idempotent по external_id SHA-256
|
||||
- A12: meaningful changes/provenance в ImportRecordEvent
|
||||
|
||||
**Риски:**
|
||||
- `html: str | None = None` — тестовый параметр в production signature
|
||||
- `_unique_slug` — N+1 query loop при коллизиях
|
||||
- Нет retry policy для HTTP 5xx с exponential backoff
|
||||
|
||||
### 2.5 `retention.py` — ✅ Хорошо
|
||||
|
||||
**Плюсы:**
|
||||
- Dry-run mode по умолчанию
|
||||
- Configurable policy через RetentionPolicy
|
||||
- Handles screenshots, payloads, moderation events
|
||||
- Автоматический rejection pending reports по TTL
|
||||
|
||||
**Проблемы:**
|
||||
- `delete_object` callback — должен быть injected, не passed per-call
|
||||
- Нет bulk delete — loop по reports
|
||||
|
||||
### 2.6 `scheduler.py` vs `community_scheduler.py` — ⚠️ Дублирование
|
||||
|
||||
Оба используют pattern:
|
||||
```python
|
||||
while True:
|
||||
try:
|
||||
run_due_import()
|
||||
except Exception:
|
||||
logger.exception(...)
|
||||
time.sleep(interval)
|
||||
```
|
||||
|
||||
**Рекомендация:** Общий base class или use APScheduler/celery-beat
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend (Astro) — детальный разбор
|
||||
|
||||
### 3.1 `index.astro` — ⚠️ Сложная логика в template
|
||||
|
||||
**Проблемы:**
|
||||
- 50+ строк бизнес-логики в `.astro` файле
|
||||
- Нет error boundary — catch all делает 503
|
||||
- `offset + items.length < totalItems` — работает, но fragile при partial last page
|
||||
- Нет optimistic UI / loading state
|
||||
|
||||
**Рекомендация:**
|
||||
1. Вынести fetch logic в `src/lib/fetchers.ts`
|
||||
2. Добавить `isLoading` state и skeleton UI
|
||||
3. Error boundary per-section (filters, activity, signals)
|
||||
|
||||
### 3.2 `Layout.astro` — ✅ Хорошо
|
||||
|
||||
**Плюсы:**
|
||||
- A08: errorPage prop для skip structuredData
|
||||
- Canonical, OG, Twitter Card, JSON-LD
|
||||
- robots/noindex для admin/error pages
|
||||
- Skip link для accessibility
|
||||
|
||||
**Минусы:**
|
||||
- 12 CSS imports — можно aggregate в `global.css`
|
||||
- `replaceAll("<", "\\u003c")` — hack для JSON escaping
|
||||
|
||||
### 3.3 `global.css` (~1200 строк) — ⚠️ Все стили вместе
|
||||
|
||||
**Проблемы:**
|
||||
- Все компоненты в одном файле — сложно navigate
|
||||
- Нет CSS modules или scoped styles
|
||||
|
||||
**Рекомендация:**
|
||||
- Разделить на `components/`, `pages/`, `utilities/`
|
||||
- Astro scoped styles для компонентов
|
||||
|
||||
---
|
||||
|
||||
## 4. Security — ✅ В целом хорошо, есть улучшения
|
||||
|
||||
### ✅ Реализовано
|
||||
| Мера | Статус |
|
||||
|------|--------|
|
||||
| Rate limiting (PostgreSQL advisory lock) | ✅ |
|
||||
| Idempotency-Key header | ✅ (A05) |
|
||||
| HMAC client_hash вместо IP | ✅ |
|
||||
| Screenshot validation (MIME, size, EXIF removal) | ✅ |
|
||||
| Admin Bearer token + Caddy Basic Auth | ✅ |
|
||||
| Security headers (HSTS, X-Frame-Deny, etc.) | ✅ |
|
||||
| Production settings validation | ✅ |
|
||||
| pip-audit в CI | ✅ |
|
||||
|
||||
### ⚠️ Улучшения
|
||||
| Проблема | Приоритет |
|
||||
|----------|----------|
|
||||
| **CORS origins** — dev defaults `localhost:4321`, production требует HTTPS | Medium |
|
||||
| **ADMIN_TOKEN** — default `change-me-in-production` в compose.yaml | High (docs) |
|
||||
| **X-Content-Type-Options** — только на admin/catch-reports, не на всех | Low |
|
||||
| **Content-Security-Policy** — отсутствует | Medium |
|
||||
| **Rate limit** — 5 requests per 10 min, нет per-endpoint limits | Low |
|
||||
| **Screenshot upload** — нет CSRF protection (POST без token) | Medium |
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance — ⚠️ Есть узкие места
|
||||
|
||||
### 5.1 Database
|
||||
|
||||
**Проблемы:**
|
||||
- `activity` endpoint: full table scan + in-memory sort
|
||||
- `spot_detail`: `count_since` — Python loop по reports
|
||||
- `public_spot_pages`: 3-way JOIN + DISTINCT + offset pagination
|
||||
|
||||
**Рекомендации — SQL индексы:**
|
||||
```sql
|
||||
-- Добавить индексы
|
||||
CREATE INDEX ix_catch_report_spot_fish_approved
|
||||
ON catch_report(spot_id, fish_id, moderation_status, reported_at);
|
||||
|
||||
CREATE INDEX ix_catch_report_waterbody_approved_reported
|
||||
ON catch_report(waterbody_id, moderation_status, reported_at);
|
||||
|
||||
CREATE INDEX ix_catch_report_official_records
|
||||
ON catch_report(source_type, caught_at, weight_g)
|
||||
WHERE source_type = 'official_record';
|
||||
```
|
||||
|
||||
### 5.2 Caching
|
||||
|
||||
**Текущее:**
|
||||
- In-memory cache: 20s TTL, 128 keys, process-local
|
||||
- `public_cache.invalidate()` на moderation/publish
|
||||
|
||||
**Проблемы:**
|
||||
- Multi-process: каждый API worker имеет свой cache
|
||||
- Нет cache warming на restart
|
||||
- Community observations не кэшируются
|
||||
|
||||
**Рекомендация:** Redis или shared memory (SHM) для multi-process
|
||||
|
||||
### 5.3 Frontend
|
||||
|
||||
**Плюсы:**
|
||||
- Astro SSR — no client JS for initial render
|
||||
- `fetchpriority="high"` для hero image
|
||||
- Reduced motion support
|
||||
|
||||
**Улучшения:**
|
||||
- Нет lazy loading для нижеfold images
|
||||
- Нет prefetch для `/spots/[id]`
|
||||
- SignalFeed — нет intersection observer для "load more"
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing — ✅ 130 passed, 1 skipped
|
||||
|
||||
### ✅ Хорошее покрытие
|
||||
- API endpoints (test_api.py)
|
||||
- Import idempotency (test_importer.py)
|
||||
- Community CLI state/validation (test_community_cli.py)
|
||||
- Readiness aggregation (test_readiness.py)
|
||||
- Rate limit with Docker chain (test_rate_limit.py)
|
||||
|
||||
### ⚠️ Пропущенные сценарии
|
||||
| Тест | Приоритет |
|
||||
|------|----------|
|
||||
| **Retention cleanup** — нет интеграционного теста | High |
|
||||
| **Screenshot validation** — edge cases (PNG vs JPEG magic bytes) | Medium |
|
||||
| **Community scheduler retry backoff** — 30min → 24h | Medium |
|
||||
| **Activity calculation** — edge cases (0 players, all same player) | Low |
|
||||
| **Bootstrap E2E** — test-production-bootstrap.sh не в CI | Medium |
|
||||
| **Astro accessibility** — audit:axe есть, но нет automated checks | Low |
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment & Operations
|
||||
|
||||
### ✅ Хорошее
|
||||
- Compose production с healthchecks
|
||||
- Backup/restore scripts
|
||||
- Monitoring docs (disk, TLS, readiness)
|
||||
- Data retention policy
|
||||
- Preflight checks before deploy
|
||||
|
||||
### ⚠️ Улучшения
|
||||
| Проблема | Решение |
|
||||
|----------|---------|
|
||||
| **Нет blue-green/canary** | Добавить rolling update в deploy script |
|
||||
| **Database migration rollback** | Нет test-rollback в bootstrap |
|
||||
| **Log rotation** — Docker json-file, 10MB × 5 | OK, но нет centralized logging |
|
||||
| **No distributed tracing** | Добавить OpenTelemetry для request_id propagation |
|
||||
| **No metrics export** | Нет Prometheus metrics (request count, latency, error rate) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
### ✅ Хорошее
|
||||
- README.md — comprehensive (150+ строк)
|
||||
- docs/ — recovery plans, audit reports, roadmap
|
||||
- deploy/README.md — production steps
|
||||
- data-policy.md, retention, monitoring
|
||||
|
||||
### ⚠️ Улучшения
|
||||
| Проблема | Решение |
|
||||
|----------|---------|
|
||||
| **API documentation** — FastAPI auto-docs, но нет OpenAPI spec file | Добавить `openapi.json` в repo |
|
||||
| **Data model ER diagram** | Добавить визуальную схему |
|
||||
| **Architecture decision records (ADR)** | Для key decisions (Astro vs Next, in-memory cache, etc.) |
|
||||
| **Runbook for incidents** | Что делать при Postgres full, MinIO down, etc. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Код-стиль и maintainability
|
||||
|
||||
### ✅ Хорошее
|
||||
- Type hints everywhere
|
||||
- `from __future__ import annotations`
|
||||
- Pydantic v2 models
|
||||
- Alembic migrations
|
||||
|
||||
### ⚠️ Улучшения
|
||||
| Проблема | Пример |
|
||||
|----------|--------|
|
||||
| **Long lines** | `create_catch_report` — 70+ char lines |
|
||||
| **Magic numbers** | `55 * min(1, weighted / 12)` — вынести константы |
|
||||
| **Inline SQL** | `text("SELECT pg_try_advisory_lock(:key)")` — вынести в constants |
|
||||
| **Duplicate URL patterns** | `"/api/v1/admin/"` repeated in middleware and endpoints |
|
||||
|
||||
---
|
||||
|
||||
## 10. Приоритизированный список улучшений
|
||||
|
||||
### 🔴 High Priority (блокирующие/рисковые)
|
||||
|
||||
1. **Разделить main.py на routers** — SRP, testability, reviewability
|
||||
2. **Добавить database indexes** — `spot_id + fish_id + moderation_status`, `source_type + official_records`
|
||||
3. **Retention integration test** — verify cleanup works end-to-end
|
||||
4. **Screenshot CSRF protection** — form submission без CSRF token
|
||||
5. **Bootstrap E2E в CI** — `test-production-bootstrap.sh` должен run на push
|
||||
|
||||
### 🟡 Medium Priority (улучшения)
|
||||
|
||||
6. **Shared cache (Redis)** — вместо in-memory для multi-process
|
||||
7. **Content-Security-Policy header** — добавить base-uri, script-src
|
||||
8. **Error boundaries в Astro** — per-section error handling
|
||||
9. **Materialized view для activity** — pre-aggregation вместо in-memory sort
|
||||
10. **OpenAPI spec export** — `openapi.json` в repo + swagger UI
|
||||
11. **Metrics export (Prometheus)** — request latency, error rate, queue depth
|
||||
12. **Screenshot validation test** — magic bytes, MIME mismatch
|
||||
|
||||
### 🟢 Low Priority (косметика/технический долг)
|
||||
|
||||
13. **CSS modularization** — split global.css into components
|
||||
14. **Extract constants** — 55, 25, 20, 12, 6, 3 → named constants
|
||||
15. **Scheduler base class** — DRY для official + community scheduler
|
||||
16. **Prefetch links** — `/spots/[id]` prefetch на hover
|
||||
17. **ADR documentation** — record architecture decisions
|
||||
18. **Incident runbook** — playbooks для common failures
|
||||
|
||||
---
|
||||
|
||||
## Итоговая оценка
|
||||
|
||||
| Категория | Оценка | Комментарий |
|
||||
|-----------|--------|-------------|
|
||||
| **Архитектура** | 8/10 | Чистое разделение, но main.py — god object |
|
||||
| **Безопасность** | 7.5/10 | Хорошие основы, нужно CSP + CSRF |
|
||||
| **Производительность** | 6.5/10 | Indexes + caching critical для scale |
|
||||
| **Тестирование** | 8/10 | 130 tests, но пропущены retention + bootstrap |
|
||||
| **Deploy/Ops** | 7.5/10 | Good compose, no metrics/tracing |
|
||||
| **Документация** | 8.5/10 | Comprehensive README, missing runbook |
|
||||
| **Code Quality** | 7.5/10 | Type hints, но long files + magic numbers |
|
||||
|
||||
**Общий балл: 7.6/10** — крепкий проект с хорошими основами, требует refactor main.py и database indexes для production scale.
|
||||
|
||||
---
|
||||
|
||||
## История ревизий
|
||||
|
||||
| Дата | Автор | Изменения |
|
||||
|------|-------|----------|
|
||||
| 2026-09-10 | AI | Полный аудит: архитектура, backend, frontend, security, performance, testing, deploy, docs |
|
||||
| 2026-09-08 | (предыдущий) | Базовый аудит UI/UX, T01/T02 fixes |
|
||||
|
||||
---
|
||||
|
||||
## Что делать дальше (рекомендации)
|
||||
|
||||
### Немедленно (до продакшена)
|
||||
1. Разделить main.py на routers (минимум 3 файла)
|
||||
2. Создать и применить SQL индексы
|
||||
3. Добавить retention integration test
|
||||
4. Добавить CSRF token для screenshot upload
|
||||
5. Добавить `test-production-bootstrap.sh` в CI workflow
|
||||
|
||||
### В течение спринта
|
||||
6. Внедрить Redis для shared caching
|
||||
7. Добавить CSP headers
|
||||
8. Вынести fetch logic из index.astro в fetchers.ts
|
||||
9. Добавить OpenAPI spec export
|
||||
10. Написать incident runbook
|
||||
|
||||
### По возможности
|
||||
11. Materialized view для activity
|
||||
12. Prometheus metrics
|
||||
13. CSS modularization
|
||||
14. ADR documentation
|
||||
15. Scheduler base class
|
||||
+47
-6
@@ -2,15 +2,15 @@
|
||||
|
||||
Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью.
|
||||
|
||||
Последняя сверка: **14 сентября 2026**.
|
||||
Последняя сверка: **15 сентября 2026**.
|
||||
|
||||
Подтверждено:
|
||||
|
||||
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md);
|
||||
- [x] полный Python suite: **157 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой;
|
||||
- [x] полный Python suite: **178 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой;
|
||||
- [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит;
|
||||
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**;
|
||||
- [x] граф Alembic линеен и имеет единственную голову `0016`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill;
|
||||
- [x] граф Alembic линеен и имеет единственную голову `0018`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill;
|
||||
- [x] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам;
|
||||
- [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются.
|
||||
|
||||
@@ -38,14 +38,43 @@
|
||||
- [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`.
|
||||
- [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы.
|
||||
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
|
||||
- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика.
|
||||
- [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01–G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям.
|
||||
- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл.
|
||||
- [ ] **B21 · Очередь quality-upgrade.** Не менять текущие approved-файлы до готовности замены. Перевести 227 RF4DB `duplicate` в отдельное состояние `upgrade_queued`, сохранив `duplicate_of`, и загружать партиями до 40 в разрешённые 30-минутные окна. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается.
|
||||
- [ ] **B22 · Сравнение вариантов разных источников.** После загрузки для каждой рыбы сравнить реальные dimensions, размер файла, MIME, прозрачность, aspect ratio и визуальное соответствие подписи. Минимальный технический порог для основной карточки — 256 px по меньшей стороне, предпочтительный — 512 px; маленький файл сохранять только как fallback. Источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета: выбирается лучший прошедший проверку файл.
|
||||
- [ ] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не меняются до готовности замены. 15.09 отдельная очередь создана для 226 прямых RF4DB-альтернатив с сохранением `duplicate_of`; первая партия из 40 файлов загружена без ошибок в `upgrade_stored`, ещё 186 замен и 10 кандидатов недостающих рыб ожидают следующих разрешённых 30-минутных окон. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переведён. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается.
|
||||
- [ ] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставляет сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверяет dimensions, размер файла, MIME, прозрачность и aspect ratio. Первая партия: 40/40 кандидатов — прозрачные WebP 1024×1024, все прошли порог 256 px и сохранили пропорции; технических ошибок нет. До переключения остаётся визуально подтвердить соответствие подписи. Маленький файл сохраняется как fallback; источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета.
|
||||
- [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый.
|
||||
- [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48.
|
||||
- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
|
||||
|
||||
### Каталог водоёмов, карты и координаты
|
||||
|
||||
- [x] **W01 · Canonical-каталог RF4DB.** 16.09 через браузерный контекст получен и проверен индекс `/ru/maps`: 19/19 карточек, source slug/ID, русское название, уровень открытия, число видов рыб и URL изображения сохранены в [`data/waterbodies/rf4db-catalog-2026-09-16.json`](../data/waterbodies/rf4db-catalog-2026-09-16.json). Добавлена команда `python -m app.cli import-waterbody-catalog --input ...` для применения снимка в БД. Изображения остаются кандидатами без автоматической роли `map` или `cover`; detail-данные выполняются отдельно по W02.
|
||||
- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы; первая detail-карточка (`level_000_home`) сохранена браузером как snapshot. Осталось последовательно разобрать 18 detail-страниц с общим 30-минутным cooldown домена.
|
||||
- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентные upsert-функции обновляют только подтверждённые source identity и не удаляют исчезнувшие строки. Осталось применить их к проверенному canonical-каталогу и отдельно разделить игровой и редакционный тексты при подключении detail-данных.
|
||||
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
|
||||
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
|
||||
- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Карточка точки показывает точность рядом с координатами. Осталось провести browser QA.
|
||||
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка.
|
||||
- [ ] **W08 · Приёмка и эксплуатация.** Добавить fixture-based parser tests, offline catalog/media audit, проверку 19 canonical entities, отсутствие битых файлов и browser QA desktop/mobile. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
|
||||
|
||||
### Каталог снастей, наживок и прочей оснастки
|
||||
|
||||
Этот пакет повторяет жизненный цикл W01–W08, но не смешивает разные уровни
|
||||
описания. Наживка/приманка — предмет, который указан в улове; снасть —
|
||||
компонент комплекта (удилище, катушка, леска, крючок и т. п.); оснастка —
|
||||
собранная схема или монтаж (например, донная, поплавочная, method). Число
|
||||
найденных изображений приманок не считается размером полного каталога.
|
||||
|
||||
- [ ] **G01 · Canonical-каталог RF4DB.** Получить разрешённый индекс категорий gear и проверить полный набор доступных страниц по типам: `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`. Сохранить исходный slug/ID, русское название, категорию, подкатегорию, бренд, семейство, игровые ограничения/уровень и source URL. Отсутствие общего счётчика или закрытая категория должны оставаться явно `unknown`, а не превращаться в оценку полноты.
|
||||
- [ ] **G02 · Карточки предметов и оснасток.** Подготовить строгие fixture-based parsers для списка и detail-страницы: характеристики, варианты, совместимость, изображения, связанные типы монтажа и исходные значения. Парсер должен различать отсутствующее поле, «не применимо» и фактическое нулевое значение; при неполном или изменившемся ответе сохранять предыдущие подтверждённые данные. Detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
|
||||
- [ ] **G03 · Модель и provenance.** Расширить `bait` до канонического `tackle_item` либо выполнить безопасную миграцию с обратной совместимостью API: `kind`, `category`, `subcategory`, `brand`, `family`, `source_system`, `source_external_id`, `source_url`, `source_checked_at`, `raw_payload`. Отдельно моделировать `rig`/монтаж и его компоненты; не хранить удилище, катушку и монтаж в одном свободном `rig_type`. Для каждой характеристики сохранить источник и статус проверки.
|
||||
- [ ] **G04 · Crosswalk и нормализация.** Построить offline-crosswalk между RF4DB, официальными рекордами, RF4MAP, RF4 Posts и локальным справочником. Нормализовать регистр, пробелы, дефисы, единицы и локализацию; предлагать совпадение только при точном имени/алиасе плюс совместимой категории. Неоднозначные, брендовые варианты и unmatched-строки отправлять на review без автоматического canonical key; исходное значение всегда сохранять.
|
||||
- [ ] **G05 · Связи с уловами и источниками.** Протянуть канонические предметы и монтажи через community import, официальные записи и форму улова, сохранив `raw_payload` и список missing fields. Поддержать несколько предметов в одном комплекте, порядок/роль компонента и источник каждой связи; старый `bait_id` и текстовые значения не терять при миграции. Публикация полного наблюдения по-прежнему требует подтверждённых соответствий, а не простого совпадения строки.
|
||||
- [ ] **G06 · API и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников.
|
||||
- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть.
|
||||
- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance.
|
||||
- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
|
||||
|
||||
### Тёмная тема
|
||||
|
||||
Реализовывать последовательно: сначала семантическая палитра и системный режим, затем ручное управление и полировка компонентов. Тёмная тема должна сохранять полевую эстетику RF4 Spotter, а не быть механической инверсией светлой.
|
||||
@@ -91,6 +120,18 @@
|
||||
- [x] **M05 · Защита от параллельных решений.** Обе очереди отдают `moderation_version`; mapping/publish/reject/approve/delete требуют увиденную версию и повторно сверяют её под row lock. Успешное решение атомарно увеличивает version, а устаревшая вкладка получает понятный `409` и автоматически перезагружает очередь. Схема обновляется линейной миграцией `0015`.
|
||||
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
||||
|
||||
### План доведения административной панели
|
||||
|
||||
Этот план фиксирует следующий рабочий контур поверх уже закрытых M01–M05. Текущий MVP функционален для одного владельца альфы, но ниже перечислены эксплуатационные пробелы, найденные аудитом, и критерии их закрытия.
|
||||
|
||||
- [ ] **A01 · Защита маршрутов и границы сессии.** Покрыть точный `/admin` и `/admin/*` единым Caddy Basic Auth, выставлять `noindex` и `no-store` для всех административных ответов, проверить отсутствие обхода через API и корректные `401/429`. Критерий: автоматический proxy-smoke для `/admin`, страниц и `/api/v1/admin/*` с отсутствующим, неверным и валидным доступом.
|
||||
- [ ] **A02 · Единая auth/error UX.** Привести dashboard, moderation и external sources к одинаковому поведению при `401`, `409`, `429`, `5xx`, loading/empty-состояниях: понятное сообщение, блокировка повторной отправки, возврат к входу только при истёкшей авторизации. Критерий: regression-тесты на каждый ответ и сохранение введённой причины.
|
||||
- [ ] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта.
|
||||
- [ ] **A04 · Контур медиа-проверки.** Сделать admin-экран для просмотра approved/upgrade_queued медиа, исходника, размеров, производных и provenance; добавить approve/rollback только через существующие безопасные состояния. Критерий: ни одна публичная замена не происходит без явного решения и проверяемого manifest-а.
|
||||
- [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере.
|
||||
- [ ] **A06 · Browser/accessibility acceptance.** Проверить `/admin`, moderation и external sources в 320/390/768/1280 px: клавиатура, focus order, screen reader labels, reduced motion, forced colors, темы, ошибки/пустые очереди и конфликт `409`. Критерий: Playwright + axe без блокирующих дефектов и ручная визуальная проверка.
|
||||
- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Критерий: acceptance-runbook пройден, rollback и процедура отзыва доступа документированы.
|
||||
|
||||
## Готовность открытой альфы — требуется сервер или внешний сервис
|
||||
|
||||
- [ ] Купить/подготовить Linux-сервер и подтвердить его публичный IPv4/IPv6.
|
||||
|
||||
Reference in New Issue
Block a user