SLICE-7: images pipeline end-to-end — fetch/host/serve + INV-12 over images (SD-0002 §7.2) #30
@@ -44,6 +44,7 @@ class CatalogImage:
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user