feat: add gear provenance models and browser fetcher
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""add canonical tackle items and rig components
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tackle_item",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
|
||||
sa.Column("category", sa.String(20), nullable=False),
|
||||
sa.Column("subcategory", sa.String(100)),
|
||||
sa.Column("brand", sa.String(100)),
|
||||
sa.Column("family", sa.String(100)),
|
||||
sa.Column("unlock_level", sa.Integer()),
|
||||
sa.Column("source_system", sa.String(50)),
|
||||
sa.Column("source_external_id", sa.String(200)),
|
||||
sa.Column("source_url", sa.Text()),
|
||||
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("raw_payload", sa.JSON()),
|
||||
sa.UniqueConstraint("source_system", "source_external_id"),
|
||||
sa.CheckConstraint(
|
||||
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
|
||||
name="ck_tackle_item_category",
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"rig",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
|
||||
sa.Column("source_system", sa.String(50)),
|
||||
sa.Column("source_external_id", sa.String(200)),
|
||||
sa.Column("source_url", sa.Text()),
|
||||
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("raw_payload", sa.JSON()),
|
||||
)
|
||||
op.create_table(
|
||||
"rig_component",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id"), nullable=False),
|
||||
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
|
||||
sa.Column("role", sa.String(50), nullable=False),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("raw_value", sa.String(200)),
|
||||
sa.UniqueConstraint("rig_id", "position"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("rig_component")
|
||||
op.drop_table("rig")
|
||||
op.drop_table("tackle_item")
|
||||
@@ -69,6 +69,61 @@ class Bait(Base):
|
||||
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
|
||||
|
||||
|
||||
class TackleItem(Base):
|
||||
"""Canonical gear item; legacy Bait rows remain source-compatible."""
|
||||
|
||||
__tablename__ = "tackle_item"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_system", "source_external_id"),
|
||||
CheckConstraint(
|
||||
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
|
||||
name="ck_tackle_item_category",
|
||||
),
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
category: Mapped[str] = mapped_column(String(20))
|
||||
subcategory: Mapped[str | None] = mapped_column(String(100))
|
||||
brand: Mapped[str | None] = mapped_column(String(100))
|
||||
family: Mapped[str | None] = mapped_column(String(100))
|
||||
unlock_level: Mapped[int | None]
|
||||
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||
source_url: Mapped[str | None] = mapped_column(Text)
|
||||
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||
rig_components: Mapped[list["RigComponent"]] = relationship(back_populates="tackle_item")
|
||||
|
||||
|
||||
class Rig(Base):
|
||||
"""A named rig/setup kept separate from individual tackle items."""
|
||||
|
||||
__tablename__ = "rig"
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||
source_url: Mapped[str | None] = mapped_column(Text)
|
||||
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||
components: Mapped[list["RigComponent"]] = relationship(back_populates="rig")
|
||||
|
||||
|
||||
class RigComponent(Base):
|
||||
__tablename__ = "rig_component"
|
||||
__table_args__ = (UniqueConstraint("rig_id", "position"),)
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
rig_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rig.id"))
|
||||
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
|
||||
role: Mapped[str] = mapped_column(String(50))
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
raw_value: Mapped[str | None] = mapped_column(String(200))
|
||||
rig: Mapped[Rig] = relationship(back_populates="components")
|
||||
tackle_item: Mapped[TackleItem | None] = relationship(back_populates="rig_components")
|
||||
|
||||
|
||||
class Spot(Base):
|
||||
__tablename__ = "spot"
|
||||
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.models import Rig, RigComponent, TackleItem
|
||||
|
||||
|
||||
def test_tackle_item_and_rig_components_keep_provenance_and_legacy_independence() -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
item_id = uuid.uuid4()
|
||||
rig_id = uuid.uuid4()
|
||||
with Session(engine) as session:
|
||||
item = TackleItem(
|
||||
id=item_id, name="Spiker #2", normalized_name="spiker #2",
|
||||
category="lure", subcategory="spinner", brand="RF4", family="spoon",
|
||||
unlock_level=0, source_system="rf4db", source_external_id="spiker-2",
|
||||
source_url="https://rf4db.com/ru/wiki/lures/spiker-2",
|
||||
raw_payload={"weight": {"state": "value", "value": 0}},
|
||||
)
|
||||
rig = Rig(
|
||||
id=rig_id, name="Method Popup", normalized_name="method popup",
|
||||
source_system="rf4db", source_external_id="method-popup",
|
||||
)
|
||||
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value="Spiker #2"))
|
||||
session.add(rig)
|
||||
session.commit()
|
||||
|
||||
saved = session.get(TackleItem, item_id)
|
||||
assert saved is not None
|
||||
assert saved.unlock_level == 0
|
||||
assert saved.raw_payload == {"weight": {"state": "value", "value": 0}}
|
||||
assert saved.rig_components[0].rig_id == rig_id
|
||||
Reference in New Issue
Block a user