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() { + ) }