fix: add authenticated product image contract
This commit is contained in:
+80
-14
@@ -46,10 +46,11 @@ from .schemas import (
|
||||
Project,
|
||||
ProjectSummary,
|
||||
ProductDetailResponse,
|
||||
ProductImageRecord,
|
||||
ProductImageResponse,
|
||||
ProductInput,
|
||||
ProductRecord,
|
||||
ProductVersionArchiveResponse,
|
||||
ProductVersionDetailResponse,
|
||||
ProductWordcloudArchiveResponse,
|
||||
Template,
|
||||
WordLocation,
|
||||
@@ -1148,6 +1149,19 @@ def _product_archive_service() -> ProductArchiveService:
|
||||
)
|
||||
|
||||
|
||||
def _product_image_response(row: sqlite3.Row) -> ProductImageResponse:
|
||||
return ProductImageResponse(
|
||||
image_id=row["image_id"],
|
||||
product_id=row["product_id"],
|
||||
version_id=row["version_id"],
|
||||
image_path=row["image_path"],
|
||||
image_type=row["image_type"],
|
||||
is_cover=bool(row["is_cover"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
image_url=f"/api/products/{row['product_id']}/images/{row['image_id']}",
|
||||
)
|
||||
|
||||
|
||||
def _version_archive_response(version_id: str) -> ProductVersionArchiveResponse:
|
||||
row = product_archive_store._fetchone(
|
||||
"SELECT * FROM product_versions WHERE version_id = ?", (version_id,)
|
||||
@@ -1172,32 +1186,50 @@ def _version_archive_response(version_id: str) -> ProductVersionArchiveResponse:
|
||||
(version_id,),
|
||||
)
|
||||
]
|
||||
preview_image = product_archive_store._fetchone(
|
||||
"""SELECT * FROM product_images
|
||||
WHERE product_id = ? AND version_id = ? AND image_type = 'design_preview'
|
||||
ORDER BY created_at DESC, image_id DESC LIMIT 1""",
|
||||
(row["product_id"], version_id),
|
||||
)
|
||||
design_preview_path = (
|
||||
str(preview_image["image_path"])
|
||||
if preview_image and preview_image["image_path"]
|
||||
else str(PRODUCT_ARCHIVES_DIR / row["product_id"] / version_id / "design-preview.png")
|
||||
)
|
||||
return ProductVersionArchiveResponse(
|
||||
version_id=row["version_id"],
|
||||
product_id=row["product_id"],
|
||||
version=row["version"],
|
||||
metadata=json.loads(row["metadata"] or "{}"),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
design_preview_path=str(
|
||||
PRODUCT_ARCHIVES_DIR / row["product_id"] / row["version_id"] / "design-preview.png"
|
||||
),
|
||||
design_preview_path=design_preview_path,
|
||||
wordcloud_count=len(archives),
|
||||
wordcloud_archives=archives,
|
||||
)
|
||||
|
||||
|
||||
def _version_detail_response(version_id: str) -> ProductVersionDetailResponse:
|
||||
response = _version_archive_response(version_id)
|
||||
row = product_archive_store._fetchone(
|
||||
"""SELECT * FROM product_images
|
||||
WHERE product_id = ? AND version_id = ? AND image_type = 'design_preview'
|
||||
ORDER BY created_at DESC, image_id DESC LIMIT 1""",
|
||||
(response.product_id, version_id),
|
||||
)
|
||||
return ProductVersionDetailResponse(
|
||||
**response.model_dump(),
|
||||
design_preview_image_id=row["image_id"] if row else None,
|
||||
design_preview_url=(
|
||||
f"/api/products/{response.product_id}/images/{row['image_id']}" if row else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _product_detail_response(product_id: str) -> ProductDetailResponse:
|
||||
product = _get_product_or_404(product_id)
|
||||
images = [
|
||||
ProductImageRecord(
|
||||
image_id=row["image_id"],
|
||||
product_id=row["product_id"],
|
||||
version_id=row["version_id"],
|
||||
image_path=row["image_path"],
|
||||
image_type=row["image_type"],
|
||||
is_cover=bool(row["is_cover"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
)
|
||||
_product_image_response(row)
|
||||
for row in product_archive_store._fetchall(
|
||||
"""SELECT * FROM product_images
|
||||
WHERE product_id = ? ORDER BY created_at, image_id""",
|
||||
@@ -1205,7 +1237,7 @@ def _product_detail_response(product_id: str) -> ProductDetailResponse:
|
||||
)
|
||||
]
|
||||
versions = [
|
||||
_version_archive_response(row["version_id"])
|
||||
_version_detail_response(row["version_id"])
|
||||
for row in product_archive_store._fetchall(
|
||||
"""SELECT version_id FROM product_versions
|
||||
WHERE product_id = ? ORDER BY created_at, version_id""",
|
||||
@@ -1215,6 +1247,40 @@ def _product_detail_response(product_id: str) -> ProductDetailResponse:
|
||||
return ProductDetailResponse(**product.model_dump(), images=images, versions=versions)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/products/{product_id}/images/{image_id}",
|
||||
response_class=FileResponse,
|
||||
)
|
||||
def get_product_image(
|
||||
request: Request,
|
||||
product_id: str,
|
||||
image_id: str,
|
||||
) -> FileResponse:
|
||||
_require_orders_auth(request)
|
||||
_get_product_or_404(product_id)
|
||||
row = product_archive_store._fetchone(
|
||||
"SELECT * FROM product_images WHERE product_id = ? AND image_id = ?",
|
||||
(product_id, image_id),
|
||||
)
|
||||
if row is None or not row["image_path"]:
|
||||
raise HTTPException(status_code=404, detail="product image not found")
|
||||
|
||||
image_path = Path(row["image_path"]).resolve()
|
||||
archive_root = PRODUCT_ARCHIVES_DIR.resolve()
|
||||
if not image_path.is_relative_to(archive_root) or not image_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="product image not found")
|
||||
|
||||
media_types = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
media_type = media_types.get(image_path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(image_path, media_type=media_type, filename=image_path.name)
|
||||
|
||||
|
||||
@app.post("/api/products", response_model=ProductRecord, status_code=201)
|
||||
def create_product(request: Request, product: ProductInput) -> ProductRecord:
|
||||
_require_orders_auth(request)
|
||||
|
||||
@@ -89,9 +89,21 @@ class ProductVersionArchiveResponse(ProductVersionRecord):
|
||||
wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProductImageResponse(ProductImageRecord):
|
||||
image_url: str = ""
|
||||
|
||||
|
||||
class ProductVersionDetailResponse(ProductVersionRecord):
|
||||
design_preview_image_id: str | None = None
|
||||
design_preview_url: str = ""
|
||||
design_preview_path: str = ""
|
||||
wordcloud_count: int = 0
|
||||
wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProductDetailResponse(ProductRecord):
|
||||
images: list[ProductImageRecord] = Field(default_factory=list)
|
||||
versions: list[ProductVersionArchiveResponse] = Field(default_factory=list)
|
||||
images: list[ProductImageResponse] = Field(default_factory=list)
|
||||
versions: list[ProductVersionDetailResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class JobCreateResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user