diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..64a54ce --- /dev/null +++ b/app/api.py @@ -0,0 +1,405 @@ +"""Bike-Tinder API (Phase 2) — FastAPI app factory. + + create_app(db_path, img_dir, state_dir=None) -> FastAPI + +The factory keeps every path injectable so the selfcheck can run the real +router against a temp copy of the DB (`fastapi.testclient.TestClient`). +Serve via app/serve.py (uvicorn) — see docs/DESIGN.md for the surface. + +Payload contract (deck/list/listing items — exact field names the FE relies +on; frozen in the task brief): + {id, title, price_display, price_num, km, location, region ('ns'|'nb'), + url, img ("/img/{id}" if img_url or cached file exists else null), desc, + family, score, year, seller_type, extras (array of strings), + decision (null|'like'|'maybe'|'nope'), first_seen} +""" +import json +import os + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse + +from . import db as dbmod +from . import importer as imp + +ACTIONS = ('like', 'nope', 'maybe') + +# --- SQL ------------------------------------------------------------------ + +DECK_WHERE = ( + "l.status = 'active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" + " AND s.action = 'nope')" + " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" + " AND s.action = 'like')" + " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" + " AND s.action = 'maybe'" + " AND COALESCE(s.updated_at, s.created_at)" + " > datetime('now', '-7 days'))" +) + +DECK_SELECT = ( + "SELECT l.*, s.action AS decision" + " FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id" + " WHERE " + DECK_WHERE +) + +DECK_ORDER = " ORDER BY l.score DESC, COALESCE(l.first_seen,'') DESC, l.id DESC" + +LIST_SORTS = { + 'score': 'l.score DESC, COALESCE(l.first_seen,\'\') DESC, l.id DESC', + 'price_asc': 'l.price_num IS NULL ASC, l.price_num ASC, l.id DESC', + 'price_desc': 'l.price_num IS NULL ASC, l.price_num DESC, l.id DESC', + 'newest': 'COALESCE(l.first_seen,\'\') DESC, l.id DESC', + 'km': 'l.km IS NULL ASC, l.km ASC, l.id DESC', +} + +LIST_DECISIONS = { + 'all': '1=1', + 'undecided': 's.action IS NULL', + 'like': "s.action = 'like'", + 'maybe': "s.action = 'maybe'", + 'nope': "s.action = 'nope'", +} + +REGION_NAMES = {'ns': 'NS', 'nb': 'NB'} + + +# --- helpers -------------------------------------------------------------- + +def get_conn(app): + conn = dbmod.connect(app.state.db_path) + dbmod.init_db(conn) + return conn + + +def set_pref(conn, key, value): + conn.execute( + "INSERT INTO prefs (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value)) + + +def get_pref(conn, key, default=None): + row = conn.execute('SELECT value FROM prefs WHERE key=?', (key,)).fetchone() + return row['value'] if row else default + + +def parse_cursor(cursor): + """Opaque '||' -> (int, str, int) or None.""" + if not cursor: + return None + try: + score, first_seen, listing_id = cursor.split('|', 2) + return int(score), first_seen, int(listing_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail='bad cursor') + + +def cursor_clause(): + """Strictly-after in (score, first_seen, id) DESC tuple order.""" + return (" AND (l.score < ? OR" + " (l.score = ? AND (COALESCE(l.first_seen,'') < ? OR" + " (COALESCE(l.first_seen,'') = ? AND l.id < ?))))") + + +def count_deck_after(app, score, first_seen, listing_id): + conn = get_conn(app) + try: + n = conn.execute( + 'SELECT COUNT(*) AS n FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE ' + DECK_WHERE + cursor_clause(), + (score, score, first_seen, first_seen, listing_id)).fetchone()['n'] + return n + finally: + conn.close() + + +def build_payload(img_dir): + def _payload(conn, row): + img = None + if row['img_url'] or os.path.exists( + os.path.join(img_dir, '%d.webp' % row['id'])): + img = '/img/%d' % row['id'] + extras = row['extras'] + if isinstance(extras, str): + try: + extras = json.loads(extras) + except ValueError: + extras = [] + return { + 'id': row['id'], + 'title': row['title'], + 'price_display': row['price_display'], + 'price_num': row['price_num'], + 'km': row['km'], + 'location': row['location'], + 'region': row['region'], + 'url': row['url'], + 'img': img, + 'desc': row['desc'], + 'family': row['family'], + 'score': row['score'], + 'year': row['year'], + 'seller_type': row['seller_type'], + 'extras': extras or [], + 'decision': row['decision'] if 'decision' in row.keys() else None, + 'first_seen': row['first_seen'], + } + return _payload + + +# --- app factory ---------------------------------------------------------- + +def create_app(db_path, img_dir, state_dir=None): + if state_dir is None: + state_dir = os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d') + os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True) + os.makedirs(os.path.abspath(img_dir), exist_ok=True) + static_dir = os.path.join(os.path.dirname(__file__), 'static') + icons_dir = os.path.join(static_dir, 'icons') + + app = FastAPI(title='Bike-Tinder') + app.state.db_path = os.path.abspath(db_path) + app.state.img_dir = os.path.abspath(img_dir) + app.state.state_dir = state_dir + app.state.static_dir = static_dir + app.state.icons_dir = icons_dir + payload = build_payload(app.state.img_dir) + + # -- meta ----------------------------------------------------------- + + @app.get('/api/health') + def health(): + conn = get_conn(app) + try: + counts = {} + for status in ('active', 'gone', 'dup'): + counts[status] = conn.execute( + 'SELECT COUNT(*) AS n FROM listings WHERE status=?', + (status,)).fetchone()['n'] + counts['total'] = sum(counts.values()) + return {'ok': True, + 'db': os.path.basename(app.state.db_path), + 'counts': counts} + finally: + conn.close() + + @app.get('/api/stats') + def stats(): + conn = get_conn(app) + try: + live = conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='active'" + ).fetchone()['n'] + badge = get_pref(conn, 'badge_seen_at') or '' + new = conn.execute( + "SELECT COUNT(*) AS n FROM listings" + " WHERE status='active' AND COALESCE(first_seen,'') > ?", + (badge,)).fetchone()['n'] + per = {} + for action in ('like', 'maybe', 'nope'): + per[action] = conn.execute( + 'SELECT COUNT(*) AS n FROM swipes WHERE action=?', + (action,)).fetchone()['n'] + last = conn.execute( + 'SELECT started_at, ok FROM import_runs' + ' ORDER BY id DESC LIMIT 1').fetchone() + return { + 'live': live, + 'new': new, + 'likes': per['like'], + 'maybes': per['maybe'], + 'nopes': per['nope'], + 'last_import': {'at': last['started_at'], 'ok': bool(last['ok'])} + if last else None, + } + finally: + conn.close() + + # -- deck ----------------------------------------------------------- + + @app.get('/api/deck') + def deck(limit: int = Query(40, ge=1, le=100), + cursor: str = Query(None)): + cur = parse_cursor(cursor) + conn = get_conn(app) + try: + if cur is None: + rows = conn.execute(DECK_SELECT + DECK_ORDER + ' LIMIT ?', + (limit,)).fetchall() + else: + rows = conn.execute( + DECK_SELECT + cursor_clause() + DECK_ORDER + ' LIMIT ?', + (cur[0], cur[0], cur[1], cur[1], cur[2], limit)).fetchall() + items = [payload(conn, r) for r in rows] + next_cursor = None + remaining = 0 + if rows: + last = rows[-1] + if len(rows) == limit: + nc = (last['score'], last['first_seen'] or '', + last['id']) + next_cursor = '%s|%s|%s' % nc + remaining = count_deck_after(app, *nc) + return {'items': items, 'next_cursor': next_cursor, + 'remaining': remaining} + finally: + conn.close() + + # -- swipes --------------------------------------------------------- + + @app.post('/api/swipe') + def swipe(body: dict): + listing_id = body.get('listing_id') + action = body.get('action') + if not isinstance(listing_id, int) or action not in ACTIONS: + raise HTTPException(status_code=400, + detail='body must be {listing_id:int, action:like|nope|maybe}') + conn = get_conn(app) + try: + row = conn.execute('SELECT id FROM listings WHERE id=?', + (listing_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail='unknown listing') + now = imp.now_iso() + conn.execute( + '''INSERT INTO swipes (listing_id, action, created_at, updated_at) + VALUES (?,?,?,?) + ON CONFLICT(listing_id) DO UPDATE SET + action=excluded.action, updated_at=excluded.updated_at''', + (listing_id, action, now, now)) + conn.commit() + stored = conn.execute( + 'SELECT listing_id, action, created_at, updated_at' + ' FROM swipes WHERE listing_id=?', (listing_id,)).fetchone() + return dict(stored) + finally: + conn.close() + + @app.delete('/api/swipe/{listing_id}') + def swipe_clear(listing_id: int): + conn = get_conn(app) + try: + cur = conn.execute('DELETE FROM swipes WHERE listing_id=?', + (listing_id,)) + conn.commit() + return {'listing_id': listing_id, 'cleared': cur.rowcount > 0} + finally: + conn.close() + + # -- list + listing ------------------------------------------------- + + @app.get('/api/list') + def list_(decision: str = Query('all'), sort: str = Query('score'), + q: str = Query(None), limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0)): + if decision not in LIST_DECISIONS: + raise HTTPException(status_code=400, detail='bad decision filter') + if sort not in LIST_SORTS: + raise HTTPException(status_code=400, detail='bad sort') + where = ("l.status = 'active' AND " + LIST_DECISIONS[decision]) + params = [] + if q: + escaped = (q.replace('\\', '\\\\').replace('%', '\\%') + .replace('_', '\\_')) + where += " AND l.title LIKE ? ESCAPE '\\'" + params.append('%' + escaped + '%') + conn = get_conn(app) + try: + total = conn.execute( + 'SELECT COUNT(*) AS n FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE ' + where, params).fetchone()['n'] + rows = conn.execute( + 'SELECT l.*, s.action AS decision FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE ' + where + ' ORDER BY ' + LIST_SORTS[sort] + + ' LIMIT ? OFFSET ?', params + [limit, offset]).fetchall() + return {'items': [payload(conn, r) for r in rows], 'total': total} + finally: + conn.close() + + @app.get('/api/listing/{listing_id}') + def listing(listing_id: int): + conn = get_conn(app) + try: + row = conn.execute( + 'SELECT l.*, s.action AS decision FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE l.id=?', (listing_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail='unknown listing') + return payload(conn, row) + finally: + conn.close() + + # -- import --------------------------------------------------------- + + @app.post('/api/import') + def run_import_endpoint(): + try: + result = imp.run_import(app.state.state_dir, app.state.db_path) + except Exception as e: # noqa: BLE001 — surface anything to the caller + result = {'ok': False, 'errors': {'exception': str(e)}} + return JSONResponse(status_code=200, content=result) + + # -- badge ---------------------------------------------------------- + + @app.post('/api/badge/seen') + def badge_seen(): + conn = get_conn(app) + try: + now = imp.now_iso() + set_pref(conn, 'badge_seen_at', now) + conn.commit() + return {'badge_seen_at': now} + finally: + conn.close() + + # -- images + static ------------------------------------------------- + + @app.get('/img/{listing_id}') + def img(listing_id: int): + fpath = os.path.join(app.state.img_dir, '%d.webp' % listing_id) + if os.path.exists(fpath): + return FileResponse(fpath, media_type='image/webp') + conn = get_conn(app) + try: + row = conn.execute('SELECT img_url FROM listings WHERE id=?', + (listing_id,)).fetchone() + finally: + conn.close() + if row and row['img_url']: + return RedirectResponse(url=row['img_url'], status_code=302) + raise HTTPException(status_code=404, detail='no image') + + @app.get('/') + def index(): + return FileResponse( + os.path.join(static_dir, 'index.html'), + headers={'Cache-Control': 'no-cache'}) + + @app.get('/manifest.json') + def manifest(): + return FileResponse(os.path.join(static_dir, 'manifest.json')) + + @app.get('/apple-touch-icon.png') + def apple_touch(): + return FileResponse(os.path.join(icons_dir, 'apple-touch-icon.png')) + + @app.get('/sw.js') + def sw(): + return FileResponse(os.path.join(static_dir, 'sw.js'), + headers={'Cache-Control': 'no-cache'}) + + @app.get('/icons/{name}') + def icon(name: str): + if '/' in name or '..' in name: + raise HTTPException(status_code=404, detail='no such icon') + fpath = os.path.join(icons_dir, name) + if not os.path.exists(fpath): + raise HTTPException(status_code=404, detail='no such icon') + return FileResponse(fpath) + + return app diff --git a/app/serve.py b/app/serve.py new file mode 100644 index 0000000..bed2482 --- /dev/null +++ b/app/serve.py @@ -0,0 +1,38 @@ +"""Serve the Bike-Tinder app (FastAPI + uvicorn). + + .venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453 + +Defaults per the task brief: host 127.0.0.1, port 9453, DB data/bike-tinder.sqlite3, +images data/img. The import endpoint points at the hunt state dir +(~/.hermes/scripts/motorcycle-hunt.d) unless --state-dir overrides it. +""" +import argparse +import os +import sys + +from .api import create_app + + +def default_state_dir(): + return os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d') + + +def main(argv=None): + ap = argparse.ArgumentParser(description='Bike-Tinder server') + ap.add_argument('--db', default='data/bike-tinder.sqlite3') + ap.add_argument('--img-dir', default='data/img') + ap.add_argument('--state-dir', default=None, + help='hunt state dir for POST /api/import' + ' (default: ~/.hermes/scripts/motorcycle-hunt.d)') + ap.add_argument('--host', default='127.0.0.1') + ap.add_argument('--port', type=int, default=9453) + args = ap.parse_args(argv) + + import uvicorn + app = create_app(args.db, args.img_dir, + args.state_dir or default_state_dir()) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..45032c7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Bike-Tinder runtime deps (plain uvicorn — no [standard] extras needed). +fastapi +uvicorn +httpx