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 sqlalchemy.orm import selectinload
from ..dependencies import Db from ..dependencies import Db
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, RigComponent, Spot, TackleItem, Waterbody
from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, WaterbodyOut from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, TackleItemRigOut, WaterbodyOut
router = APIRouter() router = APIRouter()
@@ -77,7 +77,9 @@ def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
item = db.get(TackleItem, item_id) item = db.get(TackleItem, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="tackle item not found") 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) @router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut)
+9
View File
@@ -40,6 +40,14 @@ class BaitOut(BaseModel):
kind: str kind: str
class TackleItemRigOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_checked_at: datetime | None
component_count: int
class TackleItemOut(BaseModel): class TackleItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
@@ -54,6 +62,7 @@ class TackleItemOut(BaseModel):
source_url: str | None source_url: str | None
source_checked_at: datetime | None source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list) missing_fields: list[str] = Field(default_factory=list)
related_rigs: list[TackleItemRigOut] = Field(default_factory=list)
class PaginatedTackleItemOut(BaseModel): 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 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 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 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 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 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 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> <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> </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"} /> <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>} {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> </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>
+1 -1
View File
@@ -67,7 +67,7 @@
найденных изображений приманок не считается размером полного каталога. найденных изображений приманок не считается размером полного каталога.
- [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом. - [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы сборок сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. - [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы сборок сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Detail карточки снасти теперь показывает подтверждённые сборки, в которых используется canonical item, с прямыми ссылками и количеством компонентов. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk. - [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk.
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
- [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.