mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN that rules out subnet / mDNS-reflector theories): with the physical printer off the slicer's "Send" landed in Bambuddy's archive; once the printer powered on every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP receive + archive lines, mape6-after had zero FTP attempts to Bambuddy once the printer was online. Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le / _vp_ip_uint32_le ONLY on client-identity change and early-returned on every refresh tick when the same client object was still bound. If target_client.ip_address was empty at first bind (DB row stale, or client constructed before SSDP refresh filled it in), the encoding stayed None, the net.info[*].ip rewrite block was skipped, the cache filled with the real printer IP, sticky-key preservation kept the poisoned net value alive across every subsequent incremental push, and the slicer followed the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the workaround both reporters independently arrived at. Same shape on multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries whose ip equalled _target_ip_uint32_le, so a secondary interface IP Bambuddy never saw would leak through unchanged. Bridge fix: - _resolve_client calls a new _refresh_ip_encoding() on every refresh tick, even when client identity is unchanged; self-heals once ip_address becomes valid. - _refresh_ip_encoding() sweeps the existing _latest_print_state when encoding becomes valid for the first time. Without the sweep, sticky-key preservation keeps the pre-arm poisoned cache alive forever — incremental pushes that don't include net carry the bad value forward. - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry that doesn't already equal the VP IP, not just entries matching _target_ip_uint32_le. Multi-NIC printers stop leaking secondary interfaces. Zero-IP placeholders are left alone so "active interface" detection still works. - INFO logging on encoding arm/update and on cache sweep so future bundles directly answer "did the rewrite fire?". Mode wire-value rename (#1429 follow-up, separate confusion source): - Both reporters' support bundles showed mode: immediate while the UI said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". UI button "Archive" had always saved immediate, and "Queue" had always saved print_queue. Canonical wire values are now archive / review / queue / proxy, matching the button labels 1:1. - New normalize_vp_mode() + VP_MODE_* constants in models/virtual_printer.py; manager.py normalises on construction so a legacy row read pre-migration still dispatches correctly. - core/database.py::run_migrations rewrites existing virtual_printers and settings rows; idempotent (re-runs are no-ops); identical SQL under SQLite and Postgres. - API routes accept both legacy and canonical on input, normalise before storage. GET /settings/virtual-printer normalises on read so the frontend's mode-button highlight works for stale legacy values. - Three frontend VP components (VirtualPrinterSettings, VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers and type aliases to canonical; each got its own normalizeMode() helper so a stale-cached settings payload still highlights the right button. Two pre-existing `printer.mode === 'queue' ? 'review'` legacy mappings in VirtualPrinterCard were the source of a test failure caught mid-implementation where the new canonical 'queue' was being mis-aliased back to 'review' and hiding the auto-dispatch + force-color-match toggles. mode handler is NOT the dispatch bug: manager.py::_archive_file (the handler for archive mode) doesn't dispatch to the physical printer. The "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache. The mode rename is purely clarity / support- bundle accuracy.
545 lines
21 KiB
Python
545 lines
21 KiB
Python
import logging
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.virtual_printer import VPDiagnosticResult
|
|
|
|
# Imported at module scope so tests can patch
|
|
# backend.app.api.routes.virtual_printers.tailscale_service.
|
|
from backend.app.services.virtual_printer.tailscale import tailscale_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
|
|
|
|
|
|
class TailscaleStatusResponse(BaseModel):
|
|
available: bool
|
|
fqdn: str
|
|
hostname: str
|
|
tailnet_name: str
|
|
tailscale_ips: list[str]
|
|
error: str | None
|
|
|
|
|
|
class VirtualPrinterCreate(BaseModel):
|
|
name: str = "Bambuddy"
|
|
enabled: bool = False
|
|
mode: str = "archive"
|
|
model: str | None = None
|
|
access_code: str | None = None
|
|
target_printer_id: int | None = None
|
|
auto_dispatch: bool = True
|
|
queue_force_color_match: bool = False
|
|
bind_ip: str | None = None
|
|
remote_interface_ip: str | None = None
|
|
|
|
|
|
class VirtualPrinterUpdate(BaseModel):
|
|
name: str | None = None
|
|
enabled: bool | None = None
|
|
mode: str | None = None
|
|
model: str | None = None
|
|
access_code: str | None = None
|
|
target_printer_id: int | None = None
|
|
auto_dispatch: bool | None = None
|
|
queue_force_color_match: bool | None = None
|
|
bind_ip: str | None = None
|
|
remote_interface_ip: str | None = None
|
|
tailscale_disabled: bool | None = None
|
|
|
|
|
|
def _resolve_printer_model(printer_model: str | None) -> str | None:
|
|
"""Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
|
|
|
|
Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
|
|
"""
|
|
if not printer_model:
|
|
return None
|
|
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
|
|
from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
|
|
|
|
# Already a valid SSDP model code
|
|
if printer_model in VIRTUAL_PRINTER_MODELS:
|
|
return printer_model
|
|
# Map display name to SSDP code
|
|
return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
|
|
|
|
|
|
def _vp_to_dict(vp, status: dict | None = None) -> dict:
|
|
"""Convert VirtualPrinter model to response dict."""
|
|
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
|
|
from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
|
|
|
|
model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
|
|
serial = _get_serial_for_model(model_code, vp.serial_suffix)
|
|
|
|
return {
|
|
"id": vp.id,
|
|
"name": vp.name,
|
|
"enabled": vp.enabled,
|
|
"mode": vp.mode,
|
|
"model": model_code,
|
|
"model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
|
|
"access_code_set": bool(vp.access_code),
|
|
"serial": serial,
|
|
"target_printer_id": vp.target_printer_id,
|
|
"auto_dispatch": vp.auto_dispatch,
|
|
"queue_force_color_match": vp.queue_force_color_match,
|
|
"bind_ip": vp.bind_ip,
|
|
"remote_interface_ip": vp.remote_interface_ip,
|
|
"tailscale_disabled": vp.tailscale_disabled,
|
|
"position": vp.position,
|
|
"status": status or {"running": False, "pending_files": 0},
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
async def list_virtual_printers(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""List all virtual printers with status."""
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
|
|
|
result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
|
|
vps = result.scalars().all()
|
|
|
|
printers = []
|
|
for vp in vps:
|
|
instance = virtual_printer_manager.get_instance(vp.id)
|
|
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
|
printers.append(_vp_to_dict(vp, status))
|
|
|
|
return {
|
|
"printers": printers,
|
|
"models": VIRTUAL_PRINTER_MODELS,
|
|
}
|
|
|
|
|
|
@router.post("")
|
|
async def create_virtual_printer(
|
|
body: VirtualPrinterCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Create a new virtual printer."""
|
|
from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
|
|
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
|
from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
|
|
|
|
# Accept both canonical and legacy wire values so older clients (forks /
|
|
# mobile shortcuts / scripted setups) still work; normalize before write.
|
|
body.mode = normalize_vp_mode(body.mode) or body.mode
|
|
if body.mode not in VP_MODE_VALUES:
|
|
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
|
|
|
|
# Validate model
|
|
if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
|
|
)
|
|
|
|
# Validate access code length
|
|
if body.access_code and len(body.access_code) != 8:
|
|
return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
|
|
|
|
# Validation when enabling
|
|
if body.enabled:
|
|
if not body.bind_ip:
|
|
return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
|
|
if body.mode == "proxy":
|
|
if not body.target_printer_id:
|
|
return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
|
|
else:
|
|
if not body.access_code:
|
|
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
|
|
|
|
# Validate proxy target printer exists
|
|
target_printer = None
|
|
if body.target_printer_id:
|
|
from backend.app.models.printer import Printer
|
|
|
|
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
|
|
target_printer = result.scalar_one_or_none()
|
|
if not target_printer:
|
|
return JSONResponse(
|
|
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
|
|
)
|
|
|
|
# Validate bind_ip uniqueness (against all enabled VPs)
|
|
if body.bind_ip:
|
|
result = await db.execute(
|
|
select(VirtualPrinter).where(
|
|
VirtualPrinter.bind_ip == body.bind_ip,
|
|
VirtualPrinter.enabled == True, # noqa: E712
|
|
)
|
|
)
|
|
if result.scalar_one_or_none():
|
|
return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
|
|
|
|
# Generate next serial suffix
|
|
result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
|
|
last_suffix = result.scalar()
|
|
if last_suffix:
|
|
try:
|
|
next_num = int(last_suffix) + 1
|
|
new_suffix = str(next_num).zfill(9)
|
|
except ValueError:
|
|
new_suffix = "391800002"
|
|
else:
|
|
new_suffix = "391800001"
|
|
|
|
# Get next position
|
|
result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
|
|
last_pos = result.scalar()
|
|
next_pos = (last_pos or 0) + 1
|
|
|
|
vp = VirtualPrinter(
|
|
name=body.name,
|
|
enabled=body.enabled,
|
|
mode=body.mode,
|
|
model=body.model
|
|
or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
|
|
or DEFAULT_VIRTUAL_PRINTER_MODEL,
|
|
access_code=body.access_code,
|
|
target_printer_id=body.target_printer_id,
|
|
auto_dispatch=body.auto_dispatch,
|
|
queue_force_color_match=body.queue_force_color_match,
|
|
bind_ip=body.bind_ip,
|
|
remote_interface_ip=body.remote_interface_ip,
|
|
serial_suffix=new_suffix,
|
|
position=next_pos,
|
|
)
|
|
db.add(vp)
|
|
await db.commit()
|
|
await db.refresh(vp)
|
|
|
|
logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
|
|
|
|
# Sync services if enabled
|
|
if body.enabled:
|
|
try:
|
|
await virtual_printer_manager.sync_from_db()
|
|
except Exception as e:
|
|
logger.error("Failed to start virtual printer after create: %s", e)
|
|
|
|
return _vp_to_dict(vp)
|
|
|
|
|
|
@router.get("/tailscale-status", response_model=TailscaleStatusResponse)
|
|
async def get_tailscale_status(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
) -> TailscaleStatusResponse:
|
|
"""Return current Tailscale availability and machine identity.
|
|
|
|
Used by the frontend to indicate whether virtual printer TLS is backed
|
|
by a trusted Let's Encrypt certificate or a self-signed CA.
|
|
"""
|
|
status = await tailscale_service.get_status()
|
|
return TailscaleStatusResponse(
|
|
available=status.available,
|
|
fqdn=status.fqdn,
|
|
hostname=status.hostname,
|
|
tailnet_name=status.tailnet_name,
|
|
tailscale_ips=status.tailscale_ips,
|
|
error=status.error,
|
|
)
|
|
|
|
|
|
@router.get("/ca-certificate")
|
|
async def get_ca_certificate(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
|
|
|
|
One CA is shared by every virtual printer — the user imports it into their
|
|
slicer's trust store once. Only the public certificate is returned; the CA
|
|
private key never leaves the backend.
|
|
"""
|
|
from backend.app.services.virtual_printer import virtual_printer_manager
|
|
|
|
try:
|
|
return virtual_printer_manager.get_ca_certificate_info()
|
|
except Exception as e:
|
|
logger.error("Failed to obtain virtual printer CA certificate: %s", e)
|
|
return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
|
|
|
|
|
|
@router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
|
|
async def diagnose_virtual_printer(
|
|
vp_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""Run setup diagnostics for a virtual printer.
|
|
|
|
Probes the VP's own bind IP and services so the user can self-diagnose the
|
|
common "my virtual printer doesn't show up in the slicer" failures.
|
|
"""
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.services.virtual_printer import virtual_printer_manager
|
|
from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
|
|
|
|
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
|
vp = result.scalar_one_or_none()
|
|
if not vp:
|
|
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
|
|
|
instance = virtual_printer_manager.get_instance(vp.id)
|
|
return await run_vp_diagnostic(vp, instance)
|
|
|
|
|
|
@router.get("/{vp_id}")
|
|
async def get_virtual_printer(
|
|
vp_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""Get a single virtual printer with status."""
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.services.virtual_printer import virtual_printer_manager
|
|
|
|
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
|
vp = result.scalar_one_or_none()
|
|
if not vp:
|
|
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
|
|
|
instance = virtual_printer_manager.get_instance(vp.id)
|
|
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
|
|
|
return _vp_to_dict(vp, status)
|
|
|
|
|
|
@router.put("/{vp_id}")
|
|
async def update_virtual_printer(
|
|
vp_id: int,
|
|
body: VirtualPrinterUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Update a virtual printer."""
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
|
|
|
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
|
vp = result.scalar_one_or_none()
|
|
if not vp:
|
|
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
|
|
|
# Redact the access code before logging — model_dump otherwise includes
|
|
# the plaintext value at DEBUG, violating the project no-secrets-in-logs
|
|
# rule. Replace with a marker that still signals "the user changed it"
|
|
# vs "the user didn't touch this field".
|
|
_safe_body = body.model_dump(exclude_unset=True)
|
|
if "access_code" in _safe_body:
|
|
_safe_body["access_code"] = "***"
|
|
logger.debug(
|
|
"Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
|
|
vp_id,
|
|
_safe_body,
|
|
vp.mode,
|
|
vp.enabled,
|
|
bool(vp.access_code),
|
|
vp.bind_ip,
|
|
vp.target_printer_id,
|
|
)
|
|
|
|
# Apply updates
|
|
if body.name is not None:
|
|
vp.name = body.name
|
|
if body.mode is not None:
|
|
from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
|
|
|
|
canonical_mode = normalize_vp_mode(body.mode) or body.mode
|
|
if canonical_mode not in VP_MODE_VALUES:
|
|
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
|
|
vp.mode = canonical_mode
|
|
if body.model is not None:
|
|
if body.model not in VIRTUAL_PRINTER_MODELS:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
|
|
)
|
|
vp.model = body.model
|
|
if body.access_code is not None:
|
|
if body.access_code and len(body.access_code) != 8:
|
|
return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
|
|
vp.access_code = body.access_code
|
|
if body.target_printer_id is not None:
|
|
from backend.app.models.printer import Printer
|
|
|
|
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
|
|
target_printer = result.scalar_one_or_none()
|
|
if not target_printer:
|
|
return JSONResponse(
|
|
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
|
|
)
|
|
vp.target_printer_id = body.target_printer_id
|
|
# Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
|
|
if body.model is None and vp.mode == "proxy" and target_printer.model:
|
|
vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
|
|
if body.auto_dispatch is not None:
|
|
vp.auto_dispatch = body.auto_dispatch
|
|
if body.queue_force_color_match is not None:
|
|
vp.queue_force_color_match = body.queue_force_color_match
|
|
if body.bind_ip is not None:
|
|
vp.bind_ip = body.bind_ip
|
|
if body.remote_interface_ip is not None:
|
|
vp.remote_interface_ip = body.remote_interface_ip
|
|
if body.tailscale_disabled is not None:
|
|
vp.tailscale_disabled = body.tailscale_disabled
|
|
|
|
# Auto-inherit model when switching to proxy mode with existing target printer
|
|
if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
|
|
from backend.app.models.printer import Printer as PrinterModel
|
|
|
|
result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
|
|
existing_target = result.scalar_one_or_none()
|
|
if existing_target and existing_target.model:
|
|
vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
|
|
|
|
# Determine final enabled state
|
|
explicitly_enabling = body.enabled is True
|
|
new_enabled = body.enabled if body.enabled is not None else vp.enabled
|
|
effective_mode = vp.mode
|
|
|
|
if explicitly_enabling:
|
|
# User is explicitly toggling on — enforce all requirements
|
|
if not vp.bind_ip:
|
|
logger.warning("Update VP %d rejected: no bind_ip", vp_id)
|
|
return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
|
|
# Validate bind_ip uniqueness (against all enabled VPs)
|
|
existing = await db.execute(
|
|
select(VirtualPrinter).where(
|
|
VirtualPrinter.bind_ip == vp.bind_ip,
|
|
VirtualPrinter.id != vp_id,
|
|
VirtualPrinter.enabled == True, # noqa: E712
|
|
)
|
|
)
|
|
conflict = existing.scalar_one_or_none()
|
|
if conflict:
|
|
logger.warning(
|
|
"Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
|
|
vp_id,
|
|
vp.bind_ip,
|
|
conflict.id,
|
|
conflict.enabled,
|
|
conflict.mode,
|
|
)
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
|
|
)
|
|
if effective_mode == "proxy":
|
|
if not vp.target_printer_id:
|
|
logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
|
|
return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
|
|
else:
|
|
if not vp.access_code:
|
|
logger.warning(
|
|
"Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
|
|
)
|
|
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
|
|
elif new_enabled and body.enabled is None:
|
|
# VP is already enabled and user is changing other fields —
|
|
# auto-disable if new state doesn't meet requirements
|
|
if not vp.bind_ip:
|
|
new_enabled = False
|
|
elif effective_mode == "proxy":
|
|
if not vp.target_printer_id:
|
|
new_enabled = False
|
|
else:
|
|
if not vp.access_code:
|
|
new_enabled = False
|
|
|
|
vp.enabled = new_enabled
|
|
|
|
await db.commit()
|
|
await db.refresh(vp)
|
|
|
|
logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
|
|
|
|
# Sync services
|
|
try:
|
|
await virtual_printer_manager.sync_from_db()
|
|
except Exception as e:
|
|
logger.error("Failed to sync virtual printers after update: %s", e)
|
|
|
|
instance = virtual_printer_manager.get_instance(vp.id)
|
|
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
|
|
|
return _vp_to_dict(vp, status)
|
|
|
|
|
|
@router.delete("/{vp_id}")
|
|
async def delete_virtual_printer(
|
|
vp_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Delete a virtual printer."""
|
|
from sqlalchemy import delete as sql_delete
|
|
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.services.virtual_printer import virtual_printer_manager
|
|
|
|
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
|
vp = result.scalar_one_or_none()
|
|
if not vp:
|
|
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
|
|
|
vp_name = vp.name
|
|
|
|
# Stop instance if running
|
|
await virtual_printer_manager.remove_instance(vp_id)
|
|
|
|
# Mark any PendingUpload rows that referenced this VP's upload_dir as
|
|
# discarded — without this the rows live on as phantom entries in
|
|
# /pending-uploads/ pointing at file paths that no longer exist, and
|
|
# the user only learns they're orphaned by trying to archive one and
|
|
# getting a flip-to-discarded on file-missing.
|
|
upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
|
|
try:
|
|
from backend.app.models.pending_upload import PendingUpload
|
|
|
|
stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix)))
|
|
for pending in stale.scalars().all():
|
|
pending.status = "discarded"
|
|
await db.flush()
|
|
except Exception as e:
|
|
logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
|
|
|
|
# Delete from DB
|
|
await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
|
await db.commit()
|
|
|
|
# Remove the on-disk upload directory after the DB commit succeeds, so
|
|
# a crash between commit and rmtree only leaves orphan files (vs orphan
|
|
# rows pointing at a now-missing tree).
|
|
upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
|
|
if upload_dir.exists():
|
|
import shutil
|
|
|
|
shutil.rmtree(upload_dir, ignore_errors=True)
|
|
|
|
logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
|
|
|
|
# Resync remaining services
|
|
try:
|
|
await virtual_printer_manager.sync_from_db()
|
|
except Exception as e:
|
|
logger.error("Failed to sync virtual printers after delete: %s", e)
|
|
|
|
return {"detail": "Deleted", "id": vp_id}
|