Compare commits
3 Commits
2dd7c1198c
...
267fadfc54
| Author | SHA1 | Date | |
|---|---|---|---|
| 267fadfc54 | |||
| 48bd516c0f | |||
| 0a741c9712 |
@@ -33,7 +33,8 @@
|
||||
"no": "No",
|
||||
"notAccessTitle": "You don't have permission to access",
|
||||
"hideColumns": "Hide Columns",
|
||||
"clearFilters": "Clear Filters"
|
||||
"clearFilters": "Clear Filters",
|
||||
"viewAll": "View All"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
||||
"notifications": {
|
||||
@@ -51,6 +52,11 @@
|
||||
"validationError": "Validation error: {{error}}"
|
||||
},
|
||||
"kofi": "Tip me on Ko-fi",
|
||||
"color": {
|
||||
"click_to_copy": "Click to copy",
|
||||
"copied": "Copied {{color}} to clipboard",
|
||||
"copy_failed": "Failed to copy to clipboard"
|
||||
},
|
||||
"loading": "Loading",
|
||||
"version": "Version",
|
||||
"unknown": "Unknown",
|
||||
@@ -259,7 +265,9 @@
|
||||
"longitudinal": "Longitudinal",
|
||||
"external_id": "External ID",
|
||||
"spools": "Show Spools",
|
||||
"not_set": "Not Set"
|
||||
"not_set": "Not Set",
|
||||
"spool_count": "Spools",
|
||||
"total_remaining_weight": "Total Remaining"
|
||||
},
|
||||
"fields_help": {
|
||||
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
|
||||
@@ -324,7 +332,10 @@
|
||||
"home": {
|
||||
"home": "Home",
|
||||
"welcome": "Welcome to your Spoolman instance!",
|
||||
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
|
||||
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
|
||||
"all_spools": "All Spools",
|
||||
"low_stock_alert": "{{count}} spools below {{threshold}}g",
|
||||
"low_stock_warning": "Low stock"
|
||||
},
|
||||
"help": {
|
||||
"help": "Help",
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { message, Tooltip } from "antd";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import "./spoolIcon.css";
|
||||
|
||||
interface Props {
|
||||
color: string | { colors: string[]; vertical: boolean };
|
||||
size?: "small" | "large";
|
||||
no_margin? : boolean
|
||||
no_margin?: boolean;
|
||||
showHex?: boolean;
|
||||
}
|
||||
|
||||
export default function SpoolIcon(props: Readonly<Props>) {
|
||||
const t = useTranslate();
|
||||
let dirClass = "vertical";
|
||||
let cols = [];
|
||||
let size = props.size ? props.size : "small";
|
||||
let no_margin = props.no_margin ? "no-margin" : "";
|
||||
let cols: string[] = [];
|
||||
const size = props.size ? props.size : "small";
|
||||
const no_margin = props.no_margin ? "no-margin" : "";
|
||||
|
||||
if (typeof props.color === "string") {
|
||||
cols = [props.color];
|
||||
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
|
||||
cols = props.color.colors;
|
||||
}
|
||||
|
||||
return (
|
||||
// Normalize hex codes (remove # if present, uppercase)
|
||||
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
|
||||
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(hexDisplay);
|
||||
message.success(t("color.copied", { color: hexDisplay }));
|
||||
} catch {
|
||||
message.error(t("color.copy_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const swatchContent = (
|
||||
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
|
||||
{cols.map((col) => (
|
||||
{normalizedCols.map((col) => (
|
||||
<div
|
||||
key={col}
|
||||
style={{
|
||||
backgroundColor: "#" + col.replace("#", ""),
|
||||
backgroundColor: "#" + col,
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (props.showHex) {
|
||||
return (
|
||||
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
|
||||
<div
|
||||
className="spool-icon-with-hex"
|
||||
onClick={copyToClipboard}
|
||||
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
{swatchContent}
|
||||
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
|
||||
{hexDisplay}
|
||||
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return swatchContent;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
|
||||
"article_number",
|
||||
"settings_extruder_temp",
|
||||
"settings_bed_temp",
|
||||
"spool_count",
|
||||
"total_remaining_weight",
|
||||
"registered",
|
||||
"comment",
|
||||
];
|
||||
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
maxDecimals: 0,
|
||||
width: 100,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
id: "spool_count",
|
||||
i18ncat: "filament",
|
||||
unit: "",
|
||||
maxDecimals: 0,
|
||||
width: 80,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
id: "total_remaining_weight",
|
||||
i18ncat: "filament",
|
||||
unit: "g",
|
||||
maxDecimals: 0,
|
||||
width: 120,
|
||||
}),
|
||||
DateColumn({
|
||||
...commonProps,
|
||||
id: "registered",
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface IFilament {
|
||||
multi_color_direction?: string;
|
||||
external_id?: string;
|
||||
extra: { [key: string]: string };
|
||||
spool_count?: number;
|
||||
total_remaining_weight?: number;
|
||||
}
|
||||
|
||||
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
|
||||
|
||||
@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
|
||||
<Title level={5}>{t("filament.fields.name")}</Title>
|
||||
<TextField value={record?.name} />
|
||||
<Title level={5}>{t("filament.fields.color_hex")}</Title>
|
||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
||||
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
|
||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||
<Title level={5}>{t("filament.fields.material")}</Title>
|
||||
<TextField value={record?.material} />
|
||||
<Title level={5}>{t("filament.fields.price")}</Title>
|
||||
|
||||
@@ -1,129 +1,247 @@
|
||||
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
ExclamationCircleOutlined,
|
||||
HighlightOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||
import { Card, Col, Row, Statistic, theme } from "antd";
|
||||
import { Badge, Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd";
|
||||
import { Content } from "antd/es/layout/layout";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { ReactNode } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import Logo from "../../icon.svg?react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import SpoolIcon from "../../components/spoolIcon";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { ISpool } from "../spools/model";
|
||||
import { IVendor } from "../vendors/model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
const LOW_STOCK_THRESHOLD = 100; // grams
|
||||
|
||||
export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
const { token } = useToken();
|
||||
const t = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
|
||||
// Fetch all spools (not archived) for the main list
|
||||
const spools = useList<ISpool>({
|
||||
resource: "spool",
|
||||
pagination: { pageSize: 1 },
|
||||
pagination: { current: currentPage, pageSize },
|
||||
sorters: [{ field: "last_used", order: "desc" }],
|
||||
meta: {
|
||||
queryParams: {
|
||||
allow_archived: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const filaments = useList<ISpool>({
|
||||
|
||||
// Fetch counts for stats
|
||||
const filaments = useList<IFilament>({
|
||||
resource: "filament",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
const vendors = useList<ISpool>({
|
||||
const vendors = useList<IVendor>({
|
||||
resource: "vendor",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
|
||||
const hasSpools = !spools.result || spools.result.data.length > 0;
|
||||
// Calculate low stock spools
|
||||
const lowStockCount = useMemo(() => {
|
||||
if (!spools.data?.data) return 0;
|
||||
return spools.data.data.filter(
|
||||
(spool) => spool.remaining_weight !== undefined && spool.remaining_weight < LOW_STOCK_THRESHOLD
|
||||
).length;
|
||||
}, [spools.data?.data]);
|
||||
|
||||
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
|
||||
<Col xs={12} md={6}>
|
||||
<Card
|
||||
loading={props.loading}
|
||||
hoverable
|
||||
style={{ cursor: "pointer" }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
actions={[
|
||||
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}>
|
||||
<UnorderedListOutlined />
|
||||
</Link>,
|
||||
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}>
|
||||
<PlusOutlined />
|
||||
</Link>,
|
||||
]}
|
||||
>
|
||||
<Link to={`/${props.resource}`} style={{ display: "block", padding: "24px", color: "inherit" }}>
|
||||
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
|
||||
</Link>
|
||||
</Card>
|
||||
</Col>
|
||||
// For accurate low stock count, we need all spools
|
||||
const allSpoolsForLowStock = useList<ISpool>({
|
||||
resource: "spool",
|
||||
pagination: { pageSize: 1000 },
|
||||
filters: [
|
||||
{
|
||||
field: "remaining_weight",
|
||||
operator: "lt",
|
||||
value: LOW_STOCK_THRESHOLD,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
queryParams: {
|
||||
allow_archived: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalLowStock = allSpoolsForLowStock.data?.total ?? lowStockCount;
|
||||
|
||||
const spoolColumns = [
|
||||
{
|
||||
title: "",
|
||||
dataIndex: "color",
|
||||
key: "color",
|
||||
width: 50,
|
||||
render: (_: unknown, record: ISpool) => {
|
||||
const colorObj = record.filament.multi_color_hexes
|
||||
? {
|
||||
colors: record.filament.multi_color_hexes.split(","),
|
||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: record.filament.color_hex;
|
||||
return colorObj ? <SpoolIcon color={colorObj} /> : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.filament_name"),
|
||||
dataIndex: "filament",
|
||||
key: "filament",
|
||||
render: (_: unknown, record: ISpool) => {
|
||||
const vendorName = record.filament.vendor?.name;
|
||||
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
|
||||
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.material"),
|
||||
dataIndex: ["filament", "material"],
|
||||
key: "material",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.remaining_weight"),
|
||||
dataIndex: "remaining_weight",
|
||||
key: "remaining_weight",
|
||||
width: 120,
|
||||
align: "right" as const,
|
||||
render: (weight: number | undefined, record: ISpool) => {
|
||||
if (weight === undefined) return t("unknown");
|
||||
const isLowStock = weight < LOW_STOCK_THRESHOLD;
|
||||
return (
|
||||
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
|
||||
{Math.round(weight)} g
|
||||
{isLowStock && (
|
||||
<Tooltip title={t("home.low_stock_warning")}>
|
||||
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Content
|
||||
style={{
|
||||
padding: "2em 20px",
|
||||
padding: "1.5em 20px",
|
||||
minHeight: 280,
|
||||
maxWidth: 800,
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
color: token.colorText,
|
||||
fontFamily: token.fontFamily,
|
||||
fontSize: token.fontSizeLG,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: token.fontSizeHeading1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
height: "1.5em",
|
||||
marginRight: "0.5em",
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
</div>
|
||||
Spoolman
|
||||
</Title>
|
||||
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
|
||||
<ResourceStatsCard
|
||||
resource="spool"
|
||||
value={spools.result?.total || 0}
|
||||
loading={spools.query.isLoading}
|
||||
icon={<FileOutlined />}
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
resource="filament"
|
||||
value={filaments.result?.total || 0}
|
||||
loading={filaments.query.isLoading}
|
||||
icon={<HighlightOutlined />}
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
resource="vendor"
|
||||
value={vendors.result?.total || 0}
|
||||
loading={vendors.query.isLoading}
|
||||
icon={<UserOutlined />}
|
||||
{/* Header with spool count and add button */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Statistic
|
||||
title={t("spool.spool")}
|
||||
value={spools.data?.total ?? 0}
|
||||
loading={spools.isLoading}
|
||||
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||
{t("spool.titles.create")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Alert badges */}
|
||||
{totalLowStock > 0 && (
|
||||
<Row style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Link to="/spool?remaining_weight_lt=100">
|
||||
<Badge
|
||||
count={totalLowStock}
|
||||
style={{ backgroundColor: token.colorError }}
|
||||
overflowCount={99}
|
||||
>
|
||||
<Card size="small" hoverable style={{ cursor: "pointer" }}>
|
||||
<ExclamationCircleOutlined style={{ color: token.colorError, marginRight: 8 }} />
|
||||
{t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })}
|
||||
</Card>
|
||||
</Badge>
|
||||
</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
{!hasSpools && (
|
||||
<>
|
||||
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="home.description"
|
||||
components={{
|
||||
helpPageLink: <Link to="/help" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spool list */}
|
||||
<Card
|
||||
title={t("home.all_spools")}
|
||||
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<Table
|
||||
dataSource={spools.data?.data}
|
||||
columns={spoolColumns}
|
||||
rowKey="id"
|
||||
loading={spools.isLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
onRow={(record) => ({
|
||||
onClick: () => navigate(`/spool/show/${record.id}`),
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
/>
|
||||
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={spools.data?.total ?? 0}
|
||||
onChange={setCurrentPage}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Secondary stats */}
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12}>
|
||||
<Link to="/filament">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("filament.filament")}
|
||||
value={filaments.data?.total ?? 0}
|
||||
loading={filaments.isLoading}
|
||||
prefix={<HighlightOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Link to="/vendor">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("vendor.vendor")}
|
||||
value={vendors.data?.total ?? 0}
|
||||
loading={vendors.isLoading}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -163,7 +163,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
<Title level={5}>{t("spool.fields.id")}</Title>
|
||||
<NumberField value={record?.id ?? ""} />
|
||||
<Title level={5}>{t("spool.fields.filament")}</Title>
|
||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||
<TextField value={record ? filamentURL(record?.filament) : ""} />
|
||||
<Title level={5}>{t("spool.fields.price")}</Title>
|
||||
<TextField value={spoolPrice(record)} />
|
||||
|
||||
@@ -366,7 +366,7 @@ async def find(
|
||||
else:
|
||||
filter_by_ids = None
|
||||
|
||||
db_items, total_count = await filament.find(
|
||||
db_items, total_count, spool_stats = await filament.find(
|
||||
db=db,
|
||||
ids=filter_by_ids,
|
||||
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
||||
@@ -383,7 +383,14 @@ async def find(
|
||||
# Set x-total-count header for pagination
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(
|
||||
(Filament.from_db(db_item) for db_item in db_items),
|
||||
(
|
||||
Filament.from_db(
|
||||
db_item,
|
||||
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
|
||||
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
|
||||
)
|
||||
for db_item in db_items
|
||||
),
|
||||
exclude_none=True,
|
||||
),
|
||||
headers={"x-total-count": str(total_count)},
|
||||
|
||||
@@ -221,9 +221,25 @@ class Filament(BaseModel):
|
||||
"Query the /fields endpoint for more details about the fields."
|
||||
),
|
||||
)
|
||||
spool_count: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Number of spools of this filament (excluding archived). Computed field.",
|
||||
examples=[5],
|
||||
)
|
||||
total_remaining_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
|
||||
examples=[2500.0],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_db(item: models.Filament) -> "Filament":
|
||||
def from_db(
|
||||
item: models.Filament,
|
||||
spool_count: Optional[int] = None,
|
||||
total_remaining_weight: Optional[float] = None,
|
||||
) -> "Filament":
|
||||
"""Create a new Pydantic filament object from a database filament object."""
|
||||
return Filament(
|
||||
id=item.id,
|
||||
@@ -251,6 +267,8 @@ class Filament(BaseModel):
|
||||
),
|
||||
external_id=item.external_id,
|
||||
extra={field.key: field.value for field in item.extra},
|
||||
spool_count=spool_count,
|
||||
total_remaining_weight=total_remaining_weight,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -114,13 +114,16 @@ async def find(
|
||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[models.Filament], int]:
|
||||
) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
|
||||
"""Find a list of filament objects by search criteria.
|
||||
|
||||
Sort by a field by passing a dict with the field name as key and the sort order as value.
|
||||
The field name can contain nested fields, e.g. vendor.name.
|
||||
|
||||
Returns a tuple containing the list of items and the total count of matching items.
|
||||
Returns a tuple containing:
|
||||
- list of filaments
|
||||
- total count of matching items
|
||||
- dict mapping filament_id to (spool_count, total_remaining_weight)
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Filament)
|
||||
@@ -160,7 +163,25 @@ async def find(
|
||||
if total_count is None:
|
||||
total_count = len(result)
|
||||
|
||||
return result, total_count
|
||||
# Fetch spool statistics for all filaments in the result
|
||||
spool_stats: dict[int, tuple[int, float]] = {}
|
||||
if result:
|
||||
filament_ids = [f.id for f in result]
|
||||
stats_stmt = (
|
||||
select(
|
||||
models.Spool.filament_id,
|
||||
func.count(models.Spool.id).label("spool_count"),
|
||||
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
|
||||
)
|
||||
.where(models.Spool.filament_id.in_(filament_ids))
|
||||
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
|
||||
.group_by(models.Spool.filament_id)
|
||||
)
|
||||
stats_rows = await db.execute(stats_stmt)
|
||||
for row in stats_rows.all():
|
||||
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
|
||||
|
||||
return result, total_count, spool_stats
|
||||
|
||||
|
||||
async def update(
|
||||
|
||||
Reference in New Issue
Block a user