feat(products): image-phase repo helpers + run progress/outcomes/counts (SD-0002 §6.5.4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:23:09 -07:00
parent 984dc98dee
commit 13e74f4c61
2 changed files with 208 additions and 9 deletions
+115 -9
View File
@@ -331,16 +331,29 @@ def _run_dict(row: tuple) -> dict:
}
def list_runs(
conn: psycopg.Connection, storefront_id: int, limit: int, offset: int
) -> list[dict]:
def list_runs(conn: psycopg.Connection, storefront_id: int, limit: int, offset: int) -> list[dict]:
rows = conn.execute(
_RUN_SELECT
+ " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
_RUN_SELECT + " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
" LIMIT %s OFFSET %s",
(storefront_id, limit, offset),
).fetchall()
return [_run_dict(row) for row in rows]
runs = [_run_dict(row) for row in rows]
if not runs:
return runs
ids = [r["id"] for r in runs]
by_run = {rid: {"fetched": 0, "rejected": 0, "failed": 0, "total": 0} for rid in ids}
for rid, fetched, rejected, failed, total in conn.execute(
"SELECT import_run_id,"
" count(*) FILTER (WHERE status='fetched'),"
" count(*) FILTER (WHERE status IN ('rejected_low_res','rejected_not_image')),"
" count(*) FILTER (WHERE status='failed'), count(*)"
" FROM product_image WHERE import_run_id = ANY(%s) GROUP BY import_run_id",
(ids,),
):
by_run[rid] = {"fetched": fetched, "rejected": rejected, "failed": failed, "total": total}
for r in runs:
r["image_counts"] = by_run[r["id"]]
return runs
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None:
@@ -359,12 +372,105 @@ def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict |
(run_id,),
)
]
# SLICE-7 fills these; the §6.4 payload shape is stable from SLICE-5 on.
run["image_progress"] = {"done": 0, "total": 0}
run["image_outcomes"] = []
counts = run_image_counts(conn, run_id)
run["image_progress"] = {"done": counts["total"] - counts["pending"], "total": counts["total"]}
run["image_counts"] = {"fetched": counts["fetched"], "rejected": counts["rejected"],
"failed": counts["failed"]}
run["image_outcomes"] = run_image_outcomes(conn, run_id)
return run
# ---------------------------------------------------------------------------
# Image fetch phase (SLICE-7, SD-0002 §6.5.4) — claim/mark/count/outcomes.
# ---------------------------------------------------------------------------
def pending_images_for_run(conn: psycopg.Connection, run_id: int) -> list[dict]:
rows = conn.execute(
"SELECT i.id, i.source_url, p.storefront_id, i.product_id"
" FROM product_image i JOIN product p ON p.id = i.product_id"
" WHERE i.import_run_id = %s AND i.status = 'pending' ORDER BY i.id",
(run_id,),
).fetchall()
return [{"id": r[0], "source_url": r[1], "storefront_id": r[2], "product_id": r[3]} for r in rows]
def claim_image_for_fetch(conn: psycopg.Connection, image_id: int) -> bool:
row = conn.execute(
"UPDATE product_image SET status = 'pending' WHERE id = %s AND status = 'pending' RETURNING id",
(image_id,),
).fetchone()
return row is not None
def mark_image_fetched(conn: psycopg.Connection, image_id: int, keys: dict[str, str]) -> None:
conn.execute(
"UPDATE product_image SET status = 'fetched', failure_reason = NULL,"
" key_original = %s, key_thumb = %s, key_card = %s, key_detail = %s,"
" fetched_at = now() WHERE id = %s",
(keys["original"], keys["thumb"], keys["card"], keys["detail"], image_id),
)
def mark_image_rejected(conn: psycopg.Connection, image_id: int, status: str, reason: str) -> None:
conn.execute(
"UPDATE product_image SET status = %s, failure_reason = %s, fetched_at = now() WHERE id = %s",
(status, reason, image_id),
)
def mark_image_failed(conn: psycopg.Connection, image_id: int, reason: str) -> None:
conn.execute(
"UPDATE product_image SET status = 'failed', failure_reason = %s, fetched_at = now() WHERE id = %s",
(reason, image_id),
)
def run_image_counts(conn: psycopg.Connection, run_id: int) -> dict:
row = conn.execute(
"SELECT count(*) FILTER (WHERE status = 'fetched'),"
" count(*) FILTER (WHERE status IN ('rejected_low_res', 'rejected_not_image')),"
" count(*) FILTER (WHERE status = 'failed'),"
" count(*) FILTER (WHERE status = 'pending'), count(*)"
" FROM product_image WHERE import_run_id = %s",
(run_id,),
).fetchone()
return {"fetched": row[0], "rejected": row[1], "failed": row[2], "pending": row[3], "total": row[4]}
def set_run_status(conn: psycopg.Connection, run_id: int, status: str) -> None:
conn.execute(
"UPDATE import_run SET status = %s,"
" completed_at = CASE WHEN %s THEN now() ELSE completed_at END WHERE id = %s",
(status, status in _TERMINAL_RUN_STATUSES, run_id),
)
def incomplete_runs(conn: psycopg.Connection) -> list[dict]:
rows = conn.execute(
"SELECT id, storefront_id FROM import_run WHERE status = 'fetching_images' ORDER BY id",
).fetchall()
return [{"id": r[0], "storefront_id": r[1]} for r in rows]
def run_image_outcomes(conn: psycopg.Connection, run_id: int) -> list[dict]:
rows = conn.execute(
"SELECT p.handle, i.source_url, i.status, i.failure_reason,"
" v.option1_value, v.option2_value, v.option3_value"
" FROM product_image i JOIN product p ON p.id = i.product_id"
" LEFT JOIN variant v ON v.image_id = i.id"
" WHERE i.import_run_id = %s"
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')"
" ORDER BY p.handle, i.position, i.id",
(run_id,),
).fetchall()
return [
{"handle": r[0], "url": r[1], "outcome": r[2], "reason": r[3],
"variant": " / ".join(x for x in (r[4], r[5], r[6]) if x) or None}
for r in rows
]
# ---------------------------------------------------------------------------
# Apply primitives — called inside the confirm transaction (Task 8); no commits.
# ---------------------------------------------------------------------------