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:
2026-07-19 20:57:00 +03:00
parent 6c7df8d011
commit 7a53cc3d66
4 changed files with 102 additions and 2 deletions

View File

@@ -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():

View 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"),)

View File

@@ -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}