b1: api — /img/{id}?i=n gallery chain (mirrored file -> CDN 302 -> 404; i=0 legacy), payload n_photos + last_photo_idx on deck/list/listing/section rows, POST /api/swipe persists optional photo_idx

This commit is contained in:
Torres
2026-11-11 00:04:10 -04:00
parent 4ca4cacda0
commit 604b983a51
2 changed files with 89 additions and 22 deletions

View File

@@ -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('/')

View File

@@ -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