feat: preferences store, community signals, a11y + API fixes
- Store: add save_community_signal, get/set_user_preference, get_all_preferences - App.vue: move skip link before nav (correct a11y order); bootstrap preferences store after session - useTrustFeedback: prefix fetch URL with VITE_API_BASE (fixes menagerie routing) - search.ts: guard v-model.number empty-string from cleared price inputs - Python: isort import ordering pass across adapters, trust, tasks, tests
This commit is contained in:
parent
af1ffa1d94
commit
f8dd1d261d
23 changed files with 124 additions and 29 deletions
|
|
@ -26,11 +26,11 @@ from pathlib import Path
|
|||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from app.db.store import Store
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Dataclasses for all Snipe domain objects."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Thin SQLite read/write layer for all Snipe models."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -7,7 +8,7 @@ from typing import Optional
|
|||
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
|
||||
from .models import Listing, Seller, TrustScore, MarketComp, SavedSearch, ScammerEntry
|
||||
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
|
@ -381,6 +382,59 @@ class Store:
|
|||
for r in rows
|
||||
]
|
||||
|
||||
def save_community_signal(self, seller_id: str, confirmed: bool) -> None:
|
||||
"""Record a user's trust-score feedback signal into the shared DB."""
|
||||
self._conn.execute(
|
||||
"INSERT INTO community_signals (seller_id, confirmed) VALUES (?, ?)",
|
||||
(seller_id, 1 if confirmed else 0),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# --- User Preferences ---
|
||||
|
||||
def get_user_preference(self, path: str, default=None):
|
||||
"""Read a preference value at dot-separated path (e.g. 'affiliate.opt_out').
|
||||
|
||||
Reads from the singleton user_preferences row; returns *default* if the
|
||||
table is empty or the path is not set.
|
||||
"""
|
||||
from circuitforge_core.preferences.paths import get_path
|
||||
row = self._conn.execute(
|
||||
"SELECT prefs_json FROM user_preferences WHERE id=1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return default
|
||||
return get_path(json.loads(row[0]), path, default=default)
|
||||
|
||||
def set_user_preference(self, path: str, value) -> None:
|
||||
"""Write *value* at dot-separated path (immutable JSON update).
|
||||
|
||||
Creates the singleton row on first write; merges subsequent updates
|
||||
so sibling paths are preserved.
|
||||
"""
|
||||
from circuitforge_core.preferences.paths import set_path
|
||||
row = self._conn.execute(
|
||||
"SELECT prefs_json FROM user_preferences WHERE id=1"
|
||||
).fetchone()
|
||||
prefs = json.loads(row[0]) if row else {}
|
||||
updated = set_path(prefs, path, value)
|
||||
self._conn.execute(
|
||||
"INSERT INTO user_preferences (id, prefs_json, updated_at) "
|
||||
"VALUES (1, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) "
|
||||
"ON CONFLICT(id) DO UPDATE SET "
|
||||
" prefs_json = excluded.prefs_json, "
|
||||
" updated_at = excluded.updated_at",
|
||||
(json.dumps(updated),),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_all_preferences(self) -> dict:
|
||||
"""Return all preferences as a plain dict (empty dict if not yet set)."""
|
||||
row = self._conn.execute(
|
||||
"SELECT prefs_json FROM user_preferences WHERE id=1"
|
||||
).fetchone()
|
||||
return json.loads(row[0]) if row else {}
|
||||
|
||||
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 "
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""PlatformAdapter abstract base and shared types."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from app.db.models import Listing, Seller
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"""eBay Browse API adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -18,7 +20,7 @@ _SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call
|
|||
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
|
||||
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
|
||||
|
||||
from app.db.models import Listing, Seller, MarketComp
|
||||
from app.db.models import Listing, MarketComp, Seller
|
||||
from app.db.store import Store
|
||||
from app.platforms import PlatformAdapter, SearchFilters
|
||||
from app.platforms.ebay.auth import EbayTokenManager
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""eBay OAuth2 client credentials token manager."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
EBAY_OAUTH_URLS = {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""Convert raw eBay API responses into Snipe domain objects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.db.models import Listing, Seller
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from circuitforge_core.tasks.scheduler import (
|
|||
)
|
||||
from circuitforge_core.tasks.scheduler import (
|
||||
get_scheduler as _base_get_scheduler,
|
||||
reset_scheduler, # re-export for lifespan teardown
|
||||
)
|
||||
|
||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"""Five metadata trust signals, each scored 0–20."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db.models import Seller
|
||||
|
||||
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"""Perceptual hash deduplication within a result set (free tier, v0.1)."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
import io
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
"""Main search + results page."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
from circuitforge_core.config import load_env
|
||||
|
||||
from app.db.store import Store
|
||||
from app.platforms import PlatformAdapter, SearchFilters
|
||||
from app.trust import TrustScorer
|
||||
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
|
||||
from app.ui.components.listing_row import render_listing_row
|
||||
from app.ui.components.easter_eggs import (
|
||||
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
|
||||
auction_hours_remaining,
|
||||
check_snipe_mode,
|
||||
inject_steal_css,
|
||||
render_snipe_mode_banner,
|
||||
)
|
||||
from app.ui.components.filters import FilterState, build_filter_options, render_filter_sidebar
|
||||
from app.ui.components.listing_row import render_listing_row
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import streamlit as st
|
|||
|
||||
from app.db.models import Listing, TrustScore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Konami → Snipe Mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.db.models import Listing, TrustScore
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
"""Render a single listing row with trust score, badges, and error states."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.db.models import Listing, TrustScore, Seller
|
||||
from app.db.models import Listing, Seller, TrustScore
|
||||
from app.ui.components.easter_eggs import (
|
||||
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining,
|
||||
auction_hours_remaining,
|
||||
is_steal,
|
||||
render_auction_notice,
|
||||
render_steal_banner,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""First-run wizard: collect eBay credentials and write .env."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
from circuitforge_core.wizard import BaseWizard
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Streamlit entrypoint."""
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.wizard import SnipeSetupWizard
|
||||
|
||||
st.set_page_config(
|
||||
|
|
@ -16,6 +18,7 @@ if not wizard.is_configured():
|
|||
st.stop()
|
||||
|
||||
from app.ui.components.easter_eggs import inject_konami_detector
|
||||
|
||||
inject_konami_detector()
|
||||
|
||||
with st.sidebar:
|
||||
|
|
@ -27,4 +30,5 @@ with st.sidebar:
|
|||
)
|
||||
|
||||
from app.ui.Search import render
|
||||
|
||||
render(audio_enabled=audio_enabled)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import time
|
||||
import requests
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from app.platforms.ebay.auth import EbayTokenManager
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@
|
|||
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
|
||||
No HTTP requests are made — all tests operate on the pure parsing functions.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.platforms.ebay.scraper import (
|
||||
scrape_listings,
|
||||
scrape_sellers,
|
||||
_extract_seller_from_card,
|
||||
_parse_price,
|
||||
_parse_time_left,
|
||||
_extract_seller_from_card,
|
||||
scrape_listings,
|
||||
scrape_sellers,
|
||||
)
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal eBay search results HTML fixture (li.s-card schema)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ from __future__ import annotations
|
|||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from circuitforge_core.api.feedback import make_feedback_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from circuitforge_core.api.feedback import make_feedback_router
|
||||
|
||||
|
||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
||||
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
||||
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
||||
<!-- Skip to main content — must be first focusable element before the nav -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
<AppNav />
|
||||
<main class="app-main" id="main-content" tabindex="-1">
|
||||
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
|
|
@ -22,6 +22,7 @@ import { useSnipeMode } from './composables/useSnipeMode'
|
|||
import { useKonamiCode } from './composables/useKonamiCode'
|
||||
import { useSessionStore } from './stores/session'
|
||||
import { useBlocklistStore } from './stores/blocklist'
|
||||
import { usePreferencesStore } from './stores/preferences'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
import FeedbackButton from './components/FeedbackButton.vue'
|
||||
|
||||
|
|
@ -29,14 +30,16 @@ const motion = useMotion()
|
|||
const { activate, restore } = useSnipeMode()
|
||||
const session = useSessionStore()
|
||||
const blocklistStore = useBlocklistStore()
|
||||
const preferencesStore = usePreferencesStore()
|
||||
const route = useRoute()
|
||||
|
||||
useKonamiCode(activate)
|
||||
|
||||
onMounted(() => {
|
||||
restore() // re-apply snipe mode from localStorage on hard reload
|
||||
session.bootstrap() // fetch tier + feature flags from API
|
||||
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
|
||||
onMounted(async () => {
|
||||
restore() // re-apply snipe mode from localStorage on hard reload
|
||||
await session.bootstrap() // fetch tier + feature flags from API
|
||||
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
|
||||
preferencesStore.load() // load user preferences after session resolves
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ export function useTrustFeedback(sellerId: string) {
|
|||
async function submitFeedback(confirmed: boolean): Promise<void> {
|
||||
if (state.value !== 'idle') return
|
||||
state.value = 'sending'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
try {
|
||||
await fetch('/api/community/signal', {
|
||||
await fetch(`${apiBase}/api/community/signal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seller_id: sellerId, confirmed }),
|
||||
|
|
|
|||
|
|
@ -163,8 +163,11 @@ export const useSearchStore = defineStore('search', () => {
|
|||
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
const params = new URLSearchParams({ q })
|
||||
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
|
||||
if (filters.minPrice != null) params.set('min_price', String(filters.minPrice))
|
||||
// v-model.number sends empty string when a number input is cleared — guard against that
|
||||
const maxPrice = Number(filters.maxPrice)
|
||||
const minPrice = Number(filters.minPrice)
|
||||
if (Number.isFinite(maxPrice) && maxPrice > 0) params.set('max_price', String(maxPrice))
|
||||
if (Number.isFinite(minPrice) && minPrice > 0) params.set('min_price', String(minPrice))
|
||||
if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages))
|
||||
if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim())
|
||||
if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode)
|
||||
|
|
|
|||
Loading…
Reference in a new issue