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"}