Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
291 lines
11 KiB
Python
291 lines
11 KiB
Python
import asyncio
|
|
import ipaddress
|
|
import json
|
|
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.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, 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"}
|