From 4cd58ebae9d983265980301b09ad50992c4208e1 Mon Sep 17 00:00:00 2001 From: tonym Date: Thu, 15 Jan 2026 21:10:56 -0600 Subject: [PATCH] Add Print Job tracking for slicer integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ..._01_15_0400-c3d4e5f6g7h8_add_print_jobs.py | 53 +++++ spoolman/api/v1/models.py | 5 + spoolman/api/v1/print_job.py | 199 ++++++++++++++++++ spoolman/api/v1/router.py | 3 +- spoolman/api/v1/spool.py | 1 + spoolman/database/models.py | 24 +++ spoolman/database/print_job.py | 157 ++++++++++++++ 7 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py create mode 100644 spoolman/api/v1/print_job.py create mode 100644 spoolman/database/print_job.py diff --git a/migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py b/migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py new file mode 100644 index 0000000..35371bc --- /dev/null +++ b/migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py @@ -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") diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 9675c10..23f8e1b 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -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}, ) diff --git a/spoolman/api/v1/print_job.py b/spoolman/api/v1/print_job.py new file mode 100644 index 0000000..74c12db --- /dev/null +++ b/spoolman/api/v1/print_job.py @@ -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!") diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index 8454eaa..e05a0ae 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -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) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index a451c68..77428f6 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -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.", diff --git a/spoolman/database/models.py b/spoolman/database/models.py index 5a49090..c450072 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -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" diff --git a/spoolman/database/print_job.py b/spoolman/database/print_job.py new file mode 100644 index 0000000..686a8c4 --- /dev/null +++ b/spoolman/database/print_job.py @@ -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()