mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Lets a reprint reuse the AMS slot the slicer itself picked, instead of re-deriving one from the file's static type/color. When a Print Queue VP has "Save AMS mapping" on, the slicer's own live-resolved ams_mapping (from the project_file MQTT command) is persisted onto the archive as extra_data.slicer_ams_mapping. A later reprint can reuse it via a new "Mapping" button in the filament-mapping panel — one click snaps every slot to the saved pick, click again reverts to auto-match. Archive cards and queue rows get an "AMS mapping saved" badge so it's visible beforehand. add_to_queue also falls back to the saved mapping automatically when the caller sends no explicit ams_mapping (e.g. a plain reprint with no per-slot edits). The queue item's own ams_mapping (used for that dispatch) is still captured unconditionally whenever the slicer provides it — that part is a correctness fix, not gated behind the toggle. Only the archive persistence for future reprints is opt-in. Split out from the original combined PR per review: this half is genuinely opt-in and low-risk (#2684). The dispatch-time validation gate that keeps a stored mapping honest (#1308) changes behaviour for every existing user and will land as its own PR. Review fixes applied: - _extract_slicer_ams_mapping_json: dropped the unreachable `v is None` arm and rejected bool explicitly (isinstance(v, int) accepts bool). - Translated the Russian docstring text to English. - save_ams_mapping's model comment moved to a trailing comment on the column line, matching the file's convention. - usingArchiveMapping now resets when the plate or archive changes, so the Mapping button can't read ON against a mapping it never applied. - Translated "Click to change slot assignment" and "Re-read". - add_to_queue's fallback is now called out explicitly in code comments and covered by three new integration tests (fallback fires, explicit mapping wins, unrelated extra_data doesn't false-trigger). Closes #2684
597 lines
24 KiB
Python
597 lines
24 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
|
|
save_ams_mapping: bool = False
|
|
gcode_injection: 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
|
|
save_ams_mapping: bool | None = None
|
|
gcode_injection: 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)
|
|
|
|
|
|
async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
|
|
"""Convert VirtualPrinter model to response dict.
|
|
|
|
In proxy mode the surfaced serial is the target printer's actual serial
|
|
(what the bridge advertises over SSDP / what slicers see), not the
|
|
self-generated suffix. Archive / queue / review keep the self-generated
|
|
serial since those modes never speak the target's identity.
|
|
"""
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.virtual_printer import VP_MODE_PROXY
|
|
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)
|
|
if vp.mode == VP_MODE_PROXY and vp.target_printer_id:
|
|
result = await db.execute(select(Printer.serial_number).where(Printer.id == vp.target_printer_id))
|
|
target_serial = result.scalar_one_or_none()
|
|
if target_serial:
|
|
serial = target_serial
|
|
|
|
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,
|
|
"save_ams_mapping": vp.save_ams_mapping,
|
|
"gcode_injection": vp.gcode_injection,
|
|
"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(await _vp_to_dict(vp, db, 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. Non-proxy VPs with a target printer derive
|
|
# their access code from the target (the bridge forwards the slicer's
|
|
# auth bytes through to the real printer, so the codes MUST match),
|
|
# so a separately-supplied access_code isn't required in that case.
|
|
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 and not body.target_printer_id:
|
|
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"})
|
|
|
|
# Force-inherit the access code from the target printer for non-proxy VPs.
|
|
# The non-proxy bridge (Immediate / Review / Queue with a target set) forwards
|
|
# the slicer's MQTT / RTSPS auth bytes through to the real printer, so any
|
|
# value the user supplied here would silently break the bridge if it didn't
|
|
# match the printer's code. The UI now renders the field read-only when a
|
|
# target is set; this is the belt-and-braces backstop for any non-UI client.
|
|
effective_access_code = body.access_code
|
|
if body.mode != "proxy" and target_printer is not None:
|
|
effective_access_code = target_printer.access_code
|
|
|
|
# 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=effective_access_code,
|
|
target_printer_id=body.target_printer_id,
|
|
auto_dispatch=body.auto_dispatch,
|
|
queue_force_color_match=body.queue_force_color_match,
|
|
save_ams_mapping=body.save_ams_mapping,
|
|
gcode_injection=body.gcode_injection,
|
|
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 await _vp_to_dict(vp, db)
|
|
|
|
|
|
@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 await _vp_to_dict(vp, db, 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.save_ams_mapping is not None:
|
|
vp.save_ams_mapping = body.save_ams_mapping
|
|
if body.gcode_injection is not None:
|
|
vp.gcode_injection = body.gcode_injection
|
|
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
|
|
|
|
# Force-inherit the access code from the target printer for non-proxy VPs.
|
|
# See create_virtual_printer for the rationale: the bridge forwards slicer
|
|
# auth bytes through, so the VP's code MUST equal the target's. This block
|
|
# runs after every patch (whether or not access_code or target were in the
|
|
# body), so changing the target also resyncs the code, and an explicit
|
|
# access_code submitted alongside a target is silently overridden.
|
|
if vp.mode != "proxy" and vp.target_printer_id is not None:
|
|
from backend.app.models.printer import Printer as PrinterModelAC
|
|
|
|
result = await db.execute(select(PrinterModelAC).where(PrinterModelAC.id == vp.target_printer_id))
|
|
target_for_ac = result.scalar_one_or_none()
|
|
if target_for_ac is not None and vp.access_code != target_for_ac.access_code:
|
|
vp.access_code = target_for_ac.access_code
|
|
|
|
# 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 await _vp_to_dict(vp, db, 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}
|