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 .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .public_cache import public_cache
|
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
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
@app.get("/api/v1/records", response_model=PaginatedOfficialRecordOut)
|
||||||
def records(
|
def records(
|
||||||
db: Db, fish: str | None = None, waterbody: str | None = None,
|
db: Db, fish: str | None = None, waterbody: str | None = None,
|
||||||
category: str | None = None, limit: int = Query(50, ge=1, le=100),
|
category: str | None = None, limit: int = Query(50, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
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)
|
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
|
||||||
if fish:
|
if fish:
|
||||||
query = query.join(CatchReport.fish).where(Fish.slug == 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)
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
if category:
|
if category:
|
||||||
query = query.where(CatchReport.raw_payload["category"].as_string() == 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)))
|
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:
|
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ class OfficialRecordOut(BaseModel):
|
|||||||
source_system: str = "rf4-official"
|
source_system: str = "rf4-official"
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedOfficialRecordOut(BaseModel):
|
||||||
|
items: list[OfficialRecordOut]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
class PublicObservationOut(BaseModel):
|
class PublicObservationOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
source_system: str
|
source_system: str
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
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:
|
def test_records_list_is_empty_before_import() -> None:
|
||||||
response = client.get("/api/v1/records")
|
response = client.get("/api/v1/records")
|
||||||
assert response.status_code == 200
|
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:
|
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"}),
|
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()
|
db.commit()
|
||||||
|
try:
|
||||||
response = client.get("/api/v1/records?category=wanted&limit=1")
|
response = client.get("/api/v1/records?category=wanted&limit=1")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert len(response.json()) == 1
|
payload = response.json()
|
||||||
assert response.json()[0]["category"] == "wanted"
|
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:
|
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 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 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 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 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 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 };
|
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 Layout from "../layouts/Layout.astro";
|
||||||
import SourceBadge from "../components/SourceBadge.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 params = Astro.url.searchParams;
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false, showNoIndex = false;
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
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 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];
|
const last = runs[0];
|
||||||
---
|
---
|
||||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
<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>
|
<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>
|
<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>
|
<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> : 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>}
|
{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>
|
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user