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:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user