feat: add data models, migrations, and store
This commit is contained in:
parent
ac114da5e7
commit
675146ff1a
6 changed files with 335 additions and 0 deletions
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
76
app/db/migrations/001_init.sql
Normal file
76
app/db/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
CREATE TABLE IF NOT EXISTS sellers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
platform_seller_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
account_age_days INTEGER NOT NULL,
|
||||
feedback_count INTEGER NOT NULL,
|
||||
feedback_ratio REAL NOT NULL,
|
||||
category_history_json TEXT NOT NULL DEFAULT '{}',
|
||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(platform, platform_seller_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS listings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
platform_listing_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
condition TEXT,
|
||||
seller_platform_id TEXT,
|
||||
url TEXT,
|
||||
photo_urls TEXT NOT NULL DEFAULT '[]',
|
||||
listing_age_days INTEGER DEFAULT 0,
|
||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
trust_score_id INTEGER REFERENCES trust_scores(id),
|
||||
UNIQUE(platform, platform_listing_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trust_scores (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
listing_id INTEGER NOT NULL REFERENCES listings(id),
|
||||
composite_score INTEGER NOT NULL,
|
||||
account_age_score INTEGER NOT NULL DEFAULT 0,
|
||||
feedback_count_score INTEGER NOT NULL DEFAULT 0,
|
||||
feedback_ratio_score INTEGER NOT NULL DEFAULT 0,
|
||||
price_vs_market_score INTEGER NOT NULL DEFAULT 0,
|
||||
category_history_score INTEGER NOT NULL DEFAULT 0,
|
||||
photo_hash_duplicate INTEGER NOT NULL DEFAULT 0,
|
||||
photo_analysis_json TEXT,
|
||||
red_flags_json TEXT NOT NULL DEFAULT '[]',
|
||||
score_is_partial INTEGER NOT NULL DEFAULT 0,
|
||||
scored_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_comps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
query_hash TEXT NOT NULL,
|
||||
median_price REAL NOT NULL,
|
||||
sample_count INTEGER NOT NULL,
|
||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE(platform, query_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'ebay',
|
||||
filters_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_run_at TEXT
|
||||
);
|
||||
|
||||
-- PhotoHash: perceptual hash store for cross-search dedup (v0.2+). Schema present in v0.1.
|
||||
CREATE TABLE IF NOT EXISTS photo_hashes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
listing_id INTEGER NOT NULL REFERENCES listings(id),
|
||||
photo_url TEXT NOT NULL,
|
||||
phash TEXT NOT NULL,
|
||||
first_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(listing_id, photo_url)
|
||||
);
|
||||
84
app/db/models.py
Normal file
84
app/db/models.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Dataclasses for all Snipe domain objects."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Seller:
|
||||
platform: str
|
||||
platform_seller_id: str
|
||||
username: str
|
||||
account_age_days: int
|
||||
feedback_count: int
|
||||
feedback_ratio: float # 0.0–1.0
|
||||
category_history_json: str # JSON blob of past category sales
|
||||
id: Optional[int] = None
|
||||
fetched_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Listing:
|
||||
platform: str
|
||||
platform_listing_id: str
|
||||
title: str
|
||||
price: float
|
||||
currency: str
|
||||
condition: str
|
||||
seller_platform_id: str
|
||||
url: str
|
||||
photo_urls: list[str] = field(default_factory=list)
|
||||
listing_age_days: int = 0
|
||||
id: Optional[int] = None
|
||||
fetched_at: Optional[str] = None
|
||||
trust_score_id: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrustScore:
|
||||
listing_id: int
|
||||
composite_score: int # 0–100
|
||||
account_age_score: int # 0–20
|
||||
feedback_count_score: int # 0–20
|
||||
feedback_ratio_score: int # 0–20
|
||||
price_vs_market_score: int # 0–20
|
||||
category_history_score: int # 0–20
|
||||
photo_hash_duplicate: bool = False
|
||||
photo_analysis_json: Optional[str] = None
|
||||
red_flags_json: str = "[]"
|
||||
score_is_partial: bool = False
|
||||
id: Optional[int] = None
|
||||
scored_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketComp:
|
||||
platform: str
|
||||
query_hash: str
|
||||
median_price: float
|
||||
sample_count: int
|
||||
expires_at: str # ISO8601 — checked against current time
|
||||
id: Optional[int] = None
|
||||
fetched_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SavedSearch:
|
||||
"""Schema scaffolded in v0.1; background monitoring wired in v0.2."""
|
||||
name: str
|
||||
query: str
|
||||
platform: str
|
||||
filters_json: str = "{}"
|
||||
id: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_run_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhotoHash:
|
||||
"""Perceptual hash store for cross-search dedup (v0.2+). Schema scaffolded in v0.1."""
|
||||
listing_id: int
|
||||
photo_url: str
|
||||
phash: str # hex string from imagehash
|
||||
id: Optional[int] = None
|
||||
first_seen_at: Optional[str] = None
|
||||
97
app/db/store.py
Normal file
97
app/db/store.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Thin SQLite read/write layer for all Snipe models."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
|
||||
from .models import Listing, Seller, TrustScore, MarketComp
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, db_path: Path):
|
||||
self._conn = get_connection(db_path)
|
||||
run_migrations(self._conn, MIGRATIONS_DIR)
|
||||
|
||||
# --- Seller ---
|
||||
|
||||
def save_seller(self, seller: Seller) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO sellers "
|
||||
"(platform, platform_seller_id, username, account_age_days, "
|
||||
"feedback_count, feedback_ratio, category_history_json) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(seller.platform, seller.platform_seller_id, seller.username,
|
||||
seller.account_age_days, seller.feedback_count, seller.feedback_ratio,
|
||||
seller.category_history_json),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_seller(self, platform: str, platform_seller_id: str) -> Optional[Seller]:
|
||||
row = self._conn.execute(
|
||||
"SELECT platform, platform_seller_id, username, account_age_days, "
|
||||
"feedback_count, feedback_ratio, category_history_json, id, fetched_at "
|
||||
"FROM sellers WHERE platform=? AND platform_seller_id=?",
|
||||
(platform, platform_seller_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Seller(*row[:7], id=row[7], fetched_at=row[8])
|
||||
|
||||
# --- Listing ---
|
||||
|
||||
def save_listing(self, listing: Listing) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO listings "
|
||||
"(platform, platform_listing_id, title, price, currency, condition, "
|
||||
"seller_platform_id, url, photo_urls, listing_age_days) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(listing.platform, listing.platform_listing_id, listing.title,
|
||||
listing.price, listing.currency, listing.condition,
|
||||
listing.seller_platform_id, listing.url,
|
||||
json.dumps(listing.photo_urls), listing.listing_age_days),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_listing(self, platform: str, platform_listing_id: str) -> Optional[Listing]:
|
||||
row = self._conn.execute(
|
||||
"SELECT platform, platform_listing_id, title, price, currency, condition, "
|
||||
"seller_platform_id, url, photo_urls, listing_age_days, id, fetched_at "
|
||||
"FROM listings WHERE platform=? AND platform_listing_id=?",
|
||||
(platform, platform_listing_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Listing(
|
||||
*row[:8],
|
||||
photo_urls=json.loads(row[8]),
|
||||
listing_age_days=row[9],
|
||||
id=row[10],
|
||||
fetched_at=row[11],
|
||||
)
|
||||
|
||||
# --- MarketComp ---
|
||||
|
||||
def save_market_comp(self, comp: MarketComp) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO market_comps "
|
||||
"(platform, query_hash, median_price, sample_count, expires_at) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(comp.platform, comp.query_hash, comp.median_price,
|
||||
comp.sample_count, comp.expires_at),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]:
|
||||
row = self._conn.execute(
|
||||
"SELECT platform, query_hash, median_price, sample_count, expires_at, id, fetched_at "
|
||||
"FROM market_comps WHERE platform=? AND query_hash=? AND expires_at > ?",
|
||||
(platform, query_hash, datetime.now(timezone.utc).isoformat()),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return MarketComp(*row[:5], id=row[5], fetched_at=row[6])
|
||||
0
tests/db/__init__.py
Normal file
0
tests/db/__init__.py
Normal file
78
tests/db/test_store.py
Normal file
78
tests/db/test_store.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import pytest
|
||||
from pathlib import Path
|
||||
from app.db.store import Store
|
||||
from app.db.models import Listing, Seller, TrustScore, MarketComp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return Store(tmp_path / "test.db")
|
||||
|
||||
|
||||
def test_store_creates_tables(store):
|
||||
# If no exception on init, tables exist
|
||||
pass
|
||||
|
||||
|
||||
def test_save_and_get_seller(store):
|
||||
seller = Seller(
|
||||
platform="ebay",
|
||||
platform_seller_id="user123",
|
||||
username="techseller",
|
||||
account_age_days=730,
|
||||
feedback_count=450,
|
||||
feedback_ratio=0.991,
|
||||
category_history_json="{}",
|
||||
)
|
||||
store.save_seller(seller)
|
||||
result = store.get_seller("ebay", "user123")
|
||||
assert result is not None
|
||||
assert result.username == "techseller"
|
||||
assert result.feedback_count == 450
|
||||
|
||||
|
||||
def test_save_and_get_listing(store):
|
||||
listing = Listing(
|
||||
platform="ebay",
|
||||
platform_listing_id="ebay-123",
|
||||
title="RTX 4090 FE",
|
||||
price=950.00,
|
||||
currency="USD",
|
||||
condition="used",
|
||||
seller_platform_id="user123",
|
||||
url="https://ebay.com/itm/123",
|
||||
photo_urls=["https://i.ebayimg.com/1.jpg"],
|
||||
listing_age_days=3,
|
||||
)
|
||||
store.save_listing(listing)
|
||||
result = store.get_listing("ebay", "ebay-123")
|
||||
assert result is not None
|
||||
assert result.title == "RTX 4090 FE"
|
||||
assert result.price == 950.00
|
||||
|
||||
|
||||
def test_save_and_get_market_comp(store):
|
||||
comp = MarketComp(
|
||||
platform="ebay",
|
||||
query_hash="abc123",
|
||||
median_price=1050.0,
|
||||
sample_count=12,
|
||||
expires_at="2026-03-26T00:00:00",
|
||||
)
|
||||
store.save_market_comp(comp)
|
||||
result = store.get_market_comp("ebay", "abc123")
|
||||
assert result is not None
|
||||
assert result.median_price == 1050.0
|
||||
|
||||
|
||||
def test_get_market_comp_returns_none_for_expired(store):
|
||||
comp = MarketComp(
|
||||
platform="ebay",
|
||||
query_hash="expired",
|
||||
median_price=900.0,
|
||||
sample_count=5,
|
||||
expires_at="2020-01-01T00:00:00", # past
|
||||
)
|
||||
store.save_market_comp(comp)
|
||||
result = store.get_market_comp("ebay", "expired")
|
||||
assert result is None
|
||||
Loading…
Reference in a new issue