Files
bambuddy/backend/app/api/routes/slicer_plugin.py
maziggy 1aa036294a feat(slicer-plugin): upload API for slicer plugins
Adds GET /slicer-plugin/info and POST /slicer-plugin/uploads, which take
a sliced file plus a JSON manifest in one multipart request: what the user
asked for (store, or queue a plate x copies on a printer, optionally held
for a manual start) and where the file came from (slicer and plugin
versions, presets, a model fingerprint kept for later matching).

The manifest does not carry the slice's numbers; plates, time, grams and
sliced_for_model are read from the file by the library's own parser. The
work is delegated rather than re-implemented: the body of POST
/library/files becomes a shared ingest_library_upload, and queueing calls
POST /queue/ itself, so every validation, compatibility, filament and
budget check applies unchanged.

Sends are idempotent on a plugin-chosen upload_id, claimed through a
unique constraint, released when the attempt fails, taken over when
abandoned, and replayed only to the caller who first used it; a retried
queue send is otherwise a second print. A missing plate and a key that may
not queue are refused before anything is stored. A refusal from the queue
keeps the file, reports queue_error and rolls back what that route wrote
first, since with billing on it creates the batch before asking for a cost
centre. cost_center_id is accepted for the same reason.

New table slicer_plugin_uploads, created on startup.
2026-09-25 15:24:27 +02:00

333 lines
13 KiB
Python

"""API for the Bambuddy slicer plugin (OrcaSlicer).
The plugin runs inside the slicer on the user's machine and sends a sliced
file here together with a manifest: what the user wants done with it (library
only, or queue plate N x copies on a printer) and where it came from (slicer,
presets, a fingerprint of the model). The file's own contents -- per-plate
grams, time, the model it was sliced for -- are read from the file by the same
parser every library upload goes through, not taken from the manifest, so the
two can never disagree.
The work is delegated: storing the file is ``ingest_library_upload`` and
queueing it is ``POST /queue/`` itself, so a file sent from the slicer passes
exactly the checks one added through the UI does.
"""
import asyncio
import io
import json
import logging
import zipfile
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile
from fastapi.security import HTTPAuthorizationCredentials
from pydantic import ValidationError
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.library import ingest_library_upload
from backend.app.api.routes.print_queue import add_to_queue
from backend.app.core.auth import _validate_api_key, require_permission_if_auth_enabled, security
from backend.app.core.config import APP_VERSION
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.slicer_plugin_upload import SlicerPluginUpload
from backend.app.models.user import User
from backend.app.schemas.print_queue import PrintQueueItemCreate
from backend.app.schemas.slicer_plugin import (
SlicerPluginInfoResponse,
SlicerPluginLibraryFile,
SlicerPluginManifest,
SlicerPluginQueueResult,
SlicerPluginUploadResponse,
)
from backend.app.utils.printer_models import is_gcode_compatible
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/slicer-plugin", tags=["slicer-plugin"])
# Manifest versions this server understands. A plugin reads them from /info and
# sends the newest one both sides know.
MANIFEST_VERSIONS = (1,)
# A claim still "processing" after this long belongs to a request that died
# without releasing it (a killed worker, a cancelled task). Past it, a retry
# with the same upload_id may take the claim over instead of being refused
# forever. Far longer than any upload plus queue insert takes.
_STALE_CLAIM = timedelta(minutes=10)
_MAX_MANIFEST_CHARS = 64_000
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
async def _may_queue(credentials: HTTPAuthorizationCredentials | None, x_api_key: str | None) -> bool:
"""Whether the caller may create queue items, decided by the queue's own gate.
Asks the dependency ``POST /queue/`` is guarded by rather than restating
its rules, so a key or account that could not queue from the UI cannot
queue from the slicer either.
"""
try:
await require_permission_if_auth_enabled(Permission.QUEUE_CREATE)(credentials=credentials, x_api_key=x_api_key)
except HTTPException as e:
if e.status_code == 403:
return False
raise
return True
async def _caller_id(
db: AsyncSession,
user: User | None,
credentials: HTTPAuthorizationCredentials | None,
x_api_key: str | None,
) -> str:
"""Name the caller, so an upload_id replays only to whoever used it first."""
if user is not None:
return f"user:{user.id}"
key_value = x_api_key
if key_value is None and credentials is not None and credentials.credentials.startswith("bb_"):
key_value = credentials.credentials
if key_value:
api_key = await _validate_api_key(db, key_value)
if api_key is not None:
return f"apikey:{api_key.id}"
return "anonymous"
def _check_plate(content: bytes, plate_id: int) -> str | None:
"""Return why ``plate_id`` cannot be printed from this file, or None if it can."""
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
names = set(zf.namelist())
except zipfile.BadZipFile:
return "plate_id needs a sliced 3MF; this file is not one"
if f"Metadata/plate_{plate_id}.gcode" not in names:
return f"This file has no sliced plate {plate_id}"
return None
def _replay(existing: SlicerPluginUpload, caller: str) -> SlicerPluginUploadResponse:
"""Answer a repeated upload_id with what the first request returned."""
# Anyone else presenting the id gets nothing about it: a 409 that is the
# same whether or not the stored upload is still in progress.
if existing.caller != caller:
raise HTTPException(409, "upload_id has already been used")
if existing.status != "done" or not existing.response:
raise HTTPException(409, "An upload with this upload_id is still being processed")
data = json.loads(existing.response)
data["replayed"] = True
return SlicerPluginUploadResponse.model_validate(data)
@router.get("/info", response_model=SlicerPluginInfoResponse)
async def plugin_info(
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
credentials: HTTPAuthorizationCredentials | None = Depends(security),
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
):
"""Handshake for the plugin's connect dialog.
Needs the same permission an upload does, so "connected" means the plugin
can actually send. ``can_queue`` tells it whether to offer queueing at all.
"""
return SlicerPluginInfoResponse(
bambuddy_version=APP_VERSION,
manifest_versions=list(MANIFEST_VERSIONS),
can_upload=True,
can_queue=await _may_queue(credentials, x_api_key),
)
@router.post("/uploads", response_model=SlicerPluginUploadResponse)
async def upload_from_slicer(
file: UploadFile = File(...),
manifest: str = Form(..., max_length=_MAX_MANIFEST_CHARS),
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
credentials: HTTPAuthorizationCredentials | None = Depends(security),
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
):
"""Store a sliced file from the slicer plugin, and queue it if asked.
Idempotent on ``manifest.upload_id``: a retry of a send whose response was
lost gets the first response back (``replayed: true``) instead of a second
upload -- and, with action "queue", instead of a second print.
"""
try:
m = SlicerPluginManifest.model_validate_json(manifest)
except ValidationError as e:
raise HTTPException(422, detail=e.errors(include_url=False, include_context=False, include_input=False))
if m.schema_version not in MANIFEST_VERSIONS:
raise HTTPException(
400,
f"Manifest version {m.schema_version} is not supported; this server reads {list(MANIFEST_VERSIONS)}",
)
if not file.filename:
raise HTTPException(400, "Filename is required")
intent = m.intent
caller = await _caller_id(db, current_user, credentials, x_api_key)
# A repeat is answered before anything is checked or read, so a retry of an
# upload that succeeded returns its answer even if the caller has since
# lost a permission it had then.
existing = (
await db.execute(select(SlicerPluginUpload).where(SlicerPluginUpload.upload_id == m.upload_id))
).scalar_one_or_none()
if existing is not None:
abandoned = (
existing.status != "done" and existing.caller == caller and existing.created_at < _utcnow() - _STALE_CLAIM
)
if not abandoned:
return _replay(existing, caller)
await db.delete(existing)
await db.commit()
# Refused before anything is written: a plugin told "uploaded, but you may
# not queue" would leave a file in the library the user never asked to keep.
if intent.action == "queue" and not await _may_queue(credentials, x_api_key):
raise HTTPException(403, "This account or API key may upload files but not queue them")
content = await file.read()
if intent.action == "queue" and intent.plate_id is not None:
plate_problem = await asyncio.to_thread(_check_plate, content, intent.plate_id)
if plate_problem:
raise HTTPException(400, plate_problem)
# Claim the id. The unique constraint decides between two requests that
# both got past the lookup above -- the plugin retrying a send that timed
# out while the first attempt was still working.
source = m.source
claim = SlicerPluginUpload(
upload_id=m.upload_id,
caller=caller,
status="processing",
slicer=source.slicer,
slicer_version=source.slicer_version,
plugin_version=source.plugin_version,
project_name=source.project_name,
model_fingerprint=source.model_fingerprint,
presets=source.presets.model_dump_json() if source.presets else None,
created_at=_utcnow(),
)
db.add(claim)
try:
await db.commit()
except IntegrityError:
await db.rollback()
winner = (
await db.execute(select(SlicerPluginUpload).where(SlicerPluginUpload.upload_id == m.upload_id))
).scalar_one()
return _replay(winner, caller)
claim_id = claim.id
try:
response = await _store_and_queue(db, m, file.filename, content, current_user)
except Exception:
# Release the claim, so the plugin's retry runs again rather than
# being told the failed attempt is still in progress.
await db.rollback()
await db.execute(delete(SlicerPluginUpload).where(SlicerPluginUpload.id == claim_id))
await db.commit()
raise
claim = await db.get(SlicerPluginUpload, claim_id)
claim.status = "done"
claim.library_file_id = response.library_file.id
claim.response = response.model_dump_json()
await db.commit()
return response
async def _store_and_queue(
db: AsyncSession,
m: SlicerPluginManifest,
filename: str,
content: bytes,
current_user: User | None,
) -> SlicerPluginUploadResponse:
intent = m.intent
library_file, duplicate_of = await ingest_library_upload(
db,
filename=filename,
content=content,
folder_id=intent.folder_id,
created_by_id=current_user.id if current_user else None,
)
stored = SlicerPluginLibraryFile(
id=library_file.id,
filename=library_file.filename,
file_type=library_file.file_type,
file_size=library_file.file_size,
thumbnail_path=library_file.thumbnail_path,
duplicate_of=duplicate_of,
)
sliced_for = (library_file.file_metadata or {}).get("sliced_for_model")
warnings: list[str] = []
if duplicate_of is not None:
warnings.append(f"The library already holds this exact file (#{duplicate_of}).")
if intent.action != "queue":
return SlicerPluginUploadResponse(upload_id=m.upload_id, library_file=stored, warnings=warnings)
# POST /queue/ refuses a target_model the file was not sliced for, but a
# named printer is the user's own pick and is only flagged -- the same
# latitude the print dialog gives.
if intent.printer_id is not None:
printer = await db.get(Printer, intent.printer_id)
if printer is not None and not is_gcode_compatible(sliced_for, printer.model):
warnings.append(f"Sliced for {sliced_for}, but {printer.name} is a {printer.model}.")
try:
item = await add_to_queue(
PrintQueueItemCreate(
library_file_id=library_file.id,
printer_id=intent.printer_id,
target_model=intent.target_model,
plate_id=intent.plate_id,
quantity=intent.copies,
manual_start=intent.manual_start,
cost_center_id=intent.cost_center_id,
),
db=db,
current_user=current_user,
)
except HTTPException as e:
# The queue route may have flushed a batch row before refusing (the
# budget check runs after it); none of that may outlive the refusal.
await db.rollback()
queue_error = e.detail if isinstance(e.detail, str) else json.dumps(e.detail)
return SlicerPluginUploadResponse(
upload_id=m.upload_id, library_file=stored, queue_error=queue_error, warnings=warnings
)
if item.batch_id is None:
item_ids = [item.id]
else:
item_ids = list(
(
await db.execute(
select(PrintQueueItem.id)
.where(PrintQueueItem.batch_id == item.batch_id)
.order_by(PrintQueueItem.position)
)
).scalars()
)
return SlicerPluginUploadResponse(
upload_id=m.upload_id,
library_file=stored,
queue=SlicerPluginQueueResult(queue_item_ids=item_ids, batch_id=item.batch_id),
warnings=warnings,
)