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:
2026-07-19 20:51:52 +03:00
parent 34ae328b0d
commit 934fda9405
5 changed files with 119 additions and 18 deletions

View File

@@ -732,6 +732,8 @@ def _run_migrations():
"ALTER TABLE product_preference_sets ADD COLUMN allow_multi_select INTEGER NOT NULL DEFAULT 0", "ALTER TABLE product_preference_sets ADD COLUMN allow_multi_select INTEGER NOT NULL DEFAULT 0",
# Per-choice quantity on multi-select preference sets (x2, x3, …) # Per-choice quantity on multi-select preference sets (x2, x3, …)
"ALTER TABLE product_preference_sets ADD COLUMN allow_choice_quantity INTEGER NOT NULL DEFAULT 0", "ALTER TABLE product_preference_sets ADD COLUMN allow_choice_quantity INTEGER NOT NULL DEFAULT 0",
# Tracks the hash of the product image last pushed to the cloud (Xenia Connect)
"ALTER TABLE products ADD COLUMN cloud_image_hash VARCHAR",
] ]
for sql in migrations: for sql in migrations:
try: try:

View File

@@ -45,6 +45,9 @@ class Product(Base):
digital_price = Column(Float, nullable=True) digital_price = Column(Float, nullable=True)
digital_discount = Column(Float, default=0.0, nullable=False) digital_discount = Column(Float, default=0.0, nullable=False)
digital_image_url = Column(String, nullable=True) digital_image_url = Column(String, nullable=True)
# sha256 of the image_url file last successfully pushed to the cloud — lets
# cloud_sync skip re-uploading unchanged images on every sync tick
cloud_image_hash = Column(String, nullable=True)
# Unit of measure: "piece" | "portion" | "kg" | "liter" | "gram" | "ml" # Unit of measure: "piece" | "portion" | "kg" | "liter" | "gram" | "ml"
unit_type = Column(String, default="piece", nullable=False) unit_type = Column(String, default="piece", nullable=False)

View File

@@ -16,7 +16,7 @@ from models.user import User
from models.product import Category, Product from models.product import Category, Product
from models.table import Table, TableGroup from models.table import Table, TableGroup
from services import printer_service from services import printer_service
from services.cloud_sync import _sync_once from services.cloud_sync import _sync_once, _push_menu_snapshot
from middleware.license_check import license_state from middleware.license_check import license_state
from config import settings from config import settings
@@ -106,6 +106,13 @@ async def sync_license_now(user: User = Depends(require_manager)):
} }
@router.post("/sync-menu")
async def sync_menu_now(user: User = Depends(require_manager)):
"""Immediately push the digital menu snapshot (and any changed product images) to the cloud."""
await _push_menu_snapshot()
return {"ok": True}
@router.get("/printers", response_model=List[PrinterOut]) @router.get("/printers", response_model=List[PrinterOut])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)): def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(Printer).all() return db.query(Printer).all()

View File

@@ -15,6 +15,7 @@ License expiry behaviour:
import asyncio import asyncio
import json import json
import logging import logging
import os
import socket import socket
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -176,6 +177,52 @@ async def _sync_once():
license_state.update(expiry_fields) 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(): async def _push_menu_snapshot():
"""Serialize all digital-visible products+categories and POST to cloud.""" """Serialize all digital-visible products+categories and POST to cloud."""
if not settings.SITE_ID or not settings.CLOUD_URL: if not settings.SITE_ID or not settings.CLOUD_URL:
@@ -189,6 +236,7 @@ async def _push_menu_snapshot():
try: try:
categories = db.query(Category).filter(Category.parent_id == None).all() categories = db.query(Category).filter(Category.parent_id == None).all()
payload_categories = [] payload_categories = []
all_products = []
for cat in categories: for cat in categories:
products = ( products = (
db.query(Product) db.query(Product)
@@ -200,6 +248,7 @@ async def _push_menu_snapshot():
.order_by(Product.sort_order) .order_by(Product.sort_order)
.all() .all()
) )
all_products.extend(products)
product_list = [] product_list = []
for p in products: for p in products:
product_list.append({ product_list.append({
@@ -225,8 +274,6 @@ async def _push_menu_snapshot():
"sort_order": cat.sort_order, "sort_order": cat.sort_order,
"products": product_list, "products": product_list,
}) })
finally:
db.close()
snapshot_json = json.dumps({"categories": payload_categories}) snapshot_json = json.dumps({"categories": payload_categories})
@@ -245,6 +292,10 @@ async def _push_menu_snapshot():
resp.raise_for_status() resp.raise_for_status()
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories)) logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
await _push_product_images(db, all_products)
finally:
db.close()
except Exception as e: except Exception as e:
logger.warning("Menu snapshot push failed: %s", e) logger.warning("Menu snapshot push failed: %s", e)

View File

@@ -354,6 +354,43 @@ function QuickNotesSection({ settings, updateMut }) {
) )
} }
function CloudSyncSection() {
const [syncing, setSyncing] = useState(false)
async function syncNow() {
setSyncing(true)
try {
await client.post('/api/system/sync-menu')
toast.success('Το μενού στάλθηκε στο cloud')
} catch (e) {
toast.error(e.response?.data?.detail || 'Η αποστολή απέτυχε — ελέγξτε τη σύνδεση')
} finally {
setSyncing(false)
}
}
return (
<SectionCard
title="Συγχρονισμός Ψηφιακού Μενού"
description="Στέλνει ονόματα, περιγραφές, τιμές και φωτογραφίες προϊόντων στο cloud για το ψηφιακό μενού (QR). Γίνεται αυτόματα κάθε ~5 λεπτά."
>
<OptionRow
label="Άμεσος συγχρονισμός"
description="Στείλτε τις αλλαγές τώρα αντί να περιμένετε τον επόμενο αυτόματο κύκλο"
>
<button
onClick={syncNow}
disabled={syncing}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-gray-200 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 hover:text-gray-800 transition-colors disabled:opacity-50"
>
<span className={syncing ? 'animate-spin inline-block' : 'inline-block'}></span>
{syncing ? 'Συγχρονισμός…' : 'Συγχρονισμός τώρα'}
</button>
</OptionRow>
</SectionCard>
)
}
function TableOrderSettingsSection() { function TableOrderSettingsSection() {
const qc = useQueryClient() const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({ const { data: settings, isLoading } = useQuery({
@@ -713,6 +750,7 @@ export default function OperationTab() {
<AutoScheduleSection /> <AutoScheduleSection />
<FlagDefsSection /> <FlagDefsSection />
<QuickTemplatesSection /> <QuickTemplatesSection />
<CloudSyncSection />
</div> </div>
) )
} }