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:
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user