feat: search official records
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run

This commit is contained in:
ik
2026-09-21 20:21:12 +07:00
parent f711b9ac0b
commit 55e5810e65
2 changed files with 12 additions and 4 deletions
+9 -2
View File
@@ -1,13 +1,14 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..models import ( from ..models import (
CatchReport, CatchReport,
Bait,
CommunityImportRun, CommunityImportRun,
DataSource, DataSource,
ExternalObservation, ExternalObservation,
@@ -117,9 +118,12 @@ def records(
fish: str | None = None, fish: str | None = None,
waterbody: str | None = None, waterbody: str | None = None,
category: str | None = None, category: str | None = None,
q: str | None = None,
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
) -> PaginatedOfficialRecordOut: ) -> PaginatedOfficialRecordOut:
if q is not None and len(q) > 100:
raise HTTPException(status_code=422, detail="record search query is too long")
query = select(CatchReport).options( query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.fish),
joinedload(CatchReport.waterbody), joinedload(CatchReport.waterbody),
@@ -131,6 +135,9 @@ 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)
if q and q.strip():
needle = f"%{q.strip()}%"
query = query.outerjoin(Bait, CatchReport.bait_id == Bait.id).where(or_(CatchReport.player_name.ilike(needle), Bait.name.ilike(needle)))
total = db.scalar( total = db.scalar(
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None) query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
) or 0 ) or 0
+3 -2
View File
@@ -9,13 +9,14 @@ import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type
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") ?? "";
const search = params.get("q") ?? "";
const recordsLimit = 50; const recordsLimit = 50;
const requestedOffset = Number(params.get("offset") ?? 0); const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0; let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false, showNoIndex = false, totalRecords = 0; let unavailable = false, showNoIndex = false, totalRecords = 0;
try { try {
const query = new URLSearchParams({ fish, waterbody, limit: String(recordsLimit), offset: String(offset) }); const query = new URLSearchParams({ fish, waterbody, q: search, limit: String(recordsLimit), offset: String(offset) });
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`); const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
items = paginated.items; items = paginated.items;
totalRecords = paginated.total; totalRecords = paginated.total;
@@ -36,7 +37,7 @@ 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 data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" 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><label>Игрок или приманка<input name="q" value={search} maxlength="100" placeholder="Например, игрок или блесна" /></label><button data-action="primary">Фильтровать</button>{(fish || waterbody || search) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div> <div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />} {unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
{!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 class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></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 && <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 class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></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>}