feat: add gear provenance models and browser fetcher

This commit is contained in:
ik
2026-09-20 15:50:43 +07:00
parent b0edae98c6
commit 4e9895fbf4
19 changed files with 986 additions and 20 deletions
@@ -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")
+55
View File
@@ -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"),)
+35
View File
@@ -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
+2 -2
View File
@@ -39,11 +39,11 @@
--status-warning: light-dark(#9a741d, #e8c768);
--status-changed: light-dark(#a44236, #ff9587);
--source-official: light-dark(#815713, #e3b65b);
--source-rf4db: light-dark(#286581, #74bee3);
--source-rf4db: light-dark(#245d78, #74bee3);
--source-rf4stat: light-dark(#60499a, #b7a2ed);
--source-rf4map: light-dark(#2f704f, #79c79e);
--source-rf4posts: light-dark(#984a33, #ee9a7e);
--source-players: light-dark(#526b1c, #add066);
--source-players: light-dark(#4b6419, #add066);
--graphic-line: light-dark(#3f7779, #79b5b8);
--graphic-grid: light-dark(#dce5de, #294244);
--tackle-orange: light-dark(#b96b27, #f0a55f);
+50
View File
@@ -11,3 +11,53 @@ for (const path of [
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
});
}
test("admin keyboard navigation keeps focus out of the document body", async ({ page }) => {
await page.goto("/admin");
await page.keyboard.press("Tab");
await expect(page.locator(".skip-link")).toBeFocused();
const focusedLabels: string[] = [];
for (let index = 0; index < 20; index += 1) {
await page.keyboard.press("Tab");
focusedLabels.push(await page.evaluate(() => {
const active = document.activeElement;
if (active?.tagName === "BODY") return "BODY";
if (active?.matches(".admin-login input")) return "ADMIN_TOKEN_INPUT";
if (active?.matches(".admin-login button")) return "ADMIN_PANEL_BUTTON";
return (active?.getAttribute("aria-label") || active?.textContent || active?.tagName || "").trim().slice(0, 80);
}));
}
expect(focusedLabels).not.toContain("BODY");
expect(focusedLabels).toContain("ADMIN_TOKEN_INPUT");
expect(focusedLabels).toContain("ADMIN_PANEL_BUTTON");
});
test("print media keeps a light color scheme and removes hero image filters", async ({ page }) => {
await page.emulateMedia({ media: "print" });
await page.goto("/");
const printState = await page.evaluate(() => ({
colorScheme: getComputedStyle(document.documentElement).colorScheme,
heroFilter: document.querySelector(".lake-card img") ? getComputedStyle(document.querySelector(".lake-card img")!).filter : "none",
}));
expect(printState.colorScheme).toBe("light");
expect(printState.heroFilter).toBe("none");
});
test("forced colors keeps theme controls visibly bordered", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/admin");
const forcedColorsState = await page.evaluate(() => {
const control = document.querySelector(".theme-switcher button");
const pressed = document.querySelector(".theme-switcher button[aria-pressed='true']");
return {
borderStyle: control ? getComputedStyle(control).borderStyle : "",
borderWidth: control ? getComputedStyle(control).borderWidth : "",
activeBackground: pressed ? getComputedStyle(pressed).backgroundColor : "",
};
});
expect(forcedColorsState.borderStyle).toBe("solid");
expect(forcedColorsState.borderWidth).toBe("1px");
expect(forcedColorsState.activeBackground).not.toBe("transparent");
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
const routes = [
"/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media",
];
const viewports = [
{ width: 320, height: 800 },
{ width: 390, height: 844 },
{ width: 768, height: 900 },
{ width: 1280, height: 900 },
];
const themes = ["system", "light", "dark"] as const;
test("ROADMAP visual matrix keeps routes within the viewport", async ({ page, context }) => {
for (const theme of themes) {
await context.clearCookies();
if (theme !== "system") {
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
}
await page.emulateMedia({ colorScheme: theme === "system" ? "light" : theme });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
for (const route of routes) {
await page.goto(route);
const state = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
hasMain: Boolean(document.querySelector("main")),
hasHeading: Boolean(document.querySelector("h1")),
theme: document.documentElement.dataset.theme || "system",
}));
expect(state.scrollWidth, `${theme} ${viewport.width}px ${route} overflows`).toBeLessThanOrEqual(state.clientWidth);
expect(state.hasMain, `${route} has no main landmark`).toBe(true);
expect(state.hasHeading, `${route} has no h1`).toBe(true);
expect(state.theme, `${theme} SSR theme mismatch on ${route}`).toBe(theme);
}
}
}
});