feat: add discoverable rig catalog

This commit is contained in:
ik
2026-09-21 19:43:17 +07:00
parent 5c1a7ffa01
commit b4585cee7e
5 changed files with 58 additions and 8 deletions
+28 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload
from ..dependencies import Db
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody
from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut
from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, WaterbodyOut
router = APIRouter()
@@ -34,6 +34,12 @@ def _item_missing_fields(item: TackleItem) -> list[str]:
) if value is None]
def _rig_missing_fields(rig: Rig) -> list[str]:
return [field for field, value in (
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
) if value is None]
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
def tackle_items(
db: Db,
@@ -69,14 +75,32 @@ def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)})
@router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut)
def tackle_rigs(
db: Db,
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedRigOut:
query = select(Rig).options(selectinload(Rig.components))
total = db.scalar(select(func.count(Rig.id))) or 0
rigs = list(db.scalars(query.order_by(Rig.name, Rig.id).offset(offset).limit(limit)))
return PaginatedRigOut(
items=[RigSummaryOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
source_checked_at=rig.source_checked_at, missing_fields=_rig_missing_fields(rig),
component_count=len(rig.components),
) for rig in rigs],
total=total, limit=limit, offset=offset,
)
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
if rig is None:
raise HTTPException(status_code=404, detail="rig not found")
missing = [field for field, value in (
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
) if value is None]
missing = _rig_missing_fields(rig)
return RigOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
+18
View File
@@ -63,6 +63,24 @@ class PaginatedTackleItemOut(BaseModel):
offset: int
class RigSummaryOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
component_count: int
class PaginatedRigOut(BaseModel):
items: list[RigSummaryOut]
total: int
limit: int
offset: int
class RigComponentOut(BaseModel):
id: UUID
role: str
+2
View File
@@ -22,6 +22,8 @@ export type Catch = { id: string; fish: string; weight_g: number; bait: string |
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
export type RigSummary = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; component_count: number };
export type PaginatedRigs = { items: RigSummary[]; total: number; limit: number; offset: number };
export type TackleCombination = { role: string; value: string; tackle_item_id: string | null; rig_id: string | null; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: 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 = {
+9 -3
View File
@@ -5,7 +5,7 @@ import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import DataPassport from "../../components/DataPassport.astro";
import { api, type PaginatedTackleItems } from "../../lib/api";
import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api";
const params = Astro.url.searchParams;
const category = params.get("category") ?? "";
@@ -18,8 +18,13 @@ const query = new URLSearchParams({ limit: String(limit), offset: String(offset)
if (category) query.set("category", category);
if (brand) query.set("brand", brand);
if (family) query.set("family", family);
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, unavailable = false;
try { result = await api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`); } catch { unavailable = true; }
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false;
const [itemsResult, rigsResult] = await Promise.allSettled([
api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`),
api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${offset}`),
]);
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true;
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true;
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
const filterParams = new URLSearchParams();
@@ -38,5 +43,6 @@ if (family) filterParams.set("family", family);
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small><div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Монтажи</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Монтажи временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Монтаж · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых монтажей пока нет" description="Монтажи появятся после разрешённой загрузки и проверки источников." />}</section>}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
</Layout>