feat: link tackle items to related rigs

This commit is contained in:
ik
2026-09-21 20:18:23 +07:00
parent 26ad9b5194
commit c38097767f
5 changed files with 28 additions and 5 deletions
+5 -3
View File
@@ -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)
+9
View File
@@ -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):
+1 -1
View File
@@ -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 };
@@ -42,6 +42,18 @@ const mediaCandidates = item ? (["tackle_card", "tackle_detail"] as const).map(r
<div><dl><div><dt>Подкатегория</dt><dd>{item.subcategory ?? "Не указана"}</dd></div><div><dt>Уровень открытия</dt><dd>{item.unlock_level ?? "Не указан"}</dd></div><div><dt>Источник</dt><dd>{item.source_system ?? "Не указан"}</dd></div></dl>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
</section>
<DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"} />
<section class="related-rigs content-grid" aria-labelledby="related-rigs-title"><div><span class="overline">Связанные сборки</span><h2 id="related-rigs-title">Где используется снасть</h2><p>Показываем только сборки, где компонент привязан к канонической карточке.</p></div>{item.related_rigs.length ? <nav class="related-rigs__list">{item.related_rigs.map(rig => <a href={`/tackle/rigs/${rig.id}`}><strong>{rig.name}</strong><span>{rig.component_count} компонентов · {rig.source_system ?? "Источник не указан"}</span></a>)}</nav> : <p>Подтверждённых связанных сборок пока нет.</p>}</section>
{mediaCandidates.length > 0 && <section class="catalog-media-grid content-grid" aria-label={`Изображения снасти: ${item.name}`}>{mediaCandidates.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
</>}
</Layout>
<style>
.related-rigs { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); gap: 24px; margin-block: 26px; }
.related-rigs h2 { margin: 6px 0 8px; }
.related-rigs p { margin: 0; color: var(--text-muted); line-height: 1.5; }
.related-rigs__list { display: grid; gap: 10px; }
.related-rigs__list a { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 14px; color: inherit; text-decoration: none; }
.related-rigs__list a:hover, .related-rigs__list a:focus-visible { border-color: var(--focus); }
.related-rigs__list span { color: var(--text-muted); font-size: 12px; }
@media (max-width: 720px) { .related-rigs { grid-template-columns: 1fr; } }
</style>