From 9365e399be748a4ac131bb6b0c0c7232a97a8104 Mon Sep 17 00:00:00 2001 From: tonym Date: Thu, 15 Jan 2026 21:40:22 -0600 Subject: [PATCH] Add Print Queue UI and slicer post-processing script - Create Print Queue page showing pending/completed/cancelled jobs - Add needs_weighing filter to spool API for flagged spools - Add IPrintJob interface and needs_weighing to ISpool model - Add slicer_post_process.py for Elegoo/Orca Slicer integration - Add translations for print queue feature - Bump version to 0.23C.3 The workflow: 1. Slicer post-processing script creates pending print job 2. After print, user confirms (deducts filament) or cancels (flags for weighing) 3. Spools needing weigh-in are shown in Print Queue UI Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 44 ++- client/public/locales/en/common.json | 48 +++ client/src/App.tsx | 10 + client/src/pages/print-queue/index.tsx | 456 +++++++++++++++++++++++++ client/src/pages/spools/model.tsx | 13 + pyproject.toml | 2 +- scripts/slicer_post_process.py | 214 ++++++++++++ spoolman/api/v1/spool.py | 5 + spoolman/database/spool.py | 12 + 9 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 client/src/pages/print-queue/index.tsx create mode 100755 scripts/slicer_post_process.py diff --git a/CLAUDE.md b/CLAUDE.md index 7bb7b42..79be20a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,12 +136,12 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab | 8 | (see Gitea #8) | ✅ DONE | | 9 | Hierarchical Locations (Room/Bin) | Open | | 10 | Database Import/Export | ✅ DONE | -| 11 | Orca Slicer / Elegoo Slicer Integration | Open | -| - | Theme Colors | ✅ DONE | -| - | Clickable Dashboard Cards | ✅ DONE | -| - | QR Code Size Setting | ✅ DONE | -| - | Parse Temps from Comments | ✅ DONE | -| - | Prev/Next Navigation on Edit Pages | ✅ DONE | +| 11 | Theme Colors | ✅ DONE | +| 12 | Clickable Dashboard Cards | ✅ DONE | +| 13 | QR Code Size Setting | ✅ DONE | +| 14 | Parse Temps from Comments | ✅ DONE | +| 15 | Print Job Tracking / Slicer Integration | ✅ DONE | +| 16 | Prev/Next Navigation on Edit Pages | ✅ DONE | ### Issue #1 Complete - Inline Creation Modals @@ -314,6 +314,38 @@ Added explicit QR code size control: - `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface - `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto) +### Issue #15 Complete - Print Job Tracking / Slicer Integration + +Track pending print jobs from slicer and manage filament deduction after prints complete. + +**Workflow:** +1. Slicer post-processing script parses G-code for filament usage +2. Script creates pending print job in Spoolman via API +3. After print completes, user visits Print Queue UI +4. Complete: Deducts filament from spool, records adjustment history +5. Cancel: Flags spool for manual weigh-in (no automatic deduction) + +**Backend:** +- `spoolman/database/models.py` - `PrintJob` model, `needs_weighing` field on Spool +- `spoolman/database/print_job.py` - CRUD operations for print jobs +- `spoolman/api/v1/print_job.py` - REST API endpoints + - `POST /print-job` - Create pending job + - `POST /print-job/{id}/complete` - Complete job, deduct filament + - `POST /print-job/{id}/cancel` - Cancel job, flag spool for weighing + - `GET /print-job` - List jobs with status filter +- `migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py` + +**Frontend:** +- `client/src/pages/print-queue/index.tsx` - Print Queue UI page +- `client/src/pages/spools/model.tsx` - `IPrintJob` interface, `needs_weighing` on ISpool + +**Slicer Script:** +- `scripts/slicer_post_process.py` - Elegoo/Orca Slicer post-processing script +- Parses G-code comments for filament usage (weight in grams) +- Creates pending print job via API +- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID` +- Or config file: `~/.config/spoolman/slicer.conf` + --- ## Deployment diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 350c6a9..992a65f 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -450,5 +450,53 @@ "new_location": "New Location", "no_location": "No Location", "no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!" + }, + "print-queue": { + "print-queue": "Print Queue" + }, + "print_queue": { + "title": "Print Queue", + "pending_jobs": "Pending Jobs", + "needs_weighing": "Spools Needing Weigh-In", + "recent_history": "Recent History", + "no_pending_jobs": "No pending print jobs", + "no_history": "No completed or cancelled jobs yet", + "fields": { + "filename": "Filename", + "spool": "Spool", + "filament_used": "Filament Used", + "created": "Created", + "finished": "Finished", + "status": "Status" + }, + "status": { + "pending": "Pending", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "stats": { + "pending": "Pending", + "needs_weighing": "Need Weigh-In", + "completed_today": "Completed Today", + "total_jobs": "Total Jobs" + }, + "complete": "Complete", + "cancel": "Cancel", + "cancel_job": "Cancel Job", + "weigh_in": "Weigh In", + "clear_flag": "Clear Flag", + "clear_flag_tooltip": "Mark as weighed without updating weight", + "confirm_complete": "Complete Print Job?", + "confirm_complete_description": "This will deduct {{grams}}g from the spool.", + "confirm_cancel": "Cancel Print Job?", + "confirm_cancel_description": "The spool will be flagged for manual weigh-in since filament usage is unknown.", + "confirm_delete": "Delete Print Job?", + "confirm_delete_description": "This will permanently remove this job from history.", + "job_completed": "Print job completed", + "job_complete_error": "Failed to complete print job", + "job_cancelled": "Print job cancelled - spool flagged for weigh-in", + "job_cancel_error": "Failed to cancel print job", + "job_deleted": "Print job deleted", + "weighing_cleared": "Weigh-in flag cleared" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 2679061..c590436 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ import { FileOutlined, HighlightOutlined, HomeOutlined, + PrinterOutlined, QuestionOutlined, TableOutlined, ToolOutlined, @@ -142,6 +143,14 @@ function App() { icon: , }, }, + { + name: "print-queue", + list: "/print-queue", + meta: { + canDelete: false, + icon: , + }, + }, { name: "locations", list: "/locations", @@ -225,6 +234,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/pages/print-queue/index.tsx b/client/src/pages/print-queue/index.tsx new file mode 100644 index 0000000..13f7785 --- /dev/null +++ b/client/src/pages/print-queue/index.tsx @@ -0,0 +1,456 @@ +import { + CheckCircleOutlined, + CloseCircleOutlined, + DeleteOutlined, + ExclamationCircleOutlined, + FileOutlined, + ReloadOutlined, + ToolOutlined, +} from "@ant-design/icons"; +import { IResourceComponentsProps, useCustomMutation, useInvalidate, useList, useTranslate } from "@refinedev/core"; +import { Badge, Button, Card, Col, Empty, Modal, Row, Space, Statistic, Table, Tag, theme, Tooltip } from "antd"; +import { Content } from "antd/es/layout/layout"; +import Title from "antd/es/typography/Title"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import utc from "dayjs/plugin/utc"; +import React, { useMemo } from "react"; +import { Link } from "react-router"; +import { IPrintJob, ISpool } from "../spools/model"; + +dayjs.extend(utc); +dayjs.extend(relativeTime); + +const { useToken } = theme; +const { confirm } = Modal; + +export const PrintQueue: React.FC = () => { + const { token } = useToken(); + const t = useTranslate(); + const invalidate = useInvalidate(); + const { mutate: customMutate } = useCustomMutation(); + + // Fetch print jobs + const { result: printJobs, query: printJobsQuery } = useList({ + resource: "print-job", + pagination: { mode: "off" }, + sorters: [{ field: "created", order: "desc" }], + }); + + // Fetch spools that need weighing + const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList({ + resource: "spool", + filters: [{ field: "needs_weighing", operator: "eq", value: true }], + pagination: { mode: "off" }, + }); + + // Categorize jobs + const { pendingJobs, completedJobs, cancelledJobs } = useMemo(() => { + const jobs = printJobs?.data || []; + return { + pendingJobs: jobs.filter((j) => j.status === "pending"), + completedJobs: jobs.filter((j) => j.status === "completed").slice(0, 10), + cancelledJobs: jobs.filter((j) => j.status === "cancelled").slice(0, 10), + }; + }, [printJobs]); + + const handleComplete = (job: IPrintJob) => { + confirm({ + title: t("print_queue.confirm_complete"), + content: t("print_queue.confirm_complete_description", { grams: job.filament_used_g.toFixed(1) }), + icon: , + okText: t("buttons.confirm"), + okType: "primary", + cancelText: t("buttons.cancel"), + onOk: () => { + customMutate({ + url: `print-job/${job.id}/complete`, + method: "post", + values: {}, + successNotification: { + message: t("print_queue.job_completed"), + type: "success", + }, + errorNotification: { + message: t("print_queue.job_complete_error"), + type: "error", + }, + }, { + onSuccess: () => { + invalidate({ resource: "print-job", invalidates: ["list"] }); + invalidate({ resource: "spool", invalidates: ["list", "detail"] }); + }, + }); + }, + }); + }; + + const handleCancel = (job: IPrintJob) => { + confirm({ + title: t("print_queue.confirm_cancel"), + content: t("print_queue.confirm_cancel_description"), + icon: , + okText: t("print_queue.cancel_job"), + okType: "danger", + cancelText: t("buttons.cancel"), + onOk: () => { + customMutate({ + url: `print-job/${job.id}/cancel`, + method: "post", + values: {}, + successNotification: { + message: t("print_queue.job_cancelled"), + type: "success", + }, + errorNotification: { + message: t("print_queue.job_cancel_error"), + type: "error", + }, + }, { + onSuccess: () => { + invalidate({ resource: "print-job", invalidates: ["list"] }); + invalidate({ resource: "spool", invalidates: ["list", "detail"] }); + }, + }); + }, + }); + }; + + const handleDelete = (job: IPrintJob) => { + confirm({ + title: t("print_queue.confirm_delete"), + content: t("print_queue.confirm_delete_description"), + icon: , + okText: t("buttons.delete"), + okType: "danger", + cancelText: t("buttons.cancel"), + onOk: () => { + customMutate({ + url: `print-job/${job.id}`, + method: "delete", + values: {}, + successNotification: { + message: t("print_queue.job_deleted"), + type: "success", + }, + }, { + onSuccess: () => { + invalidate({ resource: "print-job", invalidates: ["list"] }); + }, + }); + }, + }); + }; + + const handleClearWeighingFlag = (spool: ISpool) => { + customMutate({ + url: `spool/${spool.id}`, + method: "patch", + values: { needs_weighing: false }, + successNotification: { + message: t("print_queue.weighing_cleared"), + type: "success", + }, + }, { + onSuccess: () => { + invalidate({ resource: "spool", invalidates: ["list", "detail"] }); + }, + }); + }; + + const getSpoolName = (job: IPrintJob) => { + if (job.spool?.filament) { + const fil = job.spool.filament; + if (fil.vendor?.name) { + return `${fil.vendor.name} - ${fil.name || fil.material}`; + } + return fil.name || fil.material || `Filament #${fil.id}`; + } + return `Spool #${job.spool_id}`; + }; + + const pendingColumns = [ + { + title: t("print_queue.fields.filename"), + dataIndex: "filename", + key: "filename", + render: (filename: string) => ( + + + {filename} + + ), + }, + { + title: t("print_queue.fields.spool"), + key: "spool", + render: (_: unknown, record: IPrintJob) => ( + {getSpoolName(record)} + ), + }, + { + title: t("print_queue.fields.filament_used"), + key: "filament_used", + render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`, + }, + { + title: t("print_queue.fields.created"), + dataIndex: "created", + key: "created", + render: (created: string) => ( + + {dayjs.utc(created).fromNow()} + + ), + }, + { + title: t("table.actions"), + key: "actions", + render: (_: unknown, record: IPrintJob) => ( + + + + + ), + }, + ]; + + const historyColumns = [ + { + title: t("print_queue.fields.filename"), + dataIndex: "filename", + key: "filename", + }, + { + title: t("print_queue.fields.spool"), + key: "spool", + render: (_: unknown, record: IPrintJob) => ( + {getSpoolName(record)} + ), + }, + { + title: t("print_queue.fields.filament_used"), + key: "filament_used", + render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`, + }, + { + title: t("print_queue.fields.status"), + dataIndex: "status", + key: "status", + render: (status: string) => ( + + {status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")} + + ), + }, + { + title: t("print_queue.fields.finished"), + dataIndex: "finished", + key: "finished", + render: (finished: string) => + finished ? ( + + {dayjs.utc(finished).fromNow()} + + ) : ( + "-" + ), + }, + { + title: t("table.actions"), + key: "actions", + render: (_: unknown, record: IPrintJob) => ( + + + + + + + ), + }, + ]; + + const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading; + + return ( + +
+ + {t("print_queue.title")} + + +
+ + {/* Stats cards */} + + + + + + + + + 0 ? token.colorError : undefined }} + /> + + + + + dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length} + valueStyle={{ color: token.colorSuccess }} + /> + + + + + + + + + + {/* Pending Jobs */} + + {t("print_queue.pending_jobs")} + + } + style={{ marginBottom: 24 }} + > + {pendingJobs.length > 0 ? ( + + ) : ( + + )} + + + {/* Spools Needing Weighing */} + {(spoolsNeedingWeighing?.data?.length || 0) > 0 && ( + + {t("print_queue.needs_weighing")} + + } + style={{ marginBottom: 24 }} + > +
+ + )} + + {/* Recent History */} + + {completedJobs.length > 0 || cancelledJobs.length > 0 ? ( +
dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf() + ).slice(0, 10)} + columns={historyColumns} + rowKey="id" + pagination={false} + loading={isLoading} + /> + ) : ( + + )} + + + ); +}; + +export default PrintQueue; diff --git a/client/src/pages/spools/model.tsx b/client/src/pages/spools/model.tsx index 2ecf46f..dbaba11 100644 --- a/client/src/pages/spools/model.tsx +++ b/client/src/pages/spools/model.tsx @@ -23,9 +23,22 @@ export interface ISpool { lot_nr?: string; comment?: string; archived: boolean; + needs_weighing: boolean; extra: { [key: string]: string }; } +export interface IPrintJob { + id: number; + spool_id: number; + spool?: ISpool; + created: string; + finished?: string; + filename: string; + filament_used_g: number; + filament_used_mm?: number; + status: "pending" | "completed" | "cancelled"; +} + // ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types export type ISpoolParsedExtras = Omit & { extra?: { [key: string]: unknown } }; diff --git a/pyproject.toml b/pyproject.toml index 79b91a2..d66ac7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spoolman" -version = "0.23C.2" +version = "0.23C.3" description = "A web service that keeps track of 3D printing spools." authors = [ { name = "Tony", email = "tony@nastynas.xyz" }, diff --git a/scripts/slicer_post_process.py b/scripts/slicer_post_process.py new file mode 100755 index 0000000..e604d95 --- /dev/null +++ b/scripts/slicer_post_process.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Elegoo Slicer / Orca Slicer Post-Processing Script for Spoolman Integration. + +This script parses G-code files to extract filament usage information and creates +a pending print job in Spoolman. After the print completes, use the Spoolman +Print Queue UI to confirm the print (deducts filament) or cancel it (flags for weighing). + +Usage: + 1. In Elegoo Slicer, go to Printer Settings > Post-processing Scripts + 2. Add the path to this script: /path/to/slicer_post_process.py + 3. Configure the environment variables below or create a config file + +Configuration: + Set these environment variables: + - SPOOLMAN_URL: Your Spoolman instance URL (e.g., http://192.168.0.11:8000) + - SPOOLMAN_SPOOL_ID: Default spool ID to use (or set per-filament in slicer) + + Or create a file at ~/.config/spoolman/slicer.conf: + [spoolman] + url = http://192.168.0.11:8000 + spool_id = 1 + +The script will: + 1. Read the G-code file passed as argument + 2. Parse Elegoo/Orca slicer comments for filament usage + 3. Create a pending print job in Spoolman via API + 4. The job appears in the Print Queue for confirmation after printing +""" + +import configparser +import os +import re +import sys +import urllib.request +import json +from pathlib import Path + + +def get_config() -> tuple[str, int]: + """Get Spoolman URL and default spool ID from environment or config file.""" + url = os.environ.get("SPOOLMAN_URL") + spool_id = os.environ.get("SPOOLMAN_SPOOL_ID") + + if not url: + config_path = Path.home() / ".config" / "spoolman" / "slicer.conf" + if config_path.exists(): + config = configparser.ConfigParser() + config.read(config_path) + url = config.get("spoolman", "url", fallback=None) + spool_id = config.get("spoolman", "spool_id", fallback=None) + + if not url: + print("ERROR: SPOOLMAN_URL not set. Set environment variable or create config file.", file=sys.stderr) + sys.exit(1) + + if not spool_id: + print("ERROR: SPOOLMAN_SPOOL_ID not set. Set environment variable or create config file.", file=sys.stderr) + sys.exit(1) + + return url.rstrip("/"), int(spool_id) + + +def parse_gcode(filepath: str) -> dict: + """ + Parse G-code file for filament usage information. + + Looks for Elegoo/Orca Slicer comments like: + ; total filament used [g] = 12.34 + ; filament used [mm] = 4567.89 + ; filament used [g] = 12.34 + + Also checks for PrusaSlicer format: + ; filament used [mm] = 4567.89 + ; filament used [g] = 12.34 + """ + result = { + "filename": Path(filepath).name, + "filament_used_g": None, + "filament_used_mm": None, + } + + # Patterns for different slicers + patterns = { + # Elegoo/Orca format + "total_filament_g": re.compile(r";\s*total filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE), + "filament_g": re.compile(r";\s*filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE), + "filament_mm": re.compile(r";\s*filament used \[mm\]\s*=\s*([\d.]+)", re.IGNORECASE), + # Alternative formats + "filament_weight": re.compile(r";\s*(?:total )?filament (?:weight|cost)\s*=\s*([\d.]+)\s*g", re.IGNORECASE), + "filament_length": re.compile(r";\s*(?:total )?filament\s*=\s*([\d.]+)\s*mm", re.IGNORECASE), + } + + try: + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + # Only read first 500 lines and last 500 lines for efficiency + lines = f.readlines() + header = lines[:500] + footer = lines[-500:] if len(lines) > 500 else [] + check_lines = header + footer + + for line in check_lines: + if not line.startswith(";"): + continue + + # Check each pattern + for key, pattern in patterns.items(): + match = pattern.search(line) + if match: + value = float(match.group(1)) + if "g" in key or "weight" in key: + if result["filament_used_g"] is None: + result["filament_used_g"] = value + elif "mm" in key or "length" in key: + if result["filament_used_mm"] is None: + result["filament_used_mm"] = value + + except Exception as e: + print(f"Warning: Error parsing G-code: {e}", file=sys.stderr) + + return result + + +def create_print_job(url: str, spool_id: int, gcode_info: dict) -> bool: + """Create a pending print job in Spoolman.""" + if gcode_info["filament_used_g"] is None: + print("Warning: Could not find filament weight in G-code. Skipping job creation.", file=sys.stderr) + return False + + api_url = f"{url}/api/v1/print-job" + + payload = { + "spool_id": spool_id, + "filename": gcode_info["filename"], + "filament_used_g": gcode_info["filament_used_g"], + } + + if gcode_info["filament_used_mm"] is not None: + payload["filament_used_mm"] = gcode_info["filament_used_mm"] + + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + api_url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=10) as response: + if response.status in (200, 201): + result = json.loads(response.read().decode("utf-8")) + print(f"Created print job #{result['id']}: {gcode_info['filename']} ({gcode_info['filament_used_g']:.1f}g)") + return True + else: + print(f"Error: API returned status {response.status}", file=sys.stderr) + return False + + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8") if e.fp else "" + print(f"Error: API request failed: {e.code} {e.reason} - {error_body}", file=sys.stderr) + return False + except urllib.error.URLError as e: + print(f"Error: Could not connect to Spoolman: {e.reason}", file=sys.stderr) + return False + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return False + + +def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print("Usage: slicer_post_process.py ", file=sys.stderr) + print(__doc__) + sys.exit(1) + + gcode_path = sys.argv[1] + + if not os.path.exists(gcode_path): + print(f"Error: G-code file not found: {gcode_path}", file=sys.stderr) + sys.exit(1) + + # Get configuration + url, spool_id = get_config() + + # Parse G-code + print(f"Parsing G-code: {gcode_path}") + gcode_info = parse_gcode(gcode_path) + + if gcode_info["filament_used_g"]: + print(f" Filament: {gcode_info['filament_used_g']:.2f}g", end="") + if gcode_info["filament_used_mm"]: + print(f" ({gcode_info['filament_used_mm']:.1f}mm)") + else: + print() + else: + print(" Warning: Could not parse filament usage from G-code") + + # Create print job + success = create_print_job(url, spool_id, gcode_info) + + if success: + print(f"Print job created in Spoolman ({url})") + print("After printing, use the Print Queue to confirm or cancel the job.") + else: + print("Failed to create print job. The print will proceed without tracking.", file=sys.stderr) + + # Always exit 0 so the slicer continues with the print + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 77428f6..aa91283 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -264,6 +264,10 @@ async def find( bool, Query(title="Allow Archived", description="Whether to include archived spools in the search results."), ] = False, + needs_weighing: Annotated[ + Optional[bool], + Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."), + ] = None, sort: Annotated[ Optional[str], Query( @@ -308,6 +312,7 @@ async def find( location=location, lot_nr=lot_nr, allow_archived=allow_archived, + needs_weighing=needs_weighing, sort_by=sort_by, limit=limit, offset=offset, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 677f7a4..744c9d5 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -123,6 +123,7 @@ async def find( # noqa: C901, PLR0912 location: Optional[str] = None, lot_nr: Optional[str] = None, allow_archived: bool = False, + needs_weighing: Optional[bool] = None, sort_by: Optional[dict[str, SortOrder]] = None, limit: Optional[int] = None, offset: int = 0, @@ -158,6 +159,17 @@ async def find( # noqa: C901, PLR0912 ), ) + if needs_weighing is not None: + if needs_weighing: + stmt = stmt.where(models.Spool.needs_weighing.is_(True)) + else: + stmt = stmt.where( + sqlalchemy.or_( + models.Spool.needs_weighing.is_(False), + models.Spool.needs_weighing.is_(None), + ), + ) + total_count = None if limit is not None: