Files
bambuddy/backend/app/models/slicer_plugin_upload.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

54 lines
2.4 KiB
Python

"""Uploads sent by the slicer plugin, and where each one came from."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class SlicerPluginUpload(Base):
"""One upload from the slicer plugin, keyed by the id the plugin chose for it.
Two jobs. It makes the upload idempotent: the plugin retries a send whose
response it never saw, and a retry that queued the file a second time would
print it twice. And it keeps what the file cannot say about itself -- which
slicer and presets produced it, and a fingerprint of the model -- which is
what lets a later slice of the same model find this one.
"""
__tablename__ = "slicer_plugin_uploads"
id: Mapped[int] = mapped_column(primary_key=True)
# Chosen by the plugin, one per send. Unique, so two requests carrying the
# same id cannot both get past the claim in slicer_plugin.py.
upload_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
# Who sent it: "user:<id>", "apikey:<id>", or "anonymous" with auth off. A
# replay from anyone else is refused rather than answered with this
# caller's result.
caller: Mapped[str] = mapped_column(String(40))
# "processing" while the request that claimed the id is still working,
# "done" once `response` holds what it returned.
status: Mapped[str] = mapped_column(String(20), default="processing")
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True, index=True
)
# Provenance, as the plugin reported it.
slicer: Mapped[str | None] = mapped_column(String(50), nullable=True)
slicer_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
plugin_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
project_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
model_fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
presets: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON
# The JSON body the first request returned, sent back verbatim on a replay.
response: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())