feat: add discoverable rig catalog
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user