feat(api): GET /api/products/export — streamed canonical CSV, 409 empty_catalog (§6.4, PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -17,7 +17,7 @@ from typing import Any
|
|||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -374,6 +374,27 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
|||||||
_account, sf = gate
|
_account, sf = gate
|
||||||
return products.summary(conn, sf.id)
|
return products.summary(conn, sf.id)
|
||||||
|
|
||||||
|
@app.get("/api/products/export")
|
||||||
|
def export_products(
|
||||||
|
status: str = Query(default="all", pattern="^(all|active|draft|archived)$"),
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
try:
|
||||||
|
stream = products.export_catalog(conn, sf.id, status)
|
||||||
|
except products.EmptyCatalog:
|
||||||
|
return _error(409, "empty_catalog", "There are no products to export.")
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/api/products/sample.csv")
|
@app.get("/api/products/sample.csv")
|
||||||
def products_sample_csv():
|
def products_sample_csv():
|
||||||
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
|
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
|
||||||
|
|||||||
@@ -93,3 +93,43 @@ def test_sample_csv_imports_clean(fresh_db_url):
|
|||||||
sample = client.get("/api/products/sample.csv").content
|
sample = client.get("/api/products/sample.csv").content
|
||||||
body = _upload(client, sample, "sample.csv").json()
|
body = _upload(client, sample, "sample.csv").json()
|
||||||
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2
|
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_returns_canonical_csv(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
draft = _upload(client).json()
|
||||||
|
client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
|
||||||
|
resp = client.get("/api/products/export")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("text/csv")
|
||||||
|
assert "attachment" in resp.headers["content-disposition"]
|
||||||
|
body = resp.text
|
||||||
|
assert body.splitlines()[0].startswith("Handle,")
|
||||||
|
assert "moon-mug" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_status_filter_respected(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
# GOOD_CSV's moon-mug has no Status column → defaults to active.
|
||||||
|
draft = _upload(client).json()
|
||||||
|
client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
|
||||||
|
assert "moon-mug" in client.get("/api/products/export?status=active").text
|
||||||
|
# No archived products → 409.
|
||||||
|
assert client.get("/api/products/export?status=archived").status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_empty_catalog_409(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
resp = client.get("/api/products/export")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.json()["error"]["code"] == "empty_catalog"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_requires_merchant(fresh_db_url):
|
||||||
|
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||||
|
assert client.get("/api/products/export").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_bad_status_422(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
assert client.get("/api/products/export?status=bogus").status_code == 422
|
||||||
|
|||||||
Reference in New Issue
Block a user