diff --git a/app/api.py b/app/api.py index 2aa9f47..63cba69 100644 --- a/app/api.py +++ b/app/api.py @@ -12,6 +12,9 @@ on; frozen in the task brief): 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} +B1 additions (UI-SPEC §11 / DATA-MODEL "B1 additions"): + n_photos (int), last_photo_idx (int|null); GET /img/{id}?i=n serves + gallery photo n. """ import datetime import json @@ -42,7 +45,7 @@ DECK_WHERE = ( ) DECK_SELECT = ( - "SELECT l.*, s.action AS decision" + "SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx" " FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id" " WHERE " + DECK_WHERE ) @@ -128,10 +131,21 @@ def count_deck_after(app, score, first_seen, listing_id): 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'] + leg = os.path.join(img_dir, '%d.webp' % row['id']) + b1 = os.path.join(img_dir, '%d_0.webp' % row['id']) + has_photo = (bool(row['img_url']) or os.path.exists(leg) + or os.path.exists(b1)) + img = '/img/%d' % row['id'] if has_photo else None + # B1: gallery size + swipe-time photo index (UI-SPEC §11 / DATA-MODEL B1) + imgs = [] + if isinstance(row['imgs'], str) and row['imgs']: + try: + imgs = json.loads(row['imgs']) + except ValueError: + imgs = [] + if not isinstance(imgs, list): + imgs = [] + n_photos = len(imgs) if imgs else (1 if has_photo else 0) extras = row['extras'] if isinstance(extras, str): try: @@ -148,6 +162,9 @@ def build_payload(img_dir): 'region': row['region'], 'url': row['url'], 'img': img, + 'n_photos': n_photos, + 'last_photo_idx': (row['last_photo_idx'] + if 'last_photo_idx' in row.keys() else None), 'desc': row['desc'], 'family': row['family'], 'score': row['score'], @@ -290,6 +307,15 @@ def create_app(db_path, img_dir, state_dir=None): section_id = body.get('section_id') if not isinstance(section_id, int): section_id = None + # B1: optional photo index on screen at swipe time (§11.5 rotation). + has_photo_idx = 'photo_idx' in body + photo_idx = body.get('photo_idx') + if has_photo_idx and photo_idx is not None: + if (isinstance(photo_idx, bool) or not isinstance(photo_idx, int) + or photo_idx < 0): + raise HTTPException( + status_code=400, + detail='photo_idx must be a non-negative int (or null)') conn = get_conn(app) try: row = conn.execute('SELECT id FROM listings WHERE id=?', @@ -297,19 +323,32 @@ def create_app(db_path, img_dir, state_dir=None): 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)) + if has_photo_idx: + # explicit photo_idx (int or null) overwrites; an absent key + # preserves the stored index (list edits etc.) + conn.execute( + '''INSERT INTO swipes (listing_id, action, created_at, + updated_at, last_photo_idx) + VALUES (?,?,?,?,?) + ON CONFLICT(listing_id) DO UPDATE SET + action=excluded.action, updated_at=excluded.updated_at, + last_photo_idx=excluded.last_photo_idx''', + (listing_id, action, now, now, photo_idx)) + else: + 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)) # P3: append the learning event (round = prior events + 1) round_ = dbmod.append_event(conn, listing_id, action, section_id, now) conn.commit() stored = conn.execute( - 'SELECT listing_id, action, created_at, updated_at' - ' FROM swipes WHERE listing_id=?', (listing_id,)).fetchone() + 'SELECT listing_id, action, created_at, updated_at,' + ' last_photo_idx FROM swipes WHERE listing_id=?', + (listing_id,)).fetchone() result = dict(stored) result['round'] = round_ result['section_id'] = section_id @@ -353,7 +392,8 @@ def create_app(db_path, img_dir, state_dir=None): ' 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' + 'SELECT l.*, s.action AS decision,' + ' s.last_photo_idx AS last_photo_idx 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() @@ -368,7 +408,8 @@ def create_app(db_path, img_dir, state_dir=None): try: engine = secmod.build_engine(conn, now=utcnow()) row = conn.execute( - 'SELECT l.*, s.action AS decision FROM listings l' + 'SELECT l.*, s.action AS decision,' + ' s.last_photo_idx AS last_photo_idx FROM listings l' ' LEFT JOIN swipes s ON s.listing_id = l.id' ' WHERE l.id=?', (listing_id,)).fetchone() if not row: @@ -625,18 +666,43 @@ def create_app(db_path, img_dir, state_dir=None): # -- images + static ------------------------------------------------- @app.get('/img/{listing_id}') - def img(listing_id: int): - fpath = os.path.join(app.state.img_dir, '%d.webp' % listing_id) + def img(listing_id: int, i: int = Query(0, ge=0)): + """Photo n of a gallery (B1 §11.1): mirrored file -> CDN 302 -> 404. + + i=0 keeps the legacy chain ({id}.webp, with {id}_0.webp accepted as + the gallery-mirror name for the primary); i>0 serves {id}_{n}.webp + with the CDN fallback taken from imgs[n]. + """ + fpath = os.path.join( + app.state.img_dir, + '%d.webp' % listing_id if i == 0 else + '%d_%d.webp' % (listing_id, i)) + if i == 0 and not os.path.exists(fpath): + alt = os.path.join(app.state.img_dir, '%d_0.webp' % listing_id) + if os.path.exists(alt): + fpath = alt 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() + row = conn.execute( + 'SELECT img_url, imgs 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) + url = None + if row: + if i == 0 and row['img_url']: + url = row['img_url'] + elif row['imgs']: + try: + arr = json.loads(row['imgs']) + except ValueError: + arr = [] + if isinstance(arr, list) and len(arr) > i and arr[i]: + url = arr[i] + if url: + return RedirectResponse(url=url, status_code=302) raise HTTPException(status_code=404, detail='no image') @app.get('/') diff --git a/app/sections.py b/app/sections.py index 73b6fa2..a4f407a 100644 --- a/app/sections.py +++ b/app/sections.py @@ -416,7 +416,8 @@ def close_section(conn, section, now, status='closed', def section_items(conn, section): return conn.execute( - """SELECT l.*, s.action AS decision, si.slot, si.position + """SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx, + si.slot, si.position FROM section_items si JOIN listings l ON l.id = si.listing_id LEFT JOIN swipes s ON s.listing_id = l.id