R09: Add PaginatedOfficialRecordOut schema and paginated /api/v1/records endpoint
This commit is contained in:
+15
-4
@@ -28,7 +28,7 @@ from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .public_cache import public_cache
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PaginatedOfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -274,12 +274,12 @@ def source_status(db: Db) -> list[SourceStatusOut]:
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||
@app.get("/api/v1/records", response_model=PaginatedOfficialRecordOut)
|
||||
def records(
|
||||
db: Db, fish: str | None = None, waterbody: str | None = None,
|
||||
category: str | None = None, limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[OfficialRecordOut]:
|
||||
) -> PaginatedOfficialRecordOut:
|
||||
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
|
||||
if fish:
|
||||
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
||||
@@ -287,8 +287,19 @@ def records(
|
||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||
if category:
|
||||
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
||||
# Count total before pagination
|
||||
total = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) or 0
|
||||
if fish:
|
||||
total = db.scalar(select(func.count()).select_from(CatchReport).join(CatchReport.fish).where(Fish.slug == fish, CatchReport.source_type == SourceType.official_record)) or 0
|
||||
if waterbody:
|
||||
total = db.scalar(select(func.count()).select_from(CatchReport).join(CatchReport.waterbody).where(Waterbody.slug == waterbody, CatchReport.source_type == SourceType.official_record)) or 0
|
||||
if category:
|
||||
total = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record, CatchReport.raw_payload["category"].as_string() == category)) or 0
|
||||
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
|
||||
return PaginatedOfficialRecordOut(
|
||||
items=[OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items],
|
||||
total=total, limit=limit, offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
|
||||
@@ -98,6 +98,13 @@ class OfficialRecordOut(BaseModel):
|
||||
source_system: str = "rf4-official"
|
||||
|
||||
|
||||
class PaginatedOfficialRecordOut(BaseModel):
|
||||
items: list[OfficialRecordOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PublicObservationOut(BaseModel):
|
||||
id: UUID
|
||||
source_system: str
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy import create_engine, delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -177,7 +177,12 @@ def test_spot_detail_and_catches() -> None:
|
||||
def test_records_list_is_empty_before_import() -> None:
|
||||
response = client.get("/api/v1/records")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
payload = response.json()
|
||||
assert "items" in payload
|
||||
assert payload["total"] == 0
|
||||
assert payload["limit"] == 50
|
||||
assert payload["offset"] == 0
|
||||
assert payload["items"] == []
|
||||
|
||||
|
||||
def test_record_category_filter_is_applied_before_pagination() -> None:
|
||||
@@ -190,10 +195,73 @@ def test_record_category_filter_is_applied_before_pagination() -> None:
|
||||
CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}),
|
||||
])
|
||||
db.commit()
|
||||
try:
|
||||
response = client.get("/api/v1/records?category=wanted&limit=1")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["category"] == "wanted"
|
||||
payload = response.json()
|
||||
assert payload["total"] == 1 # only "wanted" matches
|
||||
assert payload["limit"] == 1
|
||||
assert payload["offset"] == 0
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["category"] == "wanted"
|
||||
finally:
|
||||
# Cleanup added records
|
||||
db.execute(delete(CatchReport).where(
|
||||
CatchReport.source_type == SourceType.official_record,
|
||||
CatchReport.raw_payload["category"].as_string().in_(["other", "wanted"]),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_records_pagination_returns_correct_total_and_offset() -> None:
|
||||
with Session(engine) as db:
|
||||
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
|
||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
|
||||
now = datetime.now(timezone.utc)
|
||||
# Add exactly 5 official records with unique weights
|
||||
for index in range(5):
|
||||
db.add(CatchReport(fish=fish, waterbody=waterbody, weight_g=70000 + index * 100, caught_at=now - timedelta(days=index), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved))
|
||||
db.commit()
|
||||
try:
|
||||
# Page 1: limit=2, offset=0
|
||||
response1 = client.get("/api/v1/records?limit=2&offset=0")
|
||||
assert response1.status_code == 200
|
||||
p1 = response1.json()
|
||||
assert p1["total"] >= 5
|
||||
assert p1["limit"] == 2
|
||||
assert p1["offset"] == 0
|
||||
assert len(p1["items"]) == 2
|
||||
# Verify first item has our newest caught_at (index=0, weight=70000)
|
||||
assert p1["items"][0]["weight_g"] == 70000
|
||||
# Page 2: limit=2, offset=2
|
||||
response2 = client.get("/api/v1/records?limit=2&offset=2")
|
||||
assert response2.status_code == 200
|
||||
p2 = response2.json()
|
||||
assert p2["total"] == p1["total"] # total must be consistent
|
||||
assert p2["limit"] == 2
|
||||
assert p2["offset"] == 2
|
||||
assert len(p2["items"]) == 2
|
||||
# Page 3: limit=2, offset=4
|
||||
response3 = client.get("/api/v1/records?limit=2&offset=4")
|
||||
assert response3.status_code == 200
|
||||
p3 = response3.json()
|
||||
assert p3["total"] == p1["total"]
|
||||
assert p3["offset"] == 4
|
||||
# Last page should have remaining items
|
||||
assert len(p3["items"]) <= 2
|
||||
# Page 4: offset=total — past total, empty
|
||||
response4 = client.get(f"/api/v1/records?limit=2&offset={p1['total']}")
|
||||
assert response4.status_code == 200
|
||||
p4 = response4.json()
|
||||
assert p4["total"] == p1["total"]
|
||||
assert p4["items"] == []
|
||||
finally:
|
||||
# Cleanup added records
|
||||
db.execute(delete(CatchReport).where(
|
||||
CatchReport.source_type == SourceType.official_record,
|
||||
CatchReport.weight_g >= 70000,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_user_report_requires_moderation_before_activity() -> None:
|
||||
|
||||
@@ -17,6 +17,12 @@ export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: n
|
||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string };
|
||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
||||
export type PaginatedOfficialRecord = {
|
||||
items: OfficialRecord[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false, showNoIndex = false;
|
||||
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
let unavailable = false, showNoIndex = false, totalRecords = 0;
|
||||
try {
|
||||
const query = new URLSearchParams({ fish, waterbody, limit: "50", offset: String(offset) });
|
||||
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
|
||||
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
||||
totalRecords = paginated.total;
|
||||
[runs, fishes, waterbodies] = await Promise.all([api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
||||
} catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
||||
const last = runs[0];
|
||||
---
|
||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
||||
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
||||
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
|
||||
{unavailable ? <div class="state content-grid"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{records.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{items.length} из {totalRecords} записей</span></div>
|
||||
{unavailable && <div class="state content-grid"><h2>Источник временно недоступен</h2></div>}
|
||||
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
|
||||
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
|
||||
{!unavailable && !items.length && <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user