Compare commits
2 Commits
695f1adeed
...
9365e399be
| Author | SHA1 | Date | |
|---|---|---|---|
| 9365e399be | |||
| 4cd58ebae9 |
44
CLAUDE.md
44
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 |
|
| 8 | (see Gitea #8) | ✅ DONE |
|
||||||
| 9 | Hierarchical Locations (Room/Bin) | Open |
|
| 9 | Hierarchical Locations (Room/Bin) | Open |
|
||||||
| 10 | Database Import/Export | ✅ DONE |
|
| 10 | Database Import/Export | ✅ DONE |
|
||||||
| 11 | Orca Slicer / Elegoo Slicer Integration | Open |
|
| 11 | Theme Colors | ✅ DONE |
|
||||||
| - | Theme Colors | ✅ DONE |
|
| 12 | Clickable Dashboard Cards | ✅ DONE |
|
||||||
| - | Clickable Dashboard Cards | ✅ DONE |
|
| 13 | QR Code Size Setting | ✅ DONE |
|
||||||
| - | QR Code Size Setting | ✅ DONE |
|
| 14 | Parse Temps from Comments | ✅ DONE |
|
||||||
| - | Parse Temps from Comments | ✅ DONE |
|
| 15 | Print Job Tracking / Slicer Integration | ✅ DONE |
|
||||||
| - | Prev/Next Navigation on Edit Pages | ✅ DONE |
|
| 16 | Prev/Next Navigation on Edit Pages | ✅ DONE |
|
||||||
|
|
||||||
### Issue #1 Complete - Inline Creation Modals
|
### 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/printing.tsx` - Added `qrCodeSize` to interface
|
||||||
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto)
|
- `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
|
## Deployment
|
||||||
|
|||||||
@@ -450,5 +450,53 @@
|
|||||||
"new_location": "New Location",
|
"new_location": "New Location",
|
||||||
"no_location": "No Location",
|
"no_location": "No Location",
|
||||||
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FileOutlined,
|
FileOutlined,
|
||||||
HighlightOutlined,
|
HighlightOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
|
PrinterOutlined,
|
||||||
QuestionOutlined,
|
QuestionOutlined,
|
||||||
TableOutlined,
|
TableOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
@@ -142,6 +143,14 @@ function App() {
|
|||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "print-queue",
|
||||||
|
list: "/print-queue",
|
||||||
|
meta: {
|
||||||
|
canDelete: false,
|
||||||
|
icon: <PrinterOutlined />,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "locations",
|
name: "locations",
|
||||||
list: "/locations",
|
list: "/locations",
|
||||||
@@ -225,6 +234,7 @@ function App() {
|
|||||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||||
|
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
|
||||||
<Route path="*" element={<ErrorComponent />} />
|
<Route path="*" element={<ErrorComponent />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
456
client/src/pages/print-queue/index.tsx
Normal file
456
client/src/pages/print-queue/index.tsx
Normal file
@@ -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<IResourceComponentsProps> = () => {
|
||||||
|
const { token } = useToken();
|
||||||
|
const t = useTranslate();
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
const { mutate: customMutate } = useCustomMutation();
|
||||||
|
|
||||||
|
// Fetch print jobs
|
||||||
|
const { result: printJobs, query: printJobsQuery } = useList<IPrintJob>({
|
||||||
|
resource: "print-job",
|
||||||
|
pagination: { mode: "off" },
|
||||||
|
sorters: [{ field: "created", order: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch spools that need weighing
|
||||||
|
const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList<ISpool>({
|
||||||
|
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: <CheckCircleOutlined style={{ color: token.colorSuccess }} />,
|
||||||
|
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: <ExclamationCircleOutlined style={{ color: token.colorWarning }} />,
|
||||||
|
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: <DeleteOutlined style={{ color: token.colorError }} />,
|
||||||
|
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) => (
|
||||||
|
<Space>
|
||||||
|
<FileOutlined />
|
||||||
|
{filename}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.spool"),
|
||||||
|
key: "spool",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Tooltip title={dayjs.utc(created).local().format("YYYY-MM-DD HH:mm:ss")}>
|
||||||
|
{dayjs.utc(created).fromNow()}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("table.actions"),
|
||||||
|
key: "actions",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={() => handleComplete(record)}
|
||||||
|
>
|
||||||
|
{t("print_queue.complete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
onClick={() => handleCancel(record)}
|
||||||
|
>
|
||||||
|
{t("print_queue.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const historyColumns = [
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.filename"),
|
||||||
|
dataIndex: "filename",
|
||||||
|
key: "filename",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.spool"),
|
||||||
|
key: "spool",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Tag color={status === "completed" ? "success" : "error"}>
|
||||||
|
{status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.finished"),
|
||||||
|
dataIndex: "finished",
|
||||||
|
key: "finished",
|
||||||
|
render: (finished: string) =>
|
||||||
|
finished ? (
|
||||||
|
<Tooltip title={dayjs.utc(finished).local().format("YYYY-MM-DD HH:mm:ss")}>
|
||||||
|
{dayjs.utc(finished).fromNow()}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("table.actions"),
|
||||||
|
key: "actions",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const weighingColumns = [
|
||||||
|
{
|
||||||
|
title: t("spool.fields.id"),
|
||||||
|
dataIndex: "id",
|
||||||
|
key: "id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.filament_name"),
|
||||||
|
key: "filament",
|
||||||
|
render: (_: unknown, record: ISpool) => {
|
||||||
|
const fil = record.filament;
|
||||||
|
if (fil.vendor?.name) {
|
||||||
|
return `${fil.vendor.name} - ${fil.name || fil.material}`;
|
||||||
|
}
|
||||||
|
return fil.name || fil.material || `Filament #${fil.id}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.location"),
|
||||||
|
dataIndex: "location",
|
||||||
|
key: "location",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("table.actions"),
|
||||||
|
key: "actions",
|
||||||
|
render: (_: unknown, record: ISpool) => (
|
||||||
|
<Space>
|
||||||
|
<Link to={`/spool/edit/${record.id}`}>
|
||||||
|
<Button type="primary" icon={<ToolOutlined />}>
|
||||||
|
{t("print_queue.weigh_in")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Tooltip title={t("print_queue.clear_flag_tooltip")}>
|
||||||
|
<Button onClick={() => handleClearWeighingFlag(record)}>
|
||||||
|
{t("print_queue.clear_flag")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
padding: "2em 20px",
|
||||||
|
minHeight: 280,
|
||||||
|
maxWidth: 1200,
|
||||||
|
margin: "0 auto",
|
||||||
|
backgroundColor: token.colorBgContainer,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
{t("print_queue.title")}
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.pending")}
|
||||||
|
value={pendingJobs.length}
|
||||||
|
valueStyle={{ color: token.colorWarning }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.needs_weighing")}
|
||||||
|
value={spoolsNeedingWeighing?.data?.length || 0}
|
||||||
|
valueStyle={{ color: (spoolsNeedingWeighing?.data?.length || 0) > 0 ? token.colorError : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.completed_today")}
|
||||||
|
value={completedJobs.filter((j) => dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
|
||||||
|
valueStyle={{ color: token.colorSuccess }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.total_jobs")}
|
||||||
|
value={printJobs?.data?.length || 0}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Pending Jobs */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Badge count={pendingJobs.length} offset={[15, 0]} color={token.colorWarning}>
|
||||||
|
<span>{t("print_queue.pending_jobs")}</span>
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
{pendingJobs.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={pendingJobs}
|
||||||
|
columns={pendingColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description={t("print_queue.no_pending_jobs")} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Spools Needing Weighing */}
|
||||||
|
{(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Badge count={spoolsNeedingWeighing?.data?.length || 0} offset={[15, 0]} color={token.colorError}>
|
||||||
|
<span>{t("print_queue.needs_weighing")}</span>
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={spoolsNeedingWeighing?.data || []}
|
||||||
|
columns={weighingColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent History */}
|
||||||
|
<Card title={t("print_queue.recent_history")}>
|
||||||
|
{completedJobs.length > 0 || cancelledJobs.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={[...completedJobs, ...cancelledJobs].sort(
|
||||||
|
(a, b) => dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
|
||||||
|
).slice(0, 10)}
|
||||||
|
columns={historyColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description={t("print_queue.no_history")} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrintQueue;
|
||||||
@@ -23,9 +23,22 @@ export interface ISpool {
|
|||||||
lot_nr?: string;
|
lot_nr?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
needs_weighing: boolean;
|
||||||
extra: { [key: string]: string };
|
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
|
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
|
||||||
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "spoolman"
|
name = "spoolman"
|
||||||
version = "0.23C.2"
|
version = "0.23C.3"
|
||||||
description = "A web service that keeps track of 3D printing spools."
|
description = "A web service that keeps track of 3D printing spools."
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Tony", email = "tony@nastynas.xyz" },
|
{ name = "Tony", email = "tony@nastynas.xyz" },
|
||||||
|
|||||||
214
scripts/slicer_post_process.py
Executable file
214
scripts/slicer_post_process.py
Executable file
@@ -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 <gcode_file>", 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()
|
||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
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.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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
@@ -263,6 +264,10 @@ async def find(
|
|||||||
bool,
|
bool,
|
||||||
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
|
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
|
||||||
] = False,
|
] = False,
|
||||||
|
needs_weighing: Annotated[
|
||||||
|
Optional[bool],
|
||||||
|
Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."),
|
||||||
|
] = None,
|
||||||
sort: Annotated[
|
sort: Annotated[
|
||||||
Optional[str],
|
Optional[str],
|
||||||
Query(
|
Query(
|
||||||
@@ -307,6 +312,7 @@ async def find(
|
|||||||
location=location,
|
location=location,
|
||||||
lot_nr=lot_nr,
|
lot_nr=lot_nr,
|
||||||
allow_archived=allow_archived,
|
allow_archived=allow_archived,
|
||||||
|
needs_weighing=needs_weighing,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
@@ -123,6 +123,7 @@ async def find( # noqa: C901, PLR0912
|
|||||||
location: Optional[str] = None,
|
location: Optional[str] = None,
|
||||||
lot_nr: Optional[str] = None,
|
lot_nr: Optional[str] = None,
|
||||||
allow_archived: bool = False,
|
allow_archived: bool = False,
|
||||||
|
needs_weighing: Optional[bool] = None,
|
||||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: int = 0,
|
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
|
total_count = None
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user