Files
xenia-pos-local/local_backend/routers/system.py
T
bonaminandClaude Opus 5.5 2d47530069 feat(backend): public /api/system/identity for pairing + site_id/lan_ip in status
GET /api/system/identity (unauthenticated, license-exempt) returns
{app: 'xenia-pos', site_id, venue_name, version, api_version: 1} so a phone
can confirm which venue a server is when pairing and when rediscovering the
server after an IP change. No secrets: SITE_KEY never leaves the server.
/api/system/status now also returns site_id and lan_ip (HOST_IP only - in
Docker any auto-detected address is the unreachable bridge IP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:50:29 +03:00

328 lines
12 KiB
Python

import asyncio
import ipaddress
import json
import os
import socket
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
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.settings import PosSettings
from models.product import Category, Product
from models.table import Table, TableGroup
from services import printer_service
from services.cloud_sync import _sync_once, _push_menu_snapshot
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}
# Bump when the waiter app <-> backend contract changes incompatibly, so native
# apps (which ship their own UI) can tell they're talking to a server they don't support.
API_VERSION = 1
def _lan_ip() -> str | None:
"""The address phones should use. Only HOST_IP is trusted: inside Docker any
auto-detection returns the container's bridge IP, which phones can't reach."""
return os.environ.get("HOST_IP", "").strip() or None
@router.get("/identity")
def identity(db: Session = Depends(get_db)):
"""Public, unauthenticated. Lets a phone confirm which venue a server is
(pairing, and rediscovery after the server's IP changes). No secrets here:
site_id is an identifier — the secret is SITE_KEY, which never leaves the server."""
venue = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
return {
"app": "xenia-pos",
"site_id": settings.SITE_ID or None,
"venue_name": (venue.value if venue and venue.value else None),
"version": settings.VERSION,
"api_version": API_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"),
"site_id": settings.SITE_ID or None,
"lan_ip": _lan_ip(),
"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.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()
@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, printer.codepage_n)
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, printer.line_width, printer.codepage_n)
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, printer.codepage_n)
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}
def _detect_local_subnets() -> list[str]:
"""Return plausible /24 subnets based on local interface IPs, excluding loopback/docker."""
subnets = []
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None):
ip_str = info[4][0]
try:
addr = ipaddress.IPv4Address(ip_str)
if addr.is_loopback or addr.is_link_local:
continue
# Exclude docker bridge ranges (172.16-31.x.x)
if addr.packed[0] == 172 and 16 <= addr.packed[1] <= 31:
continue
net = str(ipaddress.IPv4Network(f"{ip_str}/24", strict=False))
if net not in subnets:
subnets.append(net)
except (ValueError, OSError):
continue
except OSError:
pass
return subnets
async def _check_port(ip: str, port: int, timeout: float) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port), timeout=timeout
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def _scan_subnet_sse(subnet: str, port: int, token: str):
"""Async generator: yields SSE lines as printers are found."""
try:
network = ipaddress.IPv4Network(subnet, strict=False)
except ValueError as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
hosts = list(network.hosts())
semaphore = asyncio.Semaphore(50) # max 50 concurrent connects
async def check(ip_str: str):
async with semaphore:
return ip_str, await _check_port(ip_str, port, timeout=0.4)
tasks = [asyncio.create_task(check(str(h))) for h in hosts]
yield f"data: {json.dumps({'type': 'start', 'total': len(tasks), 'subnet': subnet, 'port': port})}\n\n"
done_count = 0
for coro in asyncio.as_completed(tasks):
ip_str, reachable = await coro
done_count += 1
if reachable:
yield f"data: {json.dumps({'type': 'found', 'ip': ip_str, 'port': port})}\n\n"
if done_count % 20 == 0 or done_count == len(tasks):
yield f"data: {json.dumps({'type': 'progress', 'done': done_count, 'total': len(tasks)})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
@router.get("/printers/scan")
async def scan_printers(
subnet: str = Query(default=""),
port: int = Query(default=9100),
user: User = Depends(require_manager),
):
"""SSE endpoint: scan a subnet for devices responding on the given port."""
# Fall back to auto-detected subnet if none provided
if not subnet:
detected = _detect_local_subnets()
subnet = detected[0] if detected else "192.168.1.0/24"
return StreamingResponse(
_scan_subnet_sse(subnet, port, ""),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.get("/printers/scan-hints")
def scan_hints(user: User = Depends(require_manager)):
"""Return auto-detected local subnets to suggest in the scan UI."""
return {"subnets": _detect_local_subnets()}
@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.perm_access_dashboard == True, User.is_active == True).count(),
"waiters": db.query(User).filter(User.perm_access_waiter_app == True, 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"}