diff --git a/local_backend/main.py b/local_backend/main.py
index 3924d65..9df8129 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -732,6 +732,8 @@ def _run_migrations():
"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, …)
"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:
try:
diff --git a/local_backend/models/product.py b/local_backend/models/product.py
index 29a77a5..1e58042 100644
--- a/local_backend/models/product.py
+++ b/local_backend/models/product.py
@@ -45,6 +45,9 @@ class Product(Base):
digital_price = Column(Float, nullable=True)
digital_discount = Column(Float, default=0.0, nullable=False)
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_type = Column(String, default="piece", nullable=False)
diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py
index f6d6058..bb1008a 100644
--- a/local_backend/routers/system.py
+++ b/local_backend/routers/system.py
@@ -16,7 +16,7 @@ from models.user import User
from models.product import Category, Product
from models.table import Table, TableGroup
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 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])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(Printer).all()
diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py
index a274085..e76fc2d 100644
--- a/local_backend/services/cloud_sync.py
+++ b/local_backend/services/cloud_sync.py
@@ -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)
diff --git a/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx b/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
index 41d79e8..9b859ad 100644
--- a/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
+++ b/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
function TableOrderSettingsSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
@@ -713,6 +750,7 @@ export default function OperationTab() {
+
)
}