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>
134 lines
5.2 KiB
Python
134 lines
5.2 KiB
Python
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"),
|
|
x_site_key: str = Header(..., alias="X-Site-Key"),
|
|
db: Session = Depends(get_db),
|
|
) -> Site:
|
|
site = db.query(Site).filter(Site.site_id == x_site_id).first()
|
|
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
|
|
return site
|
|
|
|
|
|
# ── Public ────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{site_slug}")
|
|
def get_menu(site_slug: str, db: Session = Depends(get_db)):
|
|
"""Return the latest menu snapshot for a site. Used by the public menu SPA."""
|
|
site = db.query(Site).filter(Site.site_id == site_slug).first()
|
|
if not site:
|
|
raise HTTPException(status_code=404, detail="Site not found")
|
|
|
|
snapshot = db.query(MenuSnapshot).filter(MenuSnapshot.site_id == site.id).first()
|
|
if not snapshot:
|
|
raise HTTPException(status_code=404, detail="No menu published yet")
|
|
|
|
import json
|
|
data = json.loads(snapshot.snapshot_json)
|
|
data["menu_mode"] = site.menu_mode
|
|
data["restaurant"] = {
|
|
"name": site.menu_display_name,
|
|
"tagline": {"en": site.menu_tagline_en, "gr": site.menu_tagline_gr},
|
|
"blurb": {"en": site.menu_blurb_en, "gr": site.menu_blurb_gr},
|
|
"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
|
|
|
|
|
|
# ── Internal (site API key) ───────────────────────────────────────────────────
|
|
|
|
@router.post("/sync", response_model=MenuSyncResponse)
|
|
def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Session = Depends(get_db)):
|
|
"""Upsert the menu snapshot for this site. Called by local_backend on each sync."""
|
|
snapshot = db.query(MenuSnapshot).filter(MenuSnapshot.site_id == body.site_id).first()
|
|
if snapshot:
|
|
snapshot.snapshot_json = body.snapshot_json
|
|
else:
|
|
snapshot = MenuSnapshot(site_id=body.site_id, snapshot_json=body.snapshot_json)
|
|
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}
|