feat: push product images to cloud for the QR menu
local_backend now uploads each product's image file to cloud_backend during the existing ~5 min menu sync, so the QR menu can show real photos without needing a manually-set digital_image_url. Only re-uploads images whose content hash changed since the last push (Product.cloud_image_hash), to avoid re-sending unchanged binaries every cycle. Also adds a manual "sync now" trigger (POST /api/system/sync-menu) and a matching button in Settings → Operation, for pushing menu/price/image changes immediately instead of waiting for the next automatic cycle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ License expiry behaviour:
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -176,6 +177,52 @@ async def _sync_once():
|
||||
license_state.update(expiry_fields)
|
||||
|
||||
|
||||
IMAGE_DIR = Path("/app/data/product_images")
|
||||
|
||||
|
||||
async def _push_product_images(db, products):
|
||||
"""Upload the local image file for each product whose picture changed since
|
||||
the last successful push (tracked via Product.cloud_image_hash), so the
|
||||
public QR menu can show it without needing a manually-set digital_image_url.
|
||||
Skips products that already have a manual digital_image_url override."""
|
||||
import hashlib
|
||||
|
||||
site_numeric_id = license_state.get("site_numeric_id")
|
||||
if not site_numeric_id:
|
||||
return
|
||||
|
||||
for p in products:
|
||||
if p.digital_image_url or not p.image_url:
|
||||
continue
|
||||
|
||||
filename = os.path.basename(p.image_url)
|
||||
filepath = IMAGE_DIR / filename
|
||||
if not filepath.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
contents = filepath.read_bytes()
|
||||
image_hash = hashlib.sha256(contents).hexdigest()
|
||||
if image_hash == p.cloud_image_hash:
|
||||
continue
|
||||
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.CLOUD_URL}/api/menu/sync-image",
|
||||
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||
data={"product_id": str(p.id)},
|
||||
files={"file": (filename, contents)},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
p.cloud_image_hash = image_hash
|
||||
db.commit()
|
||||
logger.info("Pushed image for product %d to cloud", p.id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Image push failed for product %d: %s", p.id, e)
|
||||
|
||||
|
||||
async def _push_menu_snapshot():
|
||||
"""Serialize all digital-visible products+categories and POST to cloud."""
|
||||
if not settings.SITE_ID or not settings.CLOUD_URL:
|
||||
@@ -189,6 +236,7 @@ async def _push_menu_snapshot():
|
||||
try:
|
||||
categories = db.query(Category).filter(Category.parent_id == None).all()
|
||||
payload_categories = []
|
||||
all_products = []
|
||||
for cat in categories:
|
||||
products = (
|
||||
db.query(Product)
|
||||
@@ -200,6 +248,7 @@ async def _push_menu_snapshot():
|
||||
.order_by(Product.sort_order)
|
||||
.all()
|
||||
)
|
||||
all_products.extend(products)
|
||||
product_list = []
|
||||
for p in products:
|
||||
product_list.append({
|
||||
@@ -225,26 +274,28 @@ async def _push_menu_snapshot():
|
||||
"sort_order": cat.sort_order,
|
||||
"products": product_list,
|
||||
})
|
||||
|
||||
snapshot_json = json.dumps({"categories": payload_categories})
|
||||
|
||||
# Resolve numeric site_id from license state (set by heartbeat response)
|
||||
site_numeric_id = license_state.get("site_numeric_id")
|
||||
if not site_numeric_id:
|
||||
logger.debug("Menu push skipped — site_numeric_id not yet known")
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.CLOUD_URL}/api/menu/sync",
|
||||
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||
json={"site_id": site_numeric_id, "snapshot_json": snapshot_json},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
|
||||
|
||||
await _push_product_images(db, all_products)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
snapshot_json = json.dumps({"categories": payload_categories})
|
||||
|
||||
# Resolve numeric site_id from license state (set by heartbeat response)
|
||||
site_numeric_id = license_state.get("site_numeric_id")
|
||||
if not site_numeric_id:
|
||||
logger.debug("Menu push skipped — site_numeric_id not yet known")
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.CLOUD_URL}/api/menu/sync",
|
||||
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||
json={"site_id": site_numeric_id, "snapshot_json": snapshot_json},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Menu snapshot push failed: %s", e)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user