diff --git a/apps/api/app/routers/catalog.py b/apps/api/app/routers/catalog.py index 3fd9858..61a29c5 100644 --- a/apps/api/app/routers/catalog.py +++ b/apps/api/app/routers/catalog.py @@ -5,8 +5,8 @@ from sqlalchemy import func, select 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, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, WaterbodyOut +from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, RigComponent, Spot, TackleItem, Waterbody +from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, TackleItemRigOut, WaterbodyOut router = APIRouter() @@ -77,7 +77,9 @@ def tackle_item(item_id: UUID, db: Db) -> TackleItemOut: item = db.get(TackleItem, item_id) if item is None: raise HTTPException(status_code=404, detail="tackle item not found") - return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) + rigs = list(db.scalars(select(Rig).join(RigComponent, RigComponent.rig_id == Rig.id).where(RigComponent.tackle_item_id == item_id).options(selectinload(Rig.components)).distinct().order_by(Rig.name, Rig.id))) + related = [TackleItemRigOut(id=rig.id, name=rig.name, source_system=rig.source_system, source_checked_at=rig.source_checked_at, component_count=len(rig.components)) for rig in rigs] + return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item), "related_rigs": related}) @router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut) diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 6a8c0cd..244a1b0 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -40,6 +40,14 @@ class BaitOut(BaseModel): kind: str +class TackleItemRigOut(BaseModel): + id: UUID + name: str + source_system: str | None + source_checked_at: datetime | None + component_count: int + + class TackleItemOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID @@ -54,6 +62,7 @@ class TackleItemOut(BaseModel): source_url: str | None source_checked_at: datetime | None missing_fields: list[str] = Field(default_factory=list) + related_rigs: list[TackleItemRigOut] = Field(default_factory=list) class PaginatedTackleItemOut(BaseModel): diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 079d2da..6717d31 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -20,7 +20,7 @@ export type RigComponent = { id: string; role: string; position: number; raw_val export type Rig = { 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[]; components: RigComponent[] }; export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null; tackle_components: CatchTackleComponent[] }; 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 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[]; related_rigs: { id: string; name: string; source_system: string | null; source_checked_at: string | null; component_count: number }[] }; 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 }; diff --git a/apps/web/src/pages/tackle/items/[id].astro b/apps/web/src/pages/tackle/items/[id].astro index 73c64ad..ddbf765 100644 --- a/apps/web/src/pages/tackle/items/[id].astro +++ b/apps/web/src/pages/tackle/items/[id].astro @@ -42,6 +42,18 @@ const mediaCandidates = item ? (["tackle_card", "tackle_detail"] as const).map(r
Не хватает: {missing.join(", ")}.
}{sourceHref && Открыть первоисточник}