Files
Bike-Tinder/app/api.py
Torres 0d2e5a592f feat(v1.1): item 6 — dry-catalog messaging (forged batches with 0 fresh are labeled)
section_state now mirrors the composed recipe at body.recipe; the forge
note appends 'catalog fully reviewed' when the batch has zero fresh cards.
The FE shows the BACKLOG sentence in the report (dryCap) and in the forge
status line whenever recipe.new===0 with cards served; a non-dry forge
behaves exactly as before. No new fetch loops.
2026-11-13 13:39:22 -04:00

753 lines
30 KiB
Python

"""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}
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
import os
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from . import agentpass as agmod
from . import db as dbmod
from . import importer as imp
from . import sections as secmod
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, s.last_photo_idx AS last_photo_idx"
" 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 utcnow():
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
def live_configured():
return bool(os.environ.get('OPENROUTER_API_KEY'))
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 '<score>|<first_seen>|<id>' -> (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):
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:
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,
'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'],
'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
# --- P3 listing payload (additive over the P2 contract) --------------------
def build_p3_payload(img_dir):
base = build_payload(img_dir)
def _payload(conn, engine, row, slot=None, include_breakdown=False):
p = base(conn, row)
p['points'] = engine.points(row['id'])
p['tank'] = engine.tank(row['id'])
p['slot'] = slot
p['round_next'] = engine.round_next(row['id'])
p['points_breakdown'] = (engine.breakdown(row['id'])
if include_breakdown else None)
return p
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)
p3_payload = build_p3_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:
engine = secmod.build_engine(conn, now=utcnow())
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 = [p3_payload(conn, engine, 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}')
# optional P3: section this verdict happened in (NULL for list edits)
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=?',
(listing_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail='unknown listing')
now = imp.now_iso()
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,'
' last_photo_idx FROM swipes WHERE listing_id=?',
(listing_id,)).fetchone()
result = dict(stored)
result['round'] = round_
result['section_id'] = section_id
return result
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:
engine = secmod.build_engine(conn, now=utcnow())
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,'
' 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()
return {'items': [p3_payload(conn, engine, 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:
engine = secmod.build_engine(conn, now=utcnow())
row = conn.execute(
'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:
raise HTTPException(status_code=404, detail='unknown listing')
return p3_payload(conn, engine, row, include_breakdown=True)
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()
# -- P3: sections ----------------------------------------------------
def section_state(conn, sec, note=None):
"""Shared body for GET /api/section and POST /api/section/forge."""
now = utcnow()
engine = secmod.build_engine(conn, now=now)
data = secmod.read_section(conn, sec, now)
sec = conn.execute('SELECT * FROM sections WHERE id=?',
(sec['id'],)).fetchone()
mode = get_pref(conn, 'forge_mode', 'ondemand')
decided_ids = secmod.section_decided_ids(conn, sec)
items = [p3_payload(conn, engine, r, slot=r['slot'],
include_breakdown=True)
for r in data['items']]
for p in items:
# item 5: decided IN this section (real verdict), not the joined
# lifetime swipes decision — present on EVERY item so the FE's
# local-intent branch can't misfire
p['decided_this_section'] = p['id'] in decided_ids
data['items'] = items
data['mode'] = mode
data['recipe'] = (json.loads(sec['recipe']) if sec['recipe'] else {})
data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')}
data['can_forge'] = (mode != 'strict'
or secmod.fresh_profile_exists(conn, sec))
data['counts'] = secmod.section_counts(conn, sec)
prof = secmod.latest_profile(conn)
data['read_info'] = {
'events': engine.n_events,
'profile_version': prof['version'] if prof else None,
'profile_source': prof['source'] if prof else None,
'profile_at': prof['created_at'] if prof else None,
}
if note:
data['note'] = note
return data
@app.get('/api/section')
def get_section():
conn = get_conn(app)
try:
sec = secmod.get_current_section(conn)
if sec is None:
# first-ever read composes a cold section
prof = secmod.latest_profile(conn)
engine = secmod.build_engine(conn, prof, now=utcnow())
sec = secmod.compose_section(
conn, engine, size=25,
profile_version=prof['version'] if prof else None,
note='cold compose (first read)')
return section_state(conn, sec)
finally:
conn.close()
@app.post('/api/section/forge')
def forge(body: dict):
size = body.get('size', 25)
if not isinstance(size, int) or not 1 <= size <= 100:
raise HTTPException(status_code=400,
detail='size must be an int 1..100')
conn = get_conn(app)
try:
mode = get_pref(conn, 'forge_mode', 'ondemand')
sec = secmod.get_current_section(conn)
note = None
if mode == 'strict':
if not secmod.fresh_profile_exists(conn, sec):
raise HTTPException(
status_code=409, detail=(
'strict mode: no fresh agent pass since this '
'section — the nightly read provides it'))
note = 'strict: forged on the fresh pass'
else:
# on-demand: agent pass between sections (mock when the
# live key is not configured; live failures fall back)
src = 'live' if live_configured() else 'mock'
res = agmod.run_pass(app.state.db_path, source=src)
note = ('pass %s v%d' % (res['source'],
res['profile_version']))
if res['source'] == 'fallback':
note += ' (agent offline — last read used)'
if sec is not None:
secmod.close_section(conn, sec, utcnow())
prof = secmod.latest_profile(conn)
engine = secmod.build_engine(conn, prof, now=utcnow())
newsec = secmod.compose_section(
conn, engine, size=size,
profile_version=prof['version'] if prof else None,
note=note)
# v1.1 item 6: label the dry-catalog state on the forge note
recipe = (json.loads(newsec['recipe'])
if newsec['recipe'] else {})
if recipe.get('new') == 0:
note = '%s · catalog fully reviewed' % (note or '')
return section_state(conn, newsec, note=note)
finally:
conn.close()
# -- P3: trends ------------------------------------------------------
@app.get('/api/trends')
def trends():
conn = get_conn(app)
try:
prof = secmod.latest_profile(conn)
profile = None
if prof:
payload_prof = {}
try:
payload_prof = json.loads(prof['payload'])
except ValueError:
payload_prof = {}
maker = conn.execute(
'SELECT * FROM agent_runs WHERE profile_version=?'
' ORDER BY id DESC LIMIT 1', (prof['version'],)
).fetchone()
profile = {
'version': prof['version'],
'source': prof['source'],
'created_at': prof['created_at'],
'model': maker['model'] if maker else None,
'lead': payload_prof.get('lead', ''),
'leanings': payload_prof.get('leanings', [])[:8],
'changes': payload_prof.get('changes', []),
'chips': payload_prof.get('chips', []),
}
per = {}
for action in ('like', 'maybe', 'nope'):
per[action] = conn.execute(
'SELECT COUNT(*) AS n FROM swipes WHERE action=?',
(action,)).fetchone()['n']
decided = per['like'] + per['maybe'] + per['nope']
events = conn.execute(
'SELECT listing_id, action FROM swipe_events ORDER BY id'
).fetchall()
prev = {}
flips = 0
for ev in events:
if ev['listing_id'] in prev and prev[ev['listing_id']] != ev[
'action']:
flips += 1
prev[ev['listing_id']] = ev['action']
ret_placed = conn.execute(
"SELECT COUNT(*) AS n FROM section_items "
"WHERE slot='return'").fetchone()['n']
ret_struck = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n
FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id
AND si.listing_id = e.listing_id
WHERE si.slot='return'""").fetchone()['n']
wild_placed = conn.execute(
"SELECT COUNT(*) AS n FROM section_items "
"WHERE slot='wildcard'").fetchone()['n']
wild_hits = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n
FROM swipe_events e
JOIN section_items si
ON si.section_id = e.section_id
AND si.listing_id = e.listing_id
WHERE si.slot='wildcard' AND e.action IN ('like','maybe')"""
).fetchone()['n']
last_run = None
row = conn.execute(
'SELECT * FROM agent_runs ORDER BY id DESC LIMIT 1'
).fetchone()
if row:
last_run = {
'started_at': row['started_at'],
'finished_at': row['finished_at'],
'ok': bool(row['ok']),
'source': row['source'],
'model': row['model'],
'cost_note': row['cost_note'],
'profile_version': row['profile_version'],
}
return {
'profile': profile,
'stats': {
'decided': decided,
'likes': per['like'],
'maybes': per['maybe'],
'nopes': per['nope'],
'hit_rate': (per['like'] / decided) if decided else None,
'hit_rate_plus_maybe': (
(per['like'] + per['maybe']) / decided
if decided else None),
'returns_placed': ret_placed,
'returns_re_struck': ret_struck,
'wildcards_placed': wild_placed,
'wildcards_hit': wild_hits,
'flips': flips,
},
'last_run': last_run,
'cold_start': decided < 10,
'forge_mode': get_pref(conn, 'forge_mode', 'ondemand'),
}
finally:
conn.close()
# -- P3: agent pass trigger ------------------------------------------
@app.post('/api/agent/run')
def agent_run(body: dict):
source = body.get('source') if isinstance(body, dict) else None
if source is None:
source = 'live' if live_configured() else 'mock'
if source not in ('mock', 'live'):
raise HTTPException(status_code=400,
detail='source must be mock|live')
if source == 'live' and not live_configured():
raise HTTPException(
status_code=400,
detail='live pass not configured (OPENROUTER_API_KEY unset)')
model = body.get('model') if isinstance(body, dict) else None
res = agmod.run_pass(app.state.db_path, source=source, model=model)
return res
# -- prefs (P3 forge_mode toggle persistence) -------------------------
@app.post('/api/prefs')
def set_pref_endpoint(body: dict):
key = body.get('key')
value = body.get('value')
allowed = {'forge_mode': ('ondemand', 'strict'),
'rubric_debug': ('off', 'on')}
if key not in allowed or value not in allowed[key]:
raise HTTPException(status_code=400,
detail='unknown or invalid pref')
conn = get_conn(app)
try:
set_pref(conn, key, value)
conn.commit()
return {'key': key, 'value': value}
finally:
conn.close()
# -- images + static -------------------------------------------------
@app.get('/img/{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, imgs FROM listings WHERE id=?',
(listing_id,)).fetchone()
finally:
conn.close()
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('/')
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