Add Print Job tracking for slicer integration

Backend for tracking print jobs from Elegoo/Orca Slicer:

- PrintJob model: tracks pending/completed/cancelled jobs
- API endpoints: create, list, complete, cancel jobs
- needs_weighing flag on Spool: for cancelled print recalibration
- Database migration for new table and column

Workflow:
1. Slicer post-processing script creates pending job
2. User completes job → auto-deducts filament
3. User cancels job → flags spool for manual weigh-in

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 21:10:56 -06:00
parent 695f1adeed
commit 4cd58ebae9
7 changed files with 441 additions and 1 deletions

View File

@@ -0,0 +1,53 @@
"""add_print_jobs.
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2025-01-15 04:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6g7h8"
down_revision = "b2c3d4e5f6g7"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create print_job table and add needs_weighing to spool."""
# Create print_job table
op.create_table(
"print_job",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("finished", sa.DateTime(), nullable=True),
sa.Column("filename", sa.String(length=256), nullable=False),
sa.Column("filament_used_g", sa.Float(), nullable=False),
sa.Column("filament_used_mm", sa.Float(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_print_job_id"), "print_job", ["id"], unique=False)
op.create_index(op.f("ix_print_job_spool_id"), "print_job", ["spool_id"], unique=False)
# Add needs_weighing column to spool table
op.add_column(
"spool",
sa.Column("needs_weighing", sa.Boolean(), nullable=True, server_default="0"),
)
def downgrade() -> None:
"""Drop print_job table and remove needs_weighing from spool."""
op.drop_column("spool", "needs_weighing")
op.drop_index(op.f("ix_print_job_spool_id"), table_name="print_job")
op.drop_index(op.f("ix_print_job_id"), table_name="print_job")
op.drop_table("print_job")

View File

@@ -331,6 +331,10 @@ class Spool(BaseModel):
examples=[""], examples=[""],
) )
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.") archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(
default=False,
description="Whether this spool needs manual weigh-in (e.g., after a cancelled print).",
)
extra: dict[str, str] = Field( extra: dict[str, str] = Field(
description=( description=(
"Extra fields for this spool. All values are JSON-encoded data. " "Extra fields for this spool. All values are JSON-encoded data. "
@@ -384,6 +388,7 @@ class Spool(BaseModel):
lot_nr=item.lot_nr, lot_nr=item.lot_nr,
comment=item.comment, comment=item.comment,
archived=item.archived if item.archived is not None else False, archived=item.archived if item.archived is not None else False,
needs_weighing=item.needs_weighing if item.needs_weighing is not None else False,
extra={field.key: field.value for field in item.extra}, extra={field.key: field.value for field in item.extra},
) )

View File

@@ -0,0 +1,199 @@
"""Print job API endpoints."""
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Spool
from spoolman.database import print_job
from spoolman.database.database import get_db_session
from spoolman.database.models import PrintJob as DBPrintJob
router = APIRouter(
prefix="/print-job",
tags=["print-job"],
)
class PrintJobResponse(BaseModel):
"""Print job response model."""
id: int
spool_id: int
spool: Optional[Spool] = None
created: datetime
finished: Optional[datetime] = None
filename: str
filament_used_g: float
filament_used_mm: Optional[float] = None
status: str
@classmethod
def from_db(cls, db_item: DBPrintJob) -> "PrintJobResponse":
"""Create from database model."""
return cls(
id=db_item.id,
spool_id=db_item.spool_id,
spool=Spool.from_db(db_item.spool) if db_item.spool else None,
created=db_item.created,
finished=db_item.finished,
filename=db_item.filename,
filament_used_g=db_item.filament_used_g,
filament_used_mm=db_item.filament_used_mm,
status=db_item.status,
)
class CreatePrintJobRequest(BaseModel):
"""Request to create a print job."""
spool_id: int = Field(..., description="ID of the spool to use.")
filename: str = Field(..., max_length=256, description="G-code filename.")
filament_used_g: float = Field(..., ge=0, description="Estimated filament usage in grams.")
filament_used_mm: Optional[float] = Field(None, ge=0, description="Estimated filament usage in mm.")
class CompletePrintJobRequest(BaseModel):
"""Request to complete a print job."""
comment: Optional[str] = Field(None, max_length=256, description="Optional comment for adjustment history.")
@router.get(
"",
name="List print jobs",
description="Get a list of print jobs.",
response_model_exclude_none=True,
responses={200: {"model": list[PrintJobResponse]}},
)
async def find(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
status: Annotated[
Optional[str],
Query(description="Filter by status: 'pending', 'completed', 'cancelled'."),
] = None,
spool_id: Annotated[
Optional[int],
Query(description="Filter by spool ID."),
] = None,
limit: Annotated[
Optional[int],
Query(description="Maximum number of items to return."),
] = None,
offset: Annotated[
int,
Query(description="Offset for pagination."),
] = 0,
) -> JSONResponse:
"""Find print jobs."""
db_items, total_count = await print_job.find(
db=db,
status=status,
spool_id=spool_id,
limit=limit,
offset=offset,
)
return JSONResponse(
content=jsonable_encoder(
[PrintJobResponse.from_db(item) for item in db_items],
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
)
@router.get(
"/{job_id}",
name="Get print job",
description="Get a specific print job.",
response_model_exclude_none=True,
responses={404: {"model": Message}},
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> PrintJobResponse:
"""Get a print job by ID."""
db_item = await print_job.get_by_id(db, job_id)
return PrintJobResponse.from_db(db_item)
@router.post(
"",
name="Create print job",
description="Create a new pending print job (called by slicer post-processing script).",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def create(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: CreatePrintJobRequest,
) -> PrintJobResponse:
"""Create a new print job."""
db_item = await print_job.create(
db=db,
spool_id=body.spool_id,
filename=body.filename,
filament_used_g=body.filament_used_g,
filament_used_mm=body.filament_used_mm,
)
return PrintJobResponse.from_db(db_item)
@router.post(
"/{job_id}/complete",
name="Complete print job",
description="Mark a print job as completed - deducts filament from spool.",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def complete(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
body: Optional[CompletePrintJobRequest] = None,
) -> PrintJobResponse:
"""Complete a print job."""
db_item = await print_job.complete(
db=db,
job_id=job_id,
comment=body.comment if body else None,
)
return PrintJobResponse.from_db(db_item)
@router.post(
"/{job_id}/cancel",
name="Cancel print job",
description="Mark a print job as cancelled - flags spool for manual weigh-in.",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def cancel(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> PrintJobResponse:
"""Cancel a print job."""
db_item = await print_job.cancel(db=db, job_id=job_id)
return PrintJobResponse.from_db(db_item)
@router.delete(
"/{job_id}",
name="Delete print job",
description="Delete a print job.",
response_model=Message,
responses={404: {"model": Message}},
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> Message:
"""Delete a print job."""
await print_job.delete(db=db, job_id=job_id)
return Message(message="Success!")

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
from . import backup as backup_module, export, externaldb, field, filament, models, other, setting, spool, vendor from . import backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -113,3 +113,4 @@ app.include_router(other.router)
app.include_router(externaldb.router) app.include_router(externaldb.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(backup_module.router) app.include_router(backup_module.router)
app.include_router(print_job.router)

View File

@@ -84,6 +84,7 @@ class SpoolParameters(BaseModel):
examples=[""], examples=[""],
) )
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.") archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(default=False, description="Whether this spool needs manual weigh-in.")
extra: Optional[dict[str, str]] = Field( extra: Optional[dict[str, str]] = Field(
None, None,
description="Extra fields for this spool.", description="Extra fields for this spool.",

View File

@@ -80,6 +80,10 @@ class Spool(Base):
lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024)) comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column() archived: Mapped[Optional[bool]] = mapped_column()
needs_weighing: Mapped[Optional[bool]] = mapped_column(
default=False,
comment="Flag indicating spool needs manual weigh-in (e.g., after cancelled print).",
)
extra: Mapped[list["SpoolField"]] = relationship( extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool", back_populates="spool",
cascade="save-update, merge, delete, delete-orphan", cascade="save-update, merge, delete, delete-orphan",
@@ -106,6 +110,26 @@ class SpoolAdjustment(Base):
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.") comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
class PrintJob(Base):
"""Track pending/completed print jobs from slicer."""
__tablename__ = "print_job"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
spool: Mapped["Spool"] = relationship()
created: Mapped[datetime] = mapped_column(comment="When the job was created (slice time).")
finished: Mapped[Optional[datetime]] = mapped_column(comment="When the job was completed/cancelled.")
filename: Mapped[str] = mapped_column(String(256), comment="G-code filename.")
filament_used_g: Mapped[float] = mapped_column(comment="Estimated filament usage in grams.")
filament_used_mm: Mapped[Optional[float]] = mapped_column(comment="Estimated filament usage in mm.")
status: Mapped[str] = mapped_column(
String(16),
default="pending",
comment="Status: 'pending', 'completed', 'cancelled'.",
)
class Setting(Base): class Setting(Base):
__tablename__ = "setting" __tablename__ = "setting"

View File

@@ -0,0 +1,157 @@
"""Database operations for print jobs."""
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from spoolman.database.models import PrintJob, Spool, SpoolAdjustment
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
spool_id: int,
filename: str,
filament_used_g: float,
filament_used_mm: Optional[float] = None,
) -> PrintJob:
"""Create a new pending print job."""
# Verify spool exists
spool = await db.get(Spool, spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {spool_id} not found.")
job = PrintJob(
spool_id=spool_id,
created=datetime.utcnow(),
filename=filename,
filament_used_g=filament_used_g,
filament_used_mm=filament_used_mm,
status="pending",
)
db.add(job)
await db.commit()
await db.refresh(job)
# Load spool relationship
result = await db.execute(
select(PrintJob).where(PrintJob.id == job.id).options(joinedload(PrintJob.spool))
)
return result.scalar_one()
async def get_by_id(db: AsyncSession, job_id: int) -> PrintJob:
"""Get a print job by ID."""
result = await db.execute(
select(PrintJob).where(PrintJob.id == job_id).options(joinedload(PrintJob.spool))
)
job = result.scalar_one_or_none()
if job is None:
raise ItemNotFoundError(f"Print job with ID {job_id} not found.")
return job
async def find(
*,
db: AsyncSession,
status: Optional[str] = None,
spool_id: Optional[int] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[PrintJob], int]:
"""Find print jobs with optional filters."""
query = select(PrintJob).options(joinedload(PrintJob.spool))
if status:
query = query.where(PrintJob.status == status)
if spool_id:
query = query.where(PrintJob.spool_id == spool_id)
# Get total count
count_query = select(PrintJob)
if status:
count_query = count_query.where(PrintJob.status == status)
if spool_id:
count_query = count_query.where(PrintJob.spool_id == spool_id)
count_result = await db.execute(count_query)
total = len(count_result.all())
# Apply ordering and pagination
query = query.order_by(PrintJob.created.desc())
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all()), total
async def complete(db: AsyncSession, job_id: int, comment: Optional[str] = None) -> PrintJob:
"""Complete a print job - deduct filament from spool."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Deduct filament from spool
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.used_weight += job.filament_used_g
if spool.first_used is None:
spool.first_used = datetime.utcnow()
spool.last_used = datetime.utcnow()
# Record adjustment
adjustment = SpoolAdjustment(
spool_id=spool.id,
timestamp=datetime.utcnow(),
adjustment_type="weight",
value=-job.filament_used_g,
comment=comment or f"Print job: {job.filename}",
)
db.add(adjustment)
# Update job status
job.status = "completed"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def cancel(db: AsyncSession, job_id: int) -> PrintJob:
"""Cancel a print job - flag spool for weighing."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Flag spool for weighing
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.needs_weighing = True
# Update job status
job.status = "cancelled"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def delete(db: AsyncSession, job_id: int) -> None:
"""Delete a print job."""
job = await get_by_id(db, job_id)
await db.delete(job)
await db.commit()