feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
179
local_backend/routers/system.py
Normal file
179
local_backend/routers/system.py
Normal file
@@ -0,0 +1,179 @@
|
||||
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.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"}
|
||||
Reference in New Issue
Block a user