feat: receive and serve product images pushed from local sites
New POST /api/menu/sync-image (site-authenticated) accepts a product's
image file, stores it under /app/data/product_images keyed by
(site, product_id), and skips the write entirely if the uploaded
content hash matches what's already stored. GET /api/menu/{site_slug}
now injects the cloud-hosted image URL into each product that has no
manual digital_image_url override — replacing the local-only image_url
from the snapshot, which was never reachable from the public internet.
Also fixes the menu-app resolving product image URLs as bare relative
paths instead of prefixing them with the cloud API origin (same class
of bug as the earlier header-image fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import models.menu_snapshot # noqa: F401
|
||||
import models.online_order # noqa: F401
|
||||
import models.manager_account # noqa: F401
|
||||
import models.stats_snapshot # noqa: F401
|
||||
import models.product_image # noqa: F401
|
||||
|
||||
from routers import auth, sites, heartbeat
|
||||
from routers import menu as menu_router
|
||||
@@ -90,6 +91,9 @@ app.include_router(remote_dashboard_router.router,prefix="/api/remote", tags=
|
||||
os.makedirs("/app/data/site_headers", exist_ok=True)
|
||||
app.mount("/static/site_headers", StaticFiles(directory="/app/data/site_headers"), name="site_headers")
|
||||
|
||||
os.makedirs("/app/data/product_images", exist_ok=True)
|
||||
app.mount("/static/product_images", StaticFiles(directory="/app/data/product_images"), name="product_images")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
|
||||
19
cloud_backend/models/product_image.py
Normal file
19
cloud_backend/models/product_image.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class ProductImage(Base):
|
||||
"""Cloud-hosted copy of a local product's image, pushed by local_backend
|
||||
during menu sync. Used as a fallback on the public QR menu when the
|
||||
product has no digital_image_url override set."""
|
||||
__tablename__ = "product_images"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, index=True)
|
||||
product_id = Column(Integer, nullable=False)
|
||||
image_url = Column(String, nullable=False)
|
||||
image_hash = Column(String, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (UniqueConstraint("site_id", "product_id", name="uq_product_images_site_product"),)
|
||||
@@ -1,15 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
import os
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, UploadFile, File, Form, status
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.site import Site
|
||||
from models.menu_snapshot import MenuSnapshot
|
||||
from models.product_image import ProductImage
|
||||
from schemas.menu import MenuSyncRequest, MenuSyncResponse
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
PRODUCT_IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _require_site(
|
||||
x_site_id: str = Header(..., alias="X-Site-ID"),
|
||||
@@ -45,6 +50,20 @@ def get_menu(site_slug: str, db: Session = Depends(get_db)):
|
||||
"hours": site.menu_hours,
|
||||
"headerImageUrl": site.menu_header_image_url,
|
||||
}
|
||||
|
||||
cloud_images = {
|
||||
img.product_id: img.image_url
|
||||
for img in db.query(ProductImage).filter(ProductImage.site_id == site.id).all()
|
||||
}
|
||||
for cat in data.get("categories", []):
|
||||
for product in cat.get("products", []):
|
||||
# image_url as pushed by local_backend points at the restaurant's own
|
||||
# LAN/local server and isn't reachable from the internet — always
|
||||
# replace it with the cloud-hosted copy (or None) unless a manual
|
||||
# digital_image_url override is set.
|
||||
if not product.get("digital_image_url"):
|
||||
product["image_url"] = cloud_images.get(product.get("id"))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -61,3 +80,54 @@ def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Se
|
||||
db.add(snapshot)
|
||||
db.commit()
|
||||
return MenuSyncResponse(ok=True)
|
||||
|
||||
|
||||
@router.post("/sync-image")
|
||||
async def sync_product_image(
|
||||
product_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
site: Site = Depends(_require_site),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload/replace the cloud-hosted copy of a product's image. Called by
|
||||
local_backend during menu sync for products whose image changed."""
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
contents = await file.read()
|
||||
|
||||
import hashlib
|
||||
image_hash = hashlib.sha256(contents).hexdigest()
|
||||
|
||||
record = (
|
||||
db.query(ProductImage)
|
||||
.filter(ProductImage.site_id == site.id, ProductImage.product_id == product_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
os.makedirs(PRODUCT_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
if record and record.image_hash == image_hash:
|
||||
return {"ok": True, "image_url": record.image_url, "unchanged": True}
|
||||
|
||||
if record:
|
||||
old_path = os.path.join(PRODUCT_IMAGE_DIR, os.path.basename(record.image_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "jpg"
|
||||
filename = f"{site.id}_{product_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
filepath = os.path.join(PRODUCT_IMAGE_DIR, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
image_url = f"/static/product_images/{filename}"
|
||||
if record:
|
||||
record.image_url = image_url
|
||||
record.image_hash = image_hash
|
||||
else:
|
||||
record = ProductImage(site_id=site.id, product_id=product_id, image_url=image_url, image_hash=image_hash)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "image_url": image_url, "unchanged": False}
|
||||
|
||||
@@ -54,6 +54,13 @@ const I18N = {
|
||||
},
|
||||
}
|
||||
|
||||
// Resolve a possibly-relative path (e.g. "/static/product_images/x.jpg") against
|
||||
// the cloud API origin. Absolute URLs (manager-set external overrides) pass through untouched.
|
||||
function resolveImageUrl(url) {
|
||||
if (!url) return null
|
||||
return /^https?:\/\//i.test(url) ? url : `${CLOUD_URL}${url}`
|
||||
}
|
||||
|
||||
// ── Normalise a backend product to the shape the UI expects ──────────────────
|
||||
function normaliseProduct(p, catId) {
|
||||
return {
|
||||
@@ -68,7 +75,7 @@ function normaliseProduct(p, catId) {
|
||||
allergens: p.allergens || [],
|
||||
ingredients: p.ingredients || null,
|
||||
discountPct: p.digital_discount || p.discountPct || 0,
|
||||
image_url: p.digital_image_url || p.image_url || null,
|
||||
image_url: resolveImageUrl(p.digital_image_url || p.image_url || null),
|
||||
digital_available: p.digital_available !== false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user