Files
xenia-pos-cloud/cloud_backend/routers/menu.py
bonamin 7a53cc3d66 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>
2026-07-19 20:57:00 +03:00

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}