From 906bc87c9692e63313ef603e9a5d871dfb8466e7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 00:15:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(products):=20export=20hosted=20detail=20UR?= =?UTF-8?q?L=20for=20fetched=20images;=20snapshot=20carries=20status=20(SD?= =?UTF-8?q?-0002=20=C2=A76.5.5,=20INV-16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/domains/products/diff.py | 1 + backend/app/domains/products/repo.py | 5 +++-- backend/app/domains/products/serialize.py | 18 ++++++++++++------ backend/app/domains/products/service.py | 4 ++-- backend/tests/test_products_serialize.py | 18 ++++++++++++++++++ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/app/domains/products/diff.py b/backend/app/domains/products/diff.py index 3dc4b3c..556e450 100644 --- a/backend/app/domains/products/diff.py +++ b/backend/app/domains/products/diff.py @@ -44,6 +44,7 @@ class CatalogImage: source_url: str position: int alt_text: str | None + status: str = "pending" @dataclass diff --git a/backend/app/domains/products/repo.py b/backend/app/domains/products/repo.py index 467f651..2a5158a 100644 --- a/backend/app/domains/products/repo.py +++ b/backend/app/domains/products/repo.py @@ -91,7 +91,7 @@ def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, Cata ) ) for row in conn.execute( - "SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text" + "SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text, i.status" " FROM product_image i" " JOIN product p ON p.id = i.product_id" " WHERE p.storefront_id = %s" @@ -99,7 +99,8 @@ def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, Cata (storefront_id,), ): by_id[row[0]].images.append( - CatalogImage(id=row[1], source_url=row[2], position=row[3], alt_text=row[4]) + CatalogImage(id=row[1], source_url=row[2], position=row[3], + alt_text=row[4], status=row[5]) ) return catalog diff --git a/backend/app/domains/products/serialize.py b/backend/app/domains/products/serialize.py index d085fd4..5e053ff 100644 --- a/backend/app/domains/products/serialize.py +++ b/backend/app/domains/products/serialize.py @@ -14,6 +14,7 @@ from collections.abc import Iterable, Iterator from decimal import Decimal from .diff import CatalogProduct +from .hosted import EXPORT_RENDITION, image_url from .models import ( IMAGE_COLUMNS, OPTION_VALUE_COLUMNS, @@ -51,14 +52,14 @@ def _cell(value: object) -> str: return str(value) -def catalog_to_csv(products: Iterable[CatalogProduct]) -> Iterator[str]: +def catalog_to_csv(products: Iterable[CatalogProduct], base_url: str = "") -> Iterator[str]: """Stream canonical CSV text, header first, one product block at a time.""" buf = io.StringIO() writer = csv.writer(buf) writer.writerow(HEADER) yield _drain(buf) for product in products: - for row in _product_rows(product): + for row in _product_rows(product, base_url): writer.writerow([row.get(col, "") for col in HEADER]) yield _drain(buf) @@ -70,7 +71,7 @@ def _drain(buf: io.StringIO) -> str: return text -def _product_rows(product: CatalogProduct) -> list[dict[str, str]]: +def _product_rows(product: CatalogProduct, base_url: str = "") -> list[dict[str, str]]: """The product's CSV rows (§6.5.1 grammar): product fields + option names on the first row; one variant per row; images interleaved; image-only rows when a product has more images than variants.""" @@ -90,13 +91,18 @@ def _product_rows(product: CatalogProduct) -> list[dict[str, str]]: if i < len(product.variants): _write_variant(row, product, product.variants[i]) if i < len(product.images): - _write_image(row, product.images[i]) + _write_image(row, product.images[i], base_url) rows.append(row) return rows -def _write_image(row: dict[str, str], image) -> None: - row["Image Src"] = image.source_url +def _write_image(row: dict[str, str], image, base_url: str) -> None: + # Fetched images export the platform-hosted URL (INV-12/16); everything + # else keeps the original source_url so nothing is lost (§6.5.5). + if image.status == "fetched": + row["Image Src"] = image_url(base_url, image.id, EXPORT_RENDITION) + else: + row["Image Src"] = image.source_url row["Image Position"] = str(image.position) if image.alt_text is not None: row["Image Alt Text"] = image.alt_text diff --git a/backend/app/domains/products/service.py b/backend/app/domains/products/service.py index ba72f5a..fe377e4 100644 --- a/backend/app/domains/products/service.py +++ b/backend/app/domains/products/service.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone import psycopg -from app.platform import telemetry +from app.platform import config, telemetry from . import codec, diff, repo, serialize, validate from .errors import ( @@ -242,7 +242,7 @@ def export_catalog( raise EmptyCatalog() def _stream() -> Iterator[str]: - yield from serialize.catalog_to_csv(snapshot) + yield from serialize.catalog_to_csv(snapshot, base_url=config.public_base_url()) telemetry.emit( "catalog_exported", storefront_id=storefront_id, diff --git a/backend/tests/test_products_serialize.py b/backend/tests/test_products_serialize.py index 77ca11f..3dc56a4 100644 --- a/backend/tests/test_products_serialize.py +++ b/backend/tests/test_products_serialize.py @@ -104,6 +104,24 @@ def test_more_images_than_variants_emits_image_only_rows(): assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"] +def test_fetched_image_serializes_hosted_detail_url(): + product = CatalogProduct( + id=1, handle="lamp", title="Lamp", option_names=(None, None, None), + fields={"title": "Lamp", "status": "active"}, + variants=[CatalogVariant(id=1, options=(None, None, None), position=1, fields={})], + images=[ + CatalogImage(id=55, source_url="https://m.example/a.png", position=1, + alt_text=None, status="fetched"), + CatalogImage(id=56, source_url="https://m.example/b.png", position=2, + alt_text=None, status="failed"), + ], + ) + rows = list(serialize._product_rows(product, base_url="https://shop.test")) + srcs = [r.get("Image Src") for r in rows if r.get("Image Src")] + assert "https://shop.test/api/products/images/55/detail" in srcs # fetched -> hosted + assert "https://m.example/b.png" in srcs # failed -> source kept + + import random from app.domains.products import codec, diff, validate