virtual_printers.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import logging
  2. import os
  3. from pathlib import Path
  4. from fastapi import APIRouter, Depends
  5. from fastapi.responses import JSONResponse
  6. from pydantic import BaseModel
  7. from sqlalchemy import select, update
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  10. from backend.app.core.database import get_db
  11. from backend.app.core.permissions import Permission
  12. from backend.app.models.user import User
  13. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  14. from backend.app.schemas.virtual_printer import VPDiagnosticResult
  15. # Imported at module scope so tests can patch
  16. # backend.app.api.routes.virtual_printers.tailscale_service.
  17. from backend.app.services.virtual_printer.tailscale import tailscale_service
  18. logger = logging.getLogger(__name__)
  19. router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
  20. class TailscaleStatusResponse(BaseModel):
  21. available: bool
  22. fqdn: str
  23. hostname: str
  24. tailnet_name: str
  25. tailscale_ips: list[str]
  26. error: str | None
  27. class VirtualPrinterCreate(BaseModel):
  28. name: str = "Bambuddy"
  29. enabled: bool = False
  30. mode: str = "archive"
  31. model: str | None = None
  32. access_code: str | None = None
  33. target_printer_id: int | None = None
  34. auto_dispatch: bool = True
  35. queue_force_color_match: bool = False
  36. save_ams_mapping: bool = False
  37. gcode_injection: bool = False
  38. bind_ip: str | None = None
  39. remote_interface_ip: str | None = None
  40. class VirtualPrinterUpdate(IgnoresNullForRequiredColumns):
  41. __orm_table__ = "virtual_printers"
  42. name: str | None = None
  43. enabled: bool | None = None
  44. mode: str | None = None
  45. model: str | None = None
  46. access_code: str | None = None
  47. target_printer_id: int | None = None
  48. auto_dispatch: bool | None = None
  49. queue_force_color_match: bool | None = None
  50. save_ams_mapping: bool | None = None
  51. gcode_injection: bool | None = None
  52. bind_ip: str | None = None
  53. remote_interface_ip: str | None = None
  54. tailscale_disabled: bool | None = None
  55. def _resolve_printer_model(printer_model: str | None) -> str | None:
  56. """Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
  57. Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
  58. """
  59. if not printer_model:
  60. return None
  61. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  62. from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
  63. # Already a valid SSDP model code
  64. if printer_model in VIRTUAL_PRINTER_MODELS:
  65. return printer_model
  66. # Map display name to SSDP code
  67. return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
  68. async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
  69. """Convert VirtualPrinter model to response dict.
  70. In proxy mode the surfaced serial is the target printer's actual serial
  71. (what the bridge advertises over SSDP / what slicers see), not the
  72. self-generated suffix. Archive / queue / review keep the self-generated
  73. serial since those modes never speak the target's identity.
  74. """
  75. from backend.app.models.printer import Printer
  76. from backend.app.models.virtual_printer import VP_MODE_PROXY
  77. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  78. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
  79. model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
  80. serial = _get_serial_for_model(model_code, vp.serial_suffix)
  81. if vp.mode == VP_MODE_PROXY and vp.target_printer_id:
  82. result = await db.execute(select(Printer.serial_number).where(Printer.id == vp.target_printer_id))
  83. target_serial = result.scalar_one_or_none()
  84. if target_serial:
  85. serial = target_serial
  86. return {
  87. "id": vp.id,
  88. "name": vp.name,
  89. "enabled": vp.enabled,
  90. "mode": vp.mode,
  91. "model": model_code,
  92. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  93. "access_code_set": bool(vp.access_code),
  94. "serial": serial,
  95. "target_printer_id": vp.target_printer_id,
  96. "auto_dispatch": vp.auto_dispatch,
  97. "queue_force_color_match": vp.queue_force_color_match,
  98. "save_ams_mapping": vp.save_ams_mapping,
  99. "gcode_injection": vp.gcode_injection,
  100. "bind_ip": vp.bind_ip,
  101. "remote_interface_ip": vp.remote_interface_ip,
  102. "tailscale_disabled": vp.tailscale_disabled,
  103. "position": vp.position,
  104. "status": status or {"running": False, "pending_files": 0},
  105. }
  106. @router.get("")
  107. async def list_virtual_printers(
  108. db: AsyncSession = Depends(get_db),
  109. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  110. ):
  111. """List all virtual printers with status."""
  112. from backend.app.models.virtual_printer import VirtualPrinter
  113. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  114. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
  115. vps = result.scalars().all()
  116. printers = []
  117. for vp in vps:
  118. instance = virtual_printer_manager.get_instance(vp.id)
  119. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  120. printers.append(await _vp_to_dict(vp, db, status))
  121. return {
  122. "printers": printers,
  123. "models": VIRTUAL_PRINTER_MODELS,
  124. }
  125. @router.post("")
  126. async def create_virtual_printer(
  127. body: VirtualPrinterCreate,
  128. db: AsyncSession = Depends(get_db),
  129. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  130. ):
  131. """Create a new virtual printer."""
  132. from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
  133. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  134. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
  135. # Accept both canonical and legacy wire values so older clients (forks /
  136. # mobile shortcuts / scripted setups) still work; normalize before write.
  137. body.mode = normalize_vp_mode(body.mode) or body.mode
  138. if body.mode not in VP_MODE_VALUES:
  139. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  140. # Validate model
  141. if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
  142. return JSONResponse(
  143. status_code=400,
  144. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  145. )
  146. # Validate access code length
  147. if body.access_code and len(body.access_code) != 8:
  148. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  149. # Validation when enabling. Non-proxy VPs with a target printer derive
  150. # their access code from the target (the bridge forwards the slicer's
  151. # auth bytes through to the real printer, so the codes MUST match),
  152. # so a separately-supplied access_code isn't required in that case.
  153. if body.enabled:
  154. if not body.bind_ip:
  155. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  156. if body.mode == "proxy":
  157. if not body.target_printer_id:
  158. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  159. else:
  160. if not body.access_code and not body.target_printer_id:
  161. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  162. # Validate proxy target printer exists
  163. target_printer = None
  164. if body.target_printer_id:
  165. from backend.app.models.printer import Printer
  166. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  167. target_printer = result.scalar_one_or_none()
  168. if not target_printer:
  169. return JSONResponse(
  170. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  171. )
  172. # Validate bind_ip uniqueness (against all enabled VPs)
  173. if body.bind_ip:
  174. result = await db.execute(
  175. select(VirtualPrinter).where(
  176. VirtualPrinter.bind_ip == body.bind_ip,
  177. VirtualPrinter.enabled == True, # noqa: E712
  178. )
  179. )
  180. if result.scalar_one_or_none():
  181. return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
  182. # Force-inherit the access code from the target printer for non-proxy VPs.
  183. # The non-proxy bridge (Immediate / Review / Queue with a target set) forwards
  184. # the slicer's MQTT / RTSPS auth bytes through to the real printer, so any
  185. # value the user supplied here would silently break the bridge if it didn't
  186. # match the printer's code. The UI now renders the field read-only when a
  187. # target is set; this is the belt-and-braces backstop for any non-UI client.
  188. effective_access_code = body.access_code
  189. if body.mode != "proxy" and target_printer is not None:
  190. effective_access_code = target_printer.access_code
  191. # Generate next serial suffix
  192. result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
  193. last_suffix = result.scalar()
  194. if last_suffix:
  195. try:
  196. next_num = int(last_suffix) + 1
  197. new_suffix = str(next_num).zfill(9)
  198. except ValueError:
  199. new_suffix = "391800002"
  200. else:
  201. new_suffix = "391800001"
  202. # Get next position
  203. result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
  204. last_pos = result.scalar()
  205. next_pos = (last_pos or 0) + 1
  206. vp = VirtualPrinter(
  207. name=body.name,
  208. enabled=body.enabled,
  209. mode=body.mode,
  210. model=body.model
  211. or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
  212. or DEFAULT_VIRTUAL_PRINTER_MODEL,
  213. access_code=effective_access_code,
  214. target_printer_id=body.target_printer_id,
  215. auto_dispatch=body.auto_dispatch,
  216. queue_force_color_match=body.queue_force_color_match,
  217. save_ams_mapping=body.save_ams_mapping,
  218. gcode_injection=body.gcode_injection,
  219. bind_ip=body.bind_ip,
  220. remote_interface_ip=body.remote_interface_ip,
  221. serial_suffix=new_suffix,
  222. position=next_pos,
  223. )
  224. db.add(vp)
  225. await db.commit()
  226. await db.refresh(vp)
  227. logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
  228. # Sync services if enabled
  229. if body.enabled:
  230. try:
  231. await virtual_printer_manager.sync_from_db()
  232. except Exception as e:
  233. logger.error("Failed to start virtual printer after create: %s", e)
  234. return await _vp_to_dict(vp, db)
  235. @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
  236. async def get_tailscale_status(
  237. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  238. ) -> TailscaleStatusResponse:
  239. """Return current Tailscale availability and machine identity.
  240. Used by the frontend to indicate whether virtual printer TLS is backed
  241. by a trusted Let's Encrypt certificate or a self-signed CA.
  242. """
  243. status = await tailscale_service.get_status()
  244. return TailscaleStatusResponse(
  245. available=status.available,
  246. fqdn=status.fqdn,
  247. hostname=status.hostname,
  248. tailnet_name=status.tailnet_name,
  249. tailscale_ips=status.tailscale_ips,
  250. error=status.error,
  251. )
  252. @router.get("/ca-certificate")
  253. async def get_ca_certificate(
  254. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  255. ):
  256. """Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
  257. One CA is shared by every virtual printer — the user imports it into their
  258. slicer's trust store once. Only the public certificate is returned; the CA
  259. private key never leaves the backend.
  260. """
  261. from backend.app.services.virtual_printer import virtual_printer_manager
  262. try:
  263. return virtual_printer_manager.get_ca_certificate_info()
  264. except Exception as e:
  265. logger.error("Failed to obtain virtual printer CA certificate: %s", e)
  266. return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
  267. @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
  268. async def diagnose_virtual_printer(
  269. vp_id: int,
  270. db: AsyncSession = Depends(get_db),
  271. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  272. ):
  273. """Run setup diagnostics for a virtual printer.
  274. Probes the VP's own bind IP and services so the user can self-diagnose the
  275. common "my virtual printer doesn't show up in the slicer" failures.
  276. """
  277. from backend.app.models.virtual_printer import VirtualPrinter
  278. from backend.app.services.virtual_printer import virtual_printer_manager
  279. from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
  280. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  281. vp = result.scalar_one_or_none()
  282. if not vp:
  283. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  284. instance = virtual_printer_manager.get_instance(vp.id)
  285. return await run_vp_diagnostic(vp, instance)
  286. @router.get("/{vp_id}")
  287. async def get_virtual_printer(
  288. vp_id: int,
  289. db: AsyncSession = Depends(get_db),
  290. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  291. ):
  292. """Get a single virtual printer with status."""
  293. from backend.app.models.virtual_printer import VirtualPrinter
  294. from backend.app.services.virtual_printer import virtual_printer_manager
  295. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  296. vp = result.scalar_one_or_none()
  297. if not vp:
  298. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  299. instance = virtual_printer_manager.get_instance(vp.id)
  300. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  301. return await _vp_to_dict(vp, db, status)
  302. @router.put("/{vp_id}")
  303. async def update_virtual_printer(
  304. vp_id: int,
  305. body: VirtualPrinterUpdate,
  306. db: AsyncSession = Depends(get_db),
  307. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  308. ):
  309. """Update a virtual printer."""
  310. from backend.app.models.virtual_printer import VirtualPrinter
  311. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  312. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  313. vp = result.scalar_one_or_none()
  314. if not vp:
  315. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  316. # Redact the access code before logging — model_dump otherwise includes
  317. # the plaintext value at DEBUG, violating the project no-secrets-in-logs
  318. # rule. Replace with a marker that still signals "the user changed it"
  319. # vs "the user didn't touch this field".
  320. _safe_body = body.model_dump(exclude_unset=True)
  321. if "access_code" in _safe_body:
  322. _safe_body["access_code"] = "***"
  323. logger.debug(
  324. "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
  325. vp_id,
  326. _safe_body,
  327. vp.mode,
  328. vp.enabled,
  329. bool(vp.access_code),
  330. vp.bind_ip,
  331. vp.target_printer_id,
  332. )
  333. # Apply updates
  334. if body.name is not None:
  335. vp.name = body.name
  336. if body.mode is not None:
  337. from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
  338. canonical_mode = normalize_vp_mode(body.mode) or body.mode
  339. if canonical_mode not in VP_MODE_VALUES:
  340. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  341. vp.mode = canonical_mode
  342. if body.model is not None:
  343. if body.model not in VIRTUAL_PRINTER_MODELS:
  344. return JSONResponse(
  345. status_code=400,
  346. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  347. )
  348. vp.model = body.model
  349. if body.access_code is not None:
  350. if body.access_code and len(body.access_code) != 8:
  351. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  352. vp.access_code = body.access_code
  353. if body.target_printer_id is not None:
  354. from backend.app.models.printer import Printer
  355. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  356. target_printer = result.scalar_one_or_none()
  357. if not target_printer:
  358. return JSONResponse(
  359. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  360. )
  361. vp.target_printer_id = body.target_printer_id
  362. # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
  363. if body.model is None and vp.mode == "proxy" and target_printer.model:
  364. vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
  365. if body.auto_dispatch is not None:
  366. vp.auto_dispatch = body.auto_dispatch
  367. if body.queue_force_color_match is not None:
  368. vp.queue_force_color_match = body.queue_force_color_match
  369. if body.save_ams_mapping is not None:
  370. vp.save_ams_mapping = body.save_ams_mapping
  371. if body.gcode_injection is not None:
  372. vp.gcode_injection = body.gcode_injection
  373. if body.bind_ip is not None:
  374. vp.bind_ip = body.bind_ip
  375. if body.remote_interface_ip is not None:
  376. vp.remote_interface_ip = body.remote_interface_ip
  377. if body.tailscale_disabled is not None:
  378. vp.tailscale_disabled = body.tailscale_disabled
  379. # Auto-inherit model when switching to proxy mode with existing target printer
  380. if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
  381. from backend.app.models.printer import Printer as PrinterModel
  382. result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
  383. existing_target = result.scalar_one_or_none()
  384. if existing_target and existing_target.model:
  385. vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
  386. # Force-inherit the access code from the target printer for non-proxy VPs.
  387. # See create_virtual_printer for the rationale: the bridge forwards slicer
  388. # auth bytes through, so the VP's code MUST equal the target's. This block
  389. # runs after every patch (whether or not access_code or target were in the
  390. # body), so changing the target also resyncs the code, and an explicit
  391. # access_code submitted alongside a target is silently overridden.
  392. if vp.mode != "proxy" and vp.target_printer_id is not None:
  393. from backend.app.models.printer import Printer as PrinterModelAC
  394. result = await db.execute(select(PrinterModelAC).where(PrinterModelAC.id == vp.target_printer_id))
  395. target_for_ac = result.scalar_one_or_none()
  396. if target_for_ac is not None and vp.access_code != target_for_ac.access_code:
  397. vp.access_code = target_for_ac.access_code
  398. # Determine final enabled state
  399. explicitly_enabling = body.enabled is True
  400. new_enabled = body.enabled if body.enabled is not None else vp.enabled
  401. effective_mode = vp.mode
  402. if explicitly_enabling:
  403. # User is explicitly toggling on — enforce all requirements
  404. if not vp.bind_ip:
  405. logger.warning("Update VP %d rejected: no bind_ip", vp_id)
  406. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  407. # Validate bind_ip uniqueness (against all enabled VPs)
  408. existing = await db.execute(
  409. select(VirtualPrinter).where(
  410. VirtualPrinter.bind_ip == vp.bind_ip,
  411. VirtualPrinter.id != vp_id,
  412. VirtualPrinter.enabled == True, # noqa: E712
  413. )
  414. )
  415. conflict = existing.scalar_one_or_none()
  416. if conflict:
  417. logger.warning(
  418. "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
  419. vp_id,
  420. vp.bind_ip,
  421. conflict.id,
  422. conflict.enabled,
  423. conflict.mode,
  424. )
  425. return JSONResponse(
  426. status_code=400,
  427. content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
  428. )
  429. if effective_mode == "proxy":
  430. if not vp.target_printer_id:
  431. logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
  432. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  433. else:
  434. if not vp.access_code:
  435. logger.warning(
  436. "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
  437. )
  438. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  439. elif new_enabled and body.enabled is None:
  440. # VP is already enabled and user is changing other fields —
  441. # auto-disable if new state doesn't meet requirements
  442. if not vp.bind_ip:
  443. new_enabled = False
  444. elif effective_mode == "proxy":
  445. if not vp.target_printer_id:
  446. new_enabled = False
  447. else:
  448. if not vp.access_code:
  449. new_enabled = False
  450. vp.enabled = new_enabled
  451. await db.commit()
  452. await db.refresh(vp)
  453. logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
  454. # Sync services
  455. try:
  456. await virtual_printer_manager.sync_from_db()
  457. except Exception as e:
  458. logger.error("Failed to sync virtual printers after update: %s", e)
  459. instance = virtual_printer_manager.get_instance(vp.id)
  460. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  461. return await _vp_to_dict(vp, db, status)
  462. async def drop_virtual_printer(db: AsyncSession, vp_id: int) -> Path:
  463. """Delete a virtual printer's row and discard its pending uploads.
  464. Its instance must be stopped first, if one runs. The caller commits, then
  465. calls remove_upload_dir with what this returns. *db* is a session or a
  466. connection.
  467. """
  468. from sqlalchemy import delete as sql_delete
  469. from backend.app.models.pending_upload import PendingUpload
  470. from backend.app.models.virtual_printer import VirtualPrinter
  471. from backend.app.services.virtual_printer import virtual_printer_manager
  472. upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
  473. # Mark any PendingUpload rows that referenced this VP's upload_dir as
  474. # discarded — without this the rows live on as phantom entries in
  475. # /pending-uploads/ pointing at file paths that no longer exist, and
  476. # the user only learns they're orphaned by trying to archive one and
  477. # getting a flip-to-discarded on file-missing.
  478. try:
  479. await db.execute(
  480. update(PendingUpload)
  481. # The folder, not every path that starts with its name (uploads/12 for 1).
  482. .where(PendingUpload.file_path.startswith(f"{upload_dir}{os.sep}", autoescape=True))
  483. .values(status="discarded")
  484. )
  485. except Exception as e:
  486. logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
  487. await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  488. return upload_dir
  489. def remove_upload_dir(upload_dir: Path) -> None:
  490. """Remove a deleted virtual printer's upload directory.
  491. After the DB commit succeeds, so a crash between commit and rmtree only
  492. leaves orphan files (vs orphan rows pointing at a now-missing tree).
  493. """
  494. if upload_dir.exists():
  495. import shutil
  496. shutil.rmtree(upload_dir, ignore_errors=True)
  497. @router.delete("/{vp_id}")
  498. async def delete_virtual_printer(
  499. vp_id: int,
  500. db: AsyncSession = Depends(get_db),
  501. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  502. ):
  503. """Delete a virtual printer."""
  504. from backend.app.models.virtual_printer import VirtualPrinter
  505. from backend.app.services.virtual_printer import virtual_printer_manager
  506. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  507. vp = result.scalar_one_or_none()
  508. if not vp:
  509. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  510. vp_name = vp.name
  511. # Stop instance if running
  512. await virtual_printer_manager.remove_instance(vp_id)
  513. upload_dir = await drop_virtual_printer(db, vp_id)
  514. await db.commit()
  515. remove_upload_dir(upload_dir)
  516. logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
  517. # Resync remaining services
  518. try:
  519. await virtual_printer_manager.sync_from_db()
  520. except Exception as e:
  521. logger.error("Failed to sync virtual printers after delete: %s", e)
  522. return {"detail": "Deleted", "id": vp_id}