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:
@@ -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")
|
||||
@@ -331,6 +331,10 @@ class Spool(BaseModel):
|
||||
examples=[""],
|
||||
)
|
||||
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(
|
||||
description=(
|
||||
"Extra fields for this spool. All values are JSON-encoded data. "
|
||||
@@ -384,6 +388,7 @@ class Spool(BaseModel):
|
||||
lot_nr=item.lot_nr,
|
||||
comment=item.comment,
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
199
spoolman/api/v1/print_job.py
Normal file
199
spoolman/api/v1/print_job.py
Normal 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!")
|
||||
@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
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__)
|
||||
|
||||
@@ -113,3 +113,4 @@ app.include_router(other.router)
|
||||
app.include_router(externaldb.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(backup_module.router)
|
||||
app.include_router(print_job.router)
|
||||
|
||||
@@ -84,6 +84,7 @@ class SpoolParameters(BaseModel):
|
||||
examples=[""],
|
||||
)
|
||||
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(
|
||||
None,
|
||||
description="Extra fields for this spool.",
|
||||
|
||||
@@ -80,6 +80,10 @@ class Spool(Base):
|
||||
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||
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(
|
||||
back_populates="spool",
|
||||
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.")
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "setting"
|
||||
|
||||
|
||||
157
spoolman/database/print_job.py
Normal file
157
spoolman/database/print_job.py
Normal 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()
|
||||
Reference in New Issue
Block a user