diff --git a/apps/api/app/main.py b/apps/api/app/main.py index e42e3fd..34b22ae 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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: diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 0ce6a3d..b4ba96d 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -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 diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 712f734..645b41e 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -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() - 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" + try: + response = client.get("/api/v1/records?category=wanted&limit=1") + assert response.status_code == 200 + 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: diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5b5ac18..3a686fd 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 }; diff --git a/apps/web/src/pages/records.astro b/apps/web/src/pages/records.astro index 0e9885d..1a4bcca 100644 --- a/apps/web/src/pages/records.astro +++ b/apps/web/src/pages/records.astro @@ -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(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api("/api/v1/imports?limit=1"), api("/api/v1/fishes"), api("/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(`/api/v1/records?${query}`); + items = offset > 0 ? [...items, ...paginated.items] : paginated.items; + totalRecords = paginated.total; + [runs, fishes, waterbodies] = await Promise.all([api("/api/v1/imports?limit=1"), api("/api/v1/fishes"), api("/api/v1/waterbodies")]); +} catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); } const last = runs[0]; ---
Публичные данные RF4

Официальные
рекорды

{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}{last?.finished_at && {new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк}
{(fish || waterbody) && Сбросить}
-
Официальный источник

Последние записи

{records.length} показано
- {unavailable ?

Источник временно недоступен

: records.length ?
РыбаВесВодоёмПриманкаИгрокДата и источник
{records.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
:

Рекорды ещё не импортированы

Для выбранных условий записей пока нет.

} +
Официальный источник

Последние записи

{items.length} из {totalRecords} записей
+ {unavailable &&

Источник временно недоступен

} + {!unavailable && items.length &&
РыбаВесВодоёмПриманкаИгрокДата и источник
{items.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
} + {!unavailable && items.length && offset + items.length < totalRecords && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё {offset + items.length} из {totalRecords}} + {!unavailable && !items.length &&

Рекорды ещё не импортированы

Для выбранных условий записей пока нет.

}

Источник: официальный сайт Russian Fishing 4. Координаты в официальных таблицах отсутствуют.