Files
xenia-pos-local/local_backend/routers/system.py
bonamin 607e78ea82 feat: printer beep support for kitchen tickets
Adds configurable buzzer control so the printer alerts kitchen staff
on each new ticket print.

Backend:
  - printer_service.py: ESC 0x07 beep command fired before paper cut,
    reads print.beep_on_ticket / print.beep_pattern settings; supports
    single/double/triple/long presets and a custom:n1:n2:n3 format
  - settings.py: registers the two new print.beep_* settings with
    defaults (beep_on_ticket=true, beep_pattern=double)
  - system.py: POST /api/system/printers/test-beep endpoint for
    live testing from the dashboard

Frontend (PrintFontsTab):
  - BeepSection component with preset grid, custom n1/n2/n3 inputs,
    and a live test-beep button targeting any configured printer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:53:58 +03:00

189 lines
7.4 KiB
Python

import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.printer import Printer
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
from routers.deps import get_current_user, require_manager, require_sysadmin
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 middleware.license_check import license_state
from config import settings
router = APIRouter()
_start_time = time.time()
@router.get("/health")
def health():
return {"status": "ok", "version": settings.VERSION}
@router.get("/status")
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from datetime import datetime, timezone
printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_statuses = []
for p in printers:
reachable = printer_service.check_printer(p.ip_address, p.port)
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
licensed = license_state.get("licensed", True)
locked = license_state.get("locked", False)
lock_pending = license_state.get("lock_pending", False)
expires_at = license_state.get("expires_at")
days_until_expiry = license_state.get("days_until_expiry")
grace_expires_at = license_state.get("grace_expires_at")
# Determine lock_reason for the frontend banner logic
# "admin" — locked by sysadmin (immediately or deferred)
# "expired" — license grace period over, site is blocked
# None — all good
lock_reason = None
if locked or lock_pending:
lock_reason = "admin"
elif not licensed:
lock_reason = "expired"
# Grace days remaining (only meaningful while in expiry grace period)
grace_days_remaining = None
if grace_expires_at:
try:
grace_dt = datetime.fromisoformat(grace_expires_at)
if grace_dt.tzinfo is None:
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
except ValueError:
pass
return {
"uptime_seconds": int(time.time() - _start_time),
"version": settings.VERSION,
"latest_version": license_state.get("latest_version"),
"licensed": licensed,
"locked": locked,
"lock_pending": lock_pending,
"lock_reason": lock_reason,
"expires_at": expires_at,
"days_until_expiry": days_until_expiry,
"grace_expires_at": grace_expires_at,
"grace_days_remaining": grace_days_remaining,
"sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"),
"waiter_domain": license_state.get("waiter_domain"),
"printers": printer_statuses,
}
@router.post("/sync-license")
async def sync_license_now(user: User = Depends(require_manager)):
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
await _sync_once()
return {
"licensed": license_state.get("licensed", True),
"locked": license_state.get("locked", False),
"lock_pending": license_state.get("lock_pending", False),
"lock_reason": (
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
else "expired" if not license_state.get("licensed", True)
else None
),
"expires_at": license_state.get("expires_at"),
"days_until_expiry": license_state.get("days_until_expiry"),
"sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"),
}
@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()
@router.post("/printers", response_model=PrinterOut)
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = Printer(**body.model_dump())
db.add(printer)
db.commit()
db.refresh(printer)
return printer
@router.post("/printers/test")
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name)
return {"success": success, "error": error}
@router.post("/printers/test-order")
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
return {"success": success, "error": error}
@router.post("/printers/test-beep")
def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3)
return {"success": success, "error": error}
@router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(printer, field, value)
db.commit()
db.refresh(printer)
return printer
@router.delete("/printers/{printer_id}")
def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
db.delete(printer)
db.commit()
return {"ok": True}
@router.get("/stats")
def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return {
"categories": db.query(Category).count(),
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
"tables": db.query(Table).filter(Table.is_active == True).count(),
"table_groups": db.query(TableGroup).count(),
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
}
@router.post("/lock")
def lock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = True
return {"status": "locked"}
@router.post("/unlock")
def unlock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = False
return {"status": "unlocked"}