virtual_printers.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import logging
  2. from fastapi import APIRouter, Depends
  3. from fastapi.responses import JSONResponse
  4. from pydantic import BaseModel
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  8. from backend.app.core.database import get_db
  9. from backend.app.core.permissions import Permission
  10. from backend.app.models.user import User
  11. logger = logging.getLogger(__name__)
  12. router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
  13. class VirtualPrinterCreate(BaseModel):
  14. name: str = "Bambuddy"
  15. enabled: bool = False
  16. mode: str = "immediate"
  17. model: str | None = None
  18. access_code: str | None = None
  19. target_printer_id: int | None = None
  20. auto_dispatch: bool = True
  21. bind_ip: str | None = None
  22. remote_interface_ip: str | None = None
  23. class VirtualPrinterUpdate(BaseModel):
  24. name: str | None = None
  25. enabled: bool | None = None
  26. mode: str | None = None
  27. model: str | None = None
  28. access_code: str | None = None
  29. target_printer_id: int | None = None
  30. auto_dispatch: bool | None = None
  31. bind_ip: str | None = None
  32. remote_interface_ip: str | None = None
  33. def _vp_to_dict(vp, status: dict | None = None) -> dict:
  34. """Convert VirtualPrinter model to response dict."""
  35. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  36. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
  37. model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
  38. serial = _get_serial_for_model(model_code, vp.serial_suffix)
  39. return {
  40. "id": vp.id,
  41. "name": vp.name,
  42. "enabled": vp.enabled,
  43. "mode": vp.mode,
  44. "model": model_code,
  45. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  46. "access_code_set": bool(vp.access_code),
  47. "serial": serial,
  48. "target_printer_id": vp.target_printer_id,
  49. "auto_dispatch": vp.auto_dispatch,
  50. "bind_ip": vp.bind_ip,
  51. "remote_interface_ip": vp.remote_interface_ip,
  52. "position": vp.position,
  53. "status": status or {"running": False, "pending_files": 0},
  54. }
  55. @router.get("")
  56. async def list_virtual_printers(
  57. db: AsyncSession = Depends(get_db),
  58. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  59. ):
  60. """List all virtual printers with status."""
  61. from backend.app.models.virtual_printer import VirtualPrinter
  62. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  63. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
  64. vps = result.scalars().all()
  65. printers = []
  66. for vp in vps:
  67. instance = virtual_printer_manager.get_instance(vp.id)
  68. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  69. printers.append(_vp_to_dict(vp, status))
  70. return {
  71. "printers": printers,
  72. "models": VIRTUAL_PRINTER_MODELS,
  73. }
  74. @router.post("")
  75. async def create_virtual_printer(
  76. body: VirtualPrinterCreate,
  77. db: AsyncSession = Depends(get_db),
  78. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  79. ):
  80. """Create a new virtual printer."""
  81. from backend.app.models.virtual_printer import VirtualPrinter
  82. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  83. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
  84. # Validate mode
  85. if body.mode not in ("immediate", "review", "print_queue", "proxy"):
  86. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  87. # Validate model
  88. if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
  89. return JSONResponse(
  90. status_code=400,
  91. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  92. )
  93. # Validate access code length
  94. if body.access_code and len(body.access_code) != 8:
  95. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  96. # Validation when enabling
  97. if body.enabled:
  98. if not body.bind_ip:
  99. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  100. if body.mode == "proxy":
  101. if not body.target_printer_id:
  102. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  103. else:
  104. if not body.access_code:
  105. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  106. # Validate proxy target printer exists
  107. if body.target_printer_id:
  108. from backend.app.models.printer import Printer
  109. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  110. if not result.scalar_one_or_none():
  111. return JSONResponse(
  112. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  113. )
  114. # Validate bind_ip uniqueness (against all enabled VPs)
  115. if body.bind_ip:
  116. result = await db.execute(
  117. select(VirtualPrinter).where(
  118. VirtualPrinter.bind_ip == body.bind_ip,
  119. VirtualPrinter.enabled == True, # noqa: E712
  120. )
  121. )
  122. if result.scalar_one_or_none():
  123. return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
  124. # Generate next serial suffix
  125. result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
  126. last_suffix = result.scalar()
  127. if last_suffix:
  128. try:
  129. next_num = int(last_suffix) + 1
  130. new_suffix = str(next_num).zfill(9)
  131. except ValueError:
  132. new_suffix = "391800002"
  133. else:
  134. new_suffix = "391800001"
  135. # Get next position
  136. result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
  137. last_pos = result.scalar()
  138. next_pos = (last_pos or 0) + 1
  139. vp = VirtualPrinter(
  140. name=body.name,
  141. enabled=body.enabled,
  142. mode=body.mode,
  143. model=body.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  144. access_code=body.access_code,
  145. target_printer_id=body.target_printer_id,
  146. auto_dispatch=body.auto_dispatch,
  147. bind_ip=body.bind_ip,
  148. remote_interface_ip=body.remote_interface_ip,
  149. serial_suffix=new_suffix,
  150. position=next_pos,
  151. )
  152. db.add(vp)
  153. await db.commit()
  154. await db.refresh(vp)
  155. logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
  156. # Sync services if enabled
  157. if body.enabled:
  158. try:
  159. await virtual_printer_manager.sync_from_db()
  160. except Exception as e:
  161. logger.error("Failed to start virtual printer after create: %s", e)
  162. return _vp_to_dict(vp)
  163. @router.get("/{vp_id}")
  164. async def get_virtual_printer(
  165. vp_id: int,
  166. db: AsyncSession = Depends(get_db),
  167. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  168. ):
  169. """Get a single virtual printer with status."""
  170. from backend.app.models.virtual_printer import VirtualPrinter
  171. from backend.app.services.virtual_printer import virtual_printer_manager
  172. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  173. vp = result.scalar_one_or_none()
  174. if not vp:
  175. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  176. instance = virtual_printer_manager.get_instance(vp.id)
  177. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  178. return _vp_to_dict(vp, status)
  179. @router.put("/{vp_id}")
  180. async def update_virtual_printer(
  181. vp_id: int,
  182. body: VirtualPrinterUpdate,
  183. db: AsyncSession = Depends(get_db),
  184. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  185. ):
  186. """Update a virtual printer."""
  187. from backend.app.models.virtual_printer import VirtualPrinter
  188. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  189. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  190. vp = result.scalar_one_or_none()
  191. if not vp:
  192. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  193. logger.debug(
  194. "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
  195. vp_id,
  196. body.model_dump(exclude_unset=True),
  197. vp.mode,
  198. vp.enabled,
  199. bool(vp.access_code),
  200. vp.bind_ip,
  201. vp.target_printer_id,
  202. )
  203. # Apply updates
  204. if body.name is not None:
  205. vp.name = body.name
  206. if body.mode is not None:
  207. if body.mode not in ("immediate", "review", "print_queue", "proxy"):
  208. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  209. vp.mode = body.mode
  210. if body.model is not None:
  211. if body.model not in VIRTUAL_PRINTER_MODELS:
  212. return JSONResponse(
  213. status_code=400,
  214. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  215. )
  216. vp.model = body.model
  217. if body.access_code is not None:
  218. if body.access_code and len(body.access_code) != 8:
  219. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  220. vp.access_code = body.access_code
  221. if body.target_printer_id is not None:
  222. from backend.app.models.printer import Printer
  223. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  224. if not result.scalar_one_or_none():
  225. return JSONResponse(
  226. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  227. )
  228. vp.target_printer_id = body.target_printer_id
  229. if body.auto_dispatch is not None:
  230. vp.auto_dispatch = body.auto_dispatch
  231. if body.bind_ip is not None:
  232. vp.bind_ip = body.bind_ip
  233. if body.remote_interface_ip is not None:
  234. vp.remote_interface_ip = body.remote_interface_ip
  235. # Determine final enabled state
  236. explicitly_enabling = body.enabled is True
  237. new_enabled = body.enabled if body.enabled is not None else vp.enabled
  238. effective_mode = vp.mode
  239. if explicitly_enabling:
  240. # User is explicitly toggling on — enforce all requirements
  241. if not vp.bind_ip:
  242. logger.warning("Update VP %d rejected: no bind_ip", vp_id)
  243. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  244. # Validate bind_ip uniqueness (against all enabled VPs)
  245. existing = await db.execute(
  246. select(VirtualPrinter).where(
  247. VirtualPrinter.bind_ip == vp.bind_ip,
  248. VirtualPrinter.id != vp_id,
  249. VirtualPrinter.enabled == True, # noqa: E712
  250. )
  251. )
  252. conflict = existing.scalar_one_or_none()
  253. if conflict:
  254. logger.warning(
  255. "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
  256. vp_id,
  257. vp.bind_ip,
  258. conflict.id,
  259. conflict.enabled,
  260. conflict.mode,
  261. )
  262. return JSONResponse(
  263. status_code=400,
  264. content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
  265. )
  266. if effective_mode == "proxy":
  267. if not vp.target_printer_id:
  268. logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
  269. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  270. else:
  271. if not vp.access_code:
  272. logger.warning(
  273. "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
  274. )
  275. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  276. elif new_enabled and body.enabled is None:
  277. # VP is already enabled and user is changing other fields —
  278. # auto-disable if new state doesn't meet requirements
  279. if not vp.bind_ip:
  280. new_enabled = False
  281. elif effective_mode == "proxy":
  282. if not vp.target_printer_id:
  283. new_enabled = False
  284. else:
  285. if not vp.access_code:
  286. new_enabled = False
  287. vp.enabled = new_enabled
  288. await db.commit()
  289. await db.refresh(vp)
  290. logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
  291. # Sync services
  292. try:
  293. await virtual_printer_manager.sync_from_db()
  294. except Exception as e:
  295. logger.error("Failed to sync virtual printers after update: %s", e)
  296. instance = virtual_printer_manager.get_instance(vp.id)
  297. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  298. return _vp_to_dict(vp, status)
  299. @router.delete("/{vp_id}")
  300. async def delete_virtual_printer(
  301. vp_id: int,
  302. db: AsyncSession = Depends(get_db),
  303. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  304. ):
  305. """Delete a virtual printer."""
  306. from sqlalchemy import delete as sql_delete
  307. from backend.app.models.virtual_printer import VirtualPrinter
  308. from backend.app.services.virtual_printer import virtual_printer_manager
  309. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  310. vp = result.scalar_one_or_none()
  311. if not vp:
  312. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  313. vp_name = vp.name
  314. # Stop instance if running
  315. await virtual_printer_manager.remove_instance(vp_id)
  316. # Delete from DB
  317. await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  318. await db.commit()
  319. logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
  320. # Resync remaining services
  321. try:
  322. await virtual_printer_manager.sync_from_db()
  323. except Exception as e:
  324. logger.error("Failed to sync virtual printers after delete: %s", e)
  325. return {"detail": "Deleted", "id": vp_id}