feat: per-site QR menu mode (order vs view-only) + editable branding

Adds a sysadmin-configurable toggle so sites can disable online ordering
on the public QR menu until it's fully supported, plus editable
tagline/hours and a header image (replacing the hardcoded "Our Menu"
placeholder) — all previously hardcoded frontend strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 09:59:35 +03:00
parent 0cad6a76d3
commit d87540e08f
7 changed files with 358 additions and 39 deletions

View File

@@ -1,7 +1,8 @@
import os
import secrets
import uuid
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from auth_utils import get_current_admin
@@ -12,6 +13,8 @@ from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRe
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
HEADER_IMAGE_DIR = "/app/data/site_headers"
@router.get("/", response_model=list[SiteOut])
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
@@ -50,6 +53,8 @@ def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if body.menu_mode is not None and body.menu_mode not in ("order", "view_only"):
raise HTTPException(status_code=400, detail="menu_mode must be 'order' or 'view_only'")
for field, value in body.model_dump(exclude_none=True).items():
setattr(site, field, value)
db.commit()
@@ -57,6 +62,51 @@ def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _
return site
@router.post("/{site_id}/header-image", response_model=SiteOut)
async def upload_header_image(site_id: str, file: UploadFile = File(...), db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
os.makedirs(HEADER_IMAGE_DIR, exist_ok=True)
if site.menu_header_image_url:
old_path = os.path.join(HEADER_IMAGE_DIR, os.path.basename(site.menu_header_image_url))
if os.path.exists(old_path):
os.remove(old_path)
filename = f"{site.site_id}_{uuid.uuid4().hex[:8]}.png"
filepath = os.path.join(HEADER_IMAGE_DIR, filename)
contents = await file.read()
with open(filepath, "wb") as f:
f.write(contents)
site.menu_header_image_url = f"/static/site_headers/{filename}"
db.commit()
db.refresh(site)
return site
@router.delete("/{site_id}/header-image", response_model=SiteOut)
def delete_header_image(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site.menu_header_image_url:
old_path = os.path.join(HEADER_IMAGE_DIR, os.path.basename(site.menu_header_image_url))
if os.path.exists(old_path):
os.remove(old_path)
site.menu_header_image_url = None
db.commit()
db.refresh(site)
return site
@router.post("/{site_id}/lock", response_model=SiteOut)
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()