Merge pull request #339 from spyder007/feature/spool_revisions

Add additional handling for spool weights
This commit is contained in:
Donkie
2024-04-24 20:38:24 +02:00
committed by GitHub
25 changed files with 1178 additions and 74 deletions

View File

@@ -144,6 +144,8 @@
"measured_weight": "Measured Weight",
"used_length": "Used Length",
"remaining_length": "Remaining Length",
"initial_weight": "Initial Weight",
"spool_weight": "Empty Weight",
"location": "Location",
"lot_nr": "Lot Nr",
"first_used": "First Used",
@@ -158,6 +160,8 @@
"used_weight": "How much filament has been used from the spool. A new spool should have 0g used.",
"remaining_weight": "How much filament is left on the spool. For a new spool this should match the spool weight.",
"measured_weight": "How much the filament and spool weigh.",
"initial_weight": "The initial Weight of the filament (net weight). This value may come from the filament, but is tracked at the spool level to allow for individual variations the weight of filament on the spool.",
"spool_weight": "The weight of the spool when it is empty. This value may come from either the filament or the vendor, but is tracked at the spool level to allow for individual variations of the weight of an empty spool.",
"location": "Where the spool is located if you have multiple locations where you store your spools.",
"lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used."
},
@@ -227,9 +231,13 @@
"fields": {
"id": "ID",
"name": "Name",
"empty_spool_weight": "Empty Spool Weight",
"registered": "Registered",
"comment": "Comment"
},
"fields_help": {
"empty_spool_weight": "The weight of an empty spool from this vendor."
},
"titles": {
"create": "Create Vendor",
"clone": "Clone Vendor",

View File

@@ -5,7 +5,7 @@ import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Button, T
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model";
import { ISpool, ISpoolParsedExtras } from "./model";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
@@ -14,6 +14,7 @@ import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import utc from "dayjs/plugin/utc";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { ValueType } from "rc-input-number";
dayjs.extend(utc);
@@ -39,6 +40,9 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
formProps.initialValues = {};
}
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
formProps.initialValues.first_used = null;
@@ -116,25 +120,26 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const selectedFilament = filamentOptions?.find((obj) => {
return obj.value === selectedFilamentID;
});
const filamentWeight = selectedFilament?.weight || 0;
const spoolWeight = selectedFilament?.spool_weight || 0;
const filamentChange = (newID: number) => {
const newSelectedFilament = filamentOptions?.find((obj) => {
return obj.value === newID;
});
const initial_weight = initialWeightValue ?? 0;
const spool_weight = spoolWeightValue ?? 0;
const newFilamentWeight = newSelectedFilament?.weight || 0;
const newSpoolWeight = newSelectedFilament?.spool_weight || 0;
if (weightToEnter >= 3) {
if (!(newFilamentWeight && newSpoolWeight)) {
setWeightToEnter(2);
}
const currentCalculatedFilamentWeight = getTotalWeightFromFilament();
if ((initial_weight === 0 || initial_weight === currentCalculatedFilamentWeight) && newFilamentWeight > 0) {
form.setFieldValue("initial_weight", newFilamentWeight);
}
if (weightToEnter >= 2) {
if (!newFilamentWeight) {
setWeightToEnter(1);
}
if ((spool_weight === 0 || spool_weight === (selectedFilament?.spool_weight ?? 0)) && newSpoolWeight > 0) {
form.setFieldValue("spool_weight", newSpoolWeight);
}
};
@@ -162,6 +167,75 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
setQuantity(quantity - 1);
};
const getSpoolWeight = (): number => {
return spoolWeightValue ?? (selectedFilament?.spool_weight ?? 0);
}
const getFilamentWeight = (): number => {
return initialWeightValue ?? (selectedFilament?.weight ?? 0)
}
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
};
const getTotalWeightFromFilament = (): number => {
return (selectedFilament?.weight ?? 0) + (selectedFilament?.spool_weight ?? 0);
}
const getMeasuredWeight = (): number => {
const grossWeight = getGrossWeight();
return grossWeight - usedWeight;
}
const getRemainingWeight = (): number => {
const initial_weight = getFilamentWeight();
return initial_weight - usedWeight;
}
const isMeasuredWeightEnabled = (): boolean => {
if (!isRemainingWeightEnabled()) {
return false;
}
const spool_weight = spoolWeightValue;
return (spool_weight || selectedFilament?.spool_weight) ? true : false;
}
const isRemainingWeightEnabled = (): boolean => {
const initial_weight = initialWeightValue;
if (initial_weight) {
return true;
}
return selectedFilament?.weight ? true : false;
}
React.useEffect(() => {
if (weightToEnter >= WeightToEnter.measured_weight)
{
if (!isMeasuredWeightEnabled()) {
setWeightToEnter(WeightToEnter.remaining_weight);
return;
}
}
if (weightToEnter >= WeightToEnter.remaining_weight)
{
if (!isRemainingWeightEnabled()) {
setWeightToEnter(WeightToEnter.used_weight);
return;
}
}
}, [selectedFilament])
return (
<Create
title={props.mode === "create" ? t("spool.titles.create") : t("spool.titles.clone")}
@@ -238,8 +312,8 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
/>
</Form.Item>
<Form.Item
label={t("filament.fields.price")}
help={t("filament.fields_help.price")}
label={t("spool.fields.price")}
help={t("spool.fields_help.price")}
name={["price"]}
rules={[
{
@@ -256,6 +330,36 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
parser={numberParser}
/>
</Form.Item>
<Form.Item
label={t("spool.fields.initial_weight")}
help={t("spool.fields_help.initial_weight")}
name={["initial_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.spool_weight")}
help={t("spool.fields_help.spool_weight")}
name={["spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>
@@ -265,14 +369,14 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
onChange={(value) => {
setWeightToEnter(value.target.value);
}}
defaultValue={1}
defaultValue={WeightToEnter.used_weight}
value={weightToEnter}
>
<Radio.Button value={1}>{t("spool.fields.used_weight")}</Radio.Button>
<Radio.Button value={2} disabled={!filamentWeight}>
<Radio.Button value={WeightToEnter.used_weight}>{t("spool.fields.used_weight")}</Radio.Button>
<Radio.Button value={WeightToEnter.remaining_weight} disabled={!isRemainingWeightEnabled()}>
{t("spool.fields.remaining_weight")}
</Radio.Button>
<Radio.Button value={3} disabled={!(filamentWeight && spoolWeight)}>
<Radio.Button value={WeightToEnter.measured_weight} disabled={!isMeasuredWeightEnabled()}>
{t("spool.fields.measured_weight")}
</Radio.Button>
</Radio.Group>
@@ -285,7 +389,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
precision={1}
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != 1}
disabled={weightToEnter != WeightToEnter.used_weight}
value={usedWeight}
onChange={(value) => {
weightChange(value ?? 0);
@@ -303,10 +407,10 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
precision={1}
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != 2}
value={filamentWeight ? filamentWeight - usedWeight : 0}
disabled={weightToEnter != WeightToEnter.remaining_weight}
value={getRemainingWeight()}
onChange={(value) => {
weightChange(filamentWeight - (value ?? 0));
weightChange(getFilamentWeight() - (value ?? 0));
}}
/>
</Form.Item>
@@ -321,10 +425,11 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
precision={1}
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != 3}
value={filamentWeight && spoolWeight ? filamentWeight - usedWeight + spoolWeight : 0}
disabled={weightToEnter != WeightToEnter.measured_weight}
value={getMeasuredWeight()}
onChange={(value) => {
weightChange(filamentWeight - ((value ?? 0) - spoolWeight));
const totalWeight = getGrossWeight();
weightChange(totalWeight - (value ?? 0));
}}
/>
</Form.Item>

View File

@@ -5,7 +5,7 @@ import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert, Ty
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model";
import { ISpool, ISpoolParsedExtras } from "./model";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels";
import { message } from "antd/lib";
@@ -21,12 +21,6 @@ We also need to stringify them again before sending them back to the API, which
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
*/
enum WeightToEnter {
used_weight = 1,
remaining_weight = 2,
measured_weight = 3,
}
export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage();
@@ -43,6 +37,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
},
});
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
// Get filament selection options
const { queryResult } = useSelect<IFilament>({
resource: "filament",
@@ -100,31 +96,34 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const selectedFilament = filamentOptions?.find((obj) => {
return obj.value === selectedFilamentID;
});
const filamentWeight = selectedFilament?.weight || 0;
const spoolWeight = selectedFilament?.spool_weight || 0;
const filamentChange = (newID: number) => {
const newSelectedFilament = filamentOptions?.find((obj) => {
return obj.value === newID;
});
const filamentHasWeight = newSelectedFilament?.weight || 0;
const filamentHasSpoolWeight = newSelectedFilament?.spool_weight || 0;
if (weightToEnter == WeightToEnter.measured_weight) {
if (!(filamentHasWeight && filamentHasSpoolWeight)) {
setWeightToEnter(WeightToEnter.remaining_weight);
}
const initial_weight = initialWeightValue ?? 0;
const spool_weight = spoolWeightValue ?? 0;
const newFilamentWeight = newSelectedFilament?.weight || 0;
const newSpoolWeight = newSelectedFilament?.spool_weight || 0;
const currentCalculatedFilamentWeight = getTotalWeightFromFilament();
if ((initial_weight === 0 || initial_weight === currentCalculatedFilamentWeight) && newFilamentWeight > 0) {
form.setFieldValue("initial_weight", newFilamentWeight);
}
if (weightToEnter == WeightToEnter.remaining_weight || weightToEnter == WeightToEnter.measured_weight) {
if (!filamentHasWeight) {
setWeightToEnter(WeightToEnter.used_weight);
}
if ((spool_weight === 0 || spool_weight === (selectedFilament?.spool_weight ?? 0)) && newSpoolWeight > 0) {
form.setFieldValue("spool_weight", newSpoolWeight);
}
};
const weightChange = (weight: number) => {
setUsedWeight(weight);
form.setFieldValue("used_weight", weight);
form.setFieldsValue({
used_weight: weight,
});
};
const locations = useSpoolmanLocations(true);
@@ -135,6 +134,74 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
allLocations.push(newLocation.trim());
}
const getSpoolWeight = (): number => {
return spoolWeightValue ?? (selectedFilament?.spool_weight ?? 0);
}
const getFilamentWeight = (): number => {
return initialWeightValue ?? (selectedFilament?.weight ?? 0)
}
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
};
const getTotalWeightFromFilament = (): number => {
return (selectedFilament?.weight ?? 0) + (selectedFilament?.spool_weight ?? 0);
}
const getMeasuredWeight = (): number => {
const grossWeight = getGrossWeight();
return grossWeight - usedWeight;
}
const getRemainingWeight = (): number => {
const initial_weight = getFilamentWeight();
return initial_weight - usedWeight;
}
const isMeasuredWeightEnabled = (): boolean => {
if (!isRemainingWeightEnabled()) {
return false;
}
const spool_weight = spoolWeightValue;
return (spool_weight || selectedFilament?.spool_weight) ? true : false;
}
const isRemainingWeightEnabled = (): boolean => {
const initial_weight = initialWeightValue;
if (initial_weight) {
return true;
}
return selectedFilament?.weight ? true : false;
}
React.useEffect(() => {
if (weightToEnter >= WeightToEnter.measured_weight)
{
if (!isMeasuredWeightEnabled()) {
setWeightToEnter(WeightToEnter.remaining_weight);
return;
}
}
if (weightToEnter >= WeightToEnter.remaining_weight)
{
if (!isRemainingWeightEnabled()) {
setWeightToEnter(WeightToEnter.used_weight);
return;
}
}
}, [selectedFilament])
const initialUsedWeight = formProps.initialValues?.used_weight || 0;
useEffect(() => {
if (initialUsedWeight) {
@@ -238,9 +305,40 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
parser={numberParser}
/>
</Form.Item>
<Form.Item
label={t("spool.fields.initial_weight")}
help={t("spool.fields_help.initial_weight")}
name={["initial_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1}/>
</Form.Item>
<Form.Item
label={t("spool.fields.spool_weight")}
help={t("spool.fields_help.spool_weight")}
name={["spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>
<Form.Item label={t("spool.fields.weight_to_use")} help={t("spool.fields_help.weight_to_use")}>
<Radio.Group
onChange={(value) => {
@@ -250,10 +348,10 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
value={weightToEnter}
>
<Radio.Button value={WeightToEnter.used_weight}>{t("spool.fields.used_weight")}</Radio.Button>
<Radio.Button value={WeightToEnter.remaining_weight} disabled={!filamentWeight}>
<Radio.Button value={WeightToEnter.remaining_weight} disabled={!isRemainingWeightEnabled()}>
{t("spool.fields.remaining_weight")}
</Radio.Button>
<Radio.Button value={WeightToEnter.measured_weight} disabled={!(filamentWeight && spoolWeight)}>
<Radio.Button value={WeightToEnter.measured_weight} disabled={!isMeasuredWeightEnabled()}>
{t("spool.fields.measured_weight")}
</Radio.Button>
</Radio.Group>
@@ -272,7 +370,10 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
}}
/>
</Form.Item>
<Form.Item label={t("spool.fields.remaining_weight")} help={t("spool.fields_help.remaining_weight")}>
<Form.Item
label={t("spool.fields.remaining_weight")}
help={t("spool.fields_help.remaining_weight")}
>
<InputNumber
min={0}
addonAfter="g"
@@ -280,13 +381,16 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != WeightToEnter.remaining_weight}
value={filamentWeight ? filamentWeight - usedWeight : 0}
value={getRemainingWeight()}
onChange={(value) => {
weightChange(filamentWeight - (value ?? 0));
weightChange(getFilamentWeight() - (value ?? 0));
}}
/>
</Form.Item>
<Form.Item label={t("spool.fields.measured_weight")} help={t("spool.fields_help.measured_weight")}>
<Form.Item
label={t("spool.fields.measured_weight")}
help={t("spool.fields_help.measured_weight")}
>
<InputNumber
min={0}
addonAfter="g"
@@ -294,9 +398,10 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != WeightToEnter.measured_weight}
value={filamentWeight && spoolWeight ? filamentWeight - usedWeight + spoolWeight : 0}
value={getMeasuredWeight()}
onChange={(value) => {
weightChange(filamentWeight - ((value ?? 0) - spoolWeight));
const totalWeight = getGrossWeight();
weightChange(totalWeight - (value ?? 0));
}}
/>
</Form.Item>

View File

@@ -1,5 +1,11 @@
import { IFilament } from "../filaments/model";
export enum WeightToEnter {
used_weight = 1,
remaining_weight = 2,
measured_weight = 3,
}
export interface ISpool {
id: number;
registered: string;
@@ -7,6 +13,8 @@ export interface ISpool {
last_used?: string;
filament: IFilament;
price?: number;
initial_weight?: number;
spool_weight?: number;
remaining_weight?: number;
used_weight: number;
remaining_length?: number;

View File

@@ -1,7 +1,7 @@
import React from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd";
import { Button, Form, Input, Typography } from "antd";
import { Button, Form, Input, Typography, InputNumber } from "antd";
import TextArea from "antd/es/input/TextArea";
import { IVendor, IVendorParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
@@ -85,6 +85,22 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
>
<TextArea maxLength={1024} />
</Form.Item>
<Form.Item
label={t("vendor.fields.empty_spool_weight")}
help={t("vendor.fields_help.empty_spool_weight")}
name={["empty_spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber
addonAfter="g"
precision={1}/>
</Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, message, Alert, Typography } from "antd";
import { Form, Input, DatePicker, message, Alert, Typography, InputNumber } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { IVendor, IVendorParsedExtras } from "./model";
@@ -100,6 +100,22 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
>
<TextArea maxLength={1024} />
</Form.Item>
<Form.Item
label={t("vendor.fields.empty_spool_weight")}
help={t("vendor.fields_help.empty_spool_weight")}
name={["empty_spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber
addonAfter="g"
precision={1}/>
</Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />

View File

@@ -7,7 +7,7 @@ import utc from "dayjs/plugin/utc";
import { IVendor } from "./model";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { EditOutlined, EyeOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { DateColumn, RichColumn, SortedColumn, ActionsColumn, CustomFieldColumn } from "../../components/column";
import { DateColumn, RichColumn, SortedColumn, ActionsColumn, CustomFieldColumn, NumberColumn } from "../../components/column";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
@@ -17,7 +17,7 @@ dayjs.extend(utc);
const namespace = "vendorList-v2";
const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment"];
const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment", "empty_spool_weight"];
export const VendorList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
@@ -171,6 +171,15 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "registered",
i18ncat: "vendor",
width: 200,
}),
NumberColumn({
...commonProps,
id: "empty_spool_weight",
i18ncat: "vendor",
unit: "g",
maxDecimals: 0,
width: 200,
}),
...(extraFields.data?.map((field) => {
return CustomFieldColumn({

View File

@@ -3,6 +3,7 @@ export interface IVendor {
registered: string;
name: string;
comment?: string;
empty_spool_weight?: number;
extra: { [key: string]: string };
}

View File

@@ -42,6 +42,8 @@ export const VendorShow: React.FC<IResourceComponentsProps> = () => {
<TextField value={record?.name} />
<Title level={5}>{t("vendor.fields.comment")}</Title>
<TextField value={enrichText(record?.comment)} />
<Title level={5}>{t("vendor.fields.empty_spool_weight")}</Title>
<TextField value={record?.empty_spool_weight} />
<Title level={4}>{t("settings.extra_fields.tab")}</Title>
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />

View File

@@ -0,0 +1,41 @@
"""spool weights.
Revision ID: aafcd7fb0e84
Revises: b8881bdb716c
Create Date: 2024-03-26 09:48:09.930022
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "aafcd7fb0e84"
down_revision = "b8881bdb716c"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Perform the upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"spool",
sa.Column(
"initial_weight",
sa.Float(),
nullable=True,
comment="The initial weight of the filament on the spool (net weight).",
),
)
op.add_column(
"spool",
sa.Column("spool_weight", sa.Float(), nullable=True, comment="The weight of the empty spool (tare weight)."),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Perform the downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("spool", "spool_weight")
op.drop_column("spool", "initial_weight")
# ### end Alembic commands ###

View File

@@ -0,0 +1,61 @@
"""spool weight population.
Revision ID: 304a32906234
Revises: aafcd7fb0e84
Create Date: 2024-03-26 13:49:26.594399
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "304a32906234"
down_revision = "aafcd7fb0e84"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Pre-populate the spool weights."""
"""This must be done in a separate migration because"""
"""of cockroachdb's execution of alembic migrations"""
filament = sa.Table(
"filament",
sa.MetaData(),
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("weight", sa.Float(), nullable=True),
sa.Column("spool_weight", sa.Float(), nullable=True),
)
spool = sa.Table(
"spool",
sa.MetaData(),
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("filament_id", sa.Integer),
sa.Column(
"initial_weight",
sa.Float(),
nullable=False,
),
sa.Column(
"spool_weight",
sa.Float(),
nullable=False,
),
)
initial_weight = (
sa.select((filament.c.weight).label("initial_weight"))
.where(filament.c.id == spool.c.filament_id)
.scalar_subquery()
)
spool_weight = sa.select(filament.c.spool_weight).where(filament.c.id == spool.c.filament_id).scalar_subquery()
set_initial_weight = sa.update(spool).values(initial_weight=initial_weight)
op.execute(set_initial_weight)
set_spool_weight = sa.update(spool).values(spool_weight=spool_weight)
op.execute(set_spool_weight)
def downgrade() -> None:
"""Perform the downgrade."""

View File

@@ -0,0 +1,31 @@
"""vendor empty spool weight.
Revision ID: 5f069e51bd89
Revises: 304a32906234
Create Date: 2024-03-26 15:07:18.366290
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "5f069e51bd89"
down_revision = "304a32906234"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Perform the upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"vendor",
sa.Column("empty_spool_weight", sa.Float(), nullable=True, comment="The weight of an empty spool."),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Perform the downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("vendor", "empty_spool_weight")
# ### end Alembic commands ###

View File

@@ -60,6 +60,7 @@ class Vendor(BaseModel):
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.")
name: str = Field(max_length=64, description="Vendor name.", example="Polymaker")
comment: Optional[str] = Field(max_length=1024, description="Free text comment about this vendor.", example="")
empty_spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
extra: dict[str, str] = Field(
description=(
"Extra fields for this vendor. All values are JSON-encoded data. "
@@ -75,6 +76,7 @@ class Vendor(BaseModel):
registered=item.registered,
name=item.name,
comment=item.comment,
empty_spool_weight=item.empty_spool_weight,
extra={field.key: field.value for field in item.extra},
)
@@ -185,6 +187,18 @@ class Spool(BaseModel):
),
example=500.6,
)
initial_weight: Optional[float] = Field(
default=None,
ge=0,
description=("The initial weight, in grams, of the filament on the spool (net weight)."),
example=1246,
)
spool_weight: Optional[float] = Field(
default=None,
ge=0,
description=("Weight of an empty spool (tare weight)."),
example=246,
)
used_weight: float = Field(ge=0, description="Consumed weight of filament from the spool in grams.", example=500.3)
remaining_length: Optional[float] = Field(
default=None,
@@ -226,7 +240,15 @@ class Spool(BaseModel):
remaining_weight: Optional[float] = None
remaining_length: Optional[float] = None
if filament.weight is not None:
if item.initial_weight is not None:
remaining_weight = max(item.initial_weight - item.used_weight, 0)
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
diameter=filament.diameter,
)
elif filament.weight is not None:
remaining_weight = max(filament.weight - item.used_weight, 0)
remaining_length = length_from_weight(
weight=remaining_weight,
@@ -247,6 +269,8 @@ class Spool(BaseModel):
last_used=item.last_used,
filament=filament,
price=item.price,
initial_weight=item.initial_weight,
spool_weight=item.spool_weight,
used_weight=item.used_weight,
used_length=used_length,
remaining_weight=remaining_weight,

View File

@@ -17,7 +17,7 @@ from spoolman.api.v1.models import Message, Spool, SpoolEvent
from spoolman.database import spool
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemCreateError
from spoolman.exceptions import ItemCreateError, SpoolMeasureError
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager
@@ -40,6 +40,16 @@ class SpoolParameters(BaseModel):
description="The price of this filament in the system configured currency.",
example=20.0,
)
initial_weight: Optional[float] = Field(
ge=0,
description="The initial weight of the filament on the spool, in grams. (net weight)",
example=200,
)
spool_weight: Optional[float] = Field(
ge=0,
description="The weight of an empty spool, in grams. (tare weight)",
example=200,
)
remaining_weight: Optional[float] = Field(
ge=0,
description=(
@@ -75,6 +85,10 @@ class SpoolUseParameters(BaseModel):
use_weight: Optional[float] = Field(description="Filament weight to reduce by, in g.", example=5.3)
class SpoolMeasureParameters(BaseModel):
weight: float = Field(description="Current gross weight of the spool, in g.", example=200)
@router.get(
"",
name="Find spool",
@@ -354,6 +368,8 @@ async def create( # noqa: ANN201
db=db,
filament_id=body.filament_id,
price=body.price,
initial_weight=body.initial_weight,
spool_weight=body.spool_weight,
remaining_weight=body.remaining_weight,
used_weight=body.used_weight,
first_used=body.first_used,
@@ -480,3 +496,30 @@ async def use( # noqa: ANN201
status_code=400,
content={"message": "Either use_weight or use_length must be specified."},
)
@router.put(
"/{spool_id}/measure",
name="Use spool filament based on the current weight measurement",
description=("Use some weight of filament from the spool. Specify the current gross weight of the spool."),
response_model_exclude_none=True,
response_model=Spool,
responses={
400: {"model": Message},
404: {"model": Message},
},
)
async def measure( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
spool_id: int,
body: SpoolMeasureParameters,
):
try:
db_item = await spool.measure(db, spool_id, body.weight)
return Spool.from_db(db_item)
except SpoolMeasureError as e:
logger.exception("Failed to update spool measurement.")
return JSONResponse(
status_code=400,
content={"message": e.args[0]},
)

View File

@@ -33,6 +33,11 @@ class VendorParameters(BaseModel):
description="Free text comment about this vendor.",
example="",
)
empty_spool_weight: Optional[float] = Field(
ge=0,
description="The weight of an empty spool, in grams.",
example=200,
)
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this vendor.",
@@ -41,11 +46,6 @@ class VendorParameters(BaseModel):
class VendorUpdateParameters(VendorParameters):
name: Optional[str] = Field(max_length=64, description="Vendor name.", example="Polymaker")
comment: Optional[str] = Field(
max_length=1024,
description="Free text comment about this vendor.",
example="",
)
@router.get(
@@ -190,6 +190,7 @@ async def create( # noqa: ANN201
db=db,
name=body.name,
comment=body.comment,
empty_spool_weight=body.empty_spool_weight,
extra=body.extra,
)

View File

@@ -48,6 +48,9 @@ async def create(
vendor_item: Optional[models.Vendor] = None
if vendor_id is not None:
vendor_item = await vendor.get_by_id(db, vendor_id)
# default spool weight from vendor
if spool_weight is None and vendor_item.empty_spool_weight is not None:
spool_weight = vendor_item.empty_spool_weight
filament = models.Filament(
name=name,

View File

@@ -18,6 +18,7 @@ class Vendor(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
name: Mapped[str] = mapped_column(String(64))
empty_spool_weight: Mapped[Optional[float]] = mapped_column(comment="The weight of an empty spool.")
comment: Mapped[Optional[str]] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
extra: Mapped[list["VendorField"]] = relationship(
@@ -64,6 +65,8 @@ class Spool(Base):
price: Mapped[Optional[float]] = mapped_column()
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"))
filament: Mapped["Filament"] = relationship(back_populates="spools")
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
location: Mapped[Optional[str]] = mapped_column(String(64))
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))

View File

@@ -20,7 +20,7 @@ from spoolman.database.utils import (
add_where_clause_str_opt,
parse_nested_field,
)
from spoolman.exceptions import ItemCreateError, ItemNotFoundError
from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError
from spoolman.math import weight_from_length
from spoolman.ws import websocket_manager
@@ -35,6 +35,8 @@ async def create(
db: AsyncSession,
filament_id: int,
remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None,
used_weight: Optional[float] = None,
first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None,
@@ -47,11 +49,23 @@ async def create(
) -> models.Spool:
"""Add a new spool to the database. Leave weight empty to assume full spool."""
filament_item = await filament.get_by_id(db, filament_id)
# Set spool_weight to spool_weight if spool_weight is not null and spool_weight not provided
if spool_weight is None and filament_item.spool_weight is not None:
spool_weight = filament_item.spool_weight
# Calculate initial_weight if not provided
if initial_weight is None and filament_item.weight is not None:
initial_weight = filament_item.weight
if used_weight is None:
if remaining_weight is not None:
if filament_item.weight is None:
raise ItemCreateError("remaining_weight can only be used if the filament type has a weight set.")
used_weight = max(filament_item.weight - remaining_weight, 0)
if initial_weight is None or initial_weight == 0:
raise ItemCreateError(
"remaining_weight can only be used if the initial_weight is "
"defined or the filament has a weight set.",
)
used_weight = max(initial_weight - remaining_weight, 0)
else:
used_weight = 0
@@ -64,6 +78,8 @@ async def create(
spool = models.Spool(
filament=filament_item,
registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight,
spool_weight=spool_weight,
used_weight=used_weight,
price=price,
first_used=first_used,
@@ -150,7 +166,7 @@ async def find(
for fieldstr, order in sort_by.items():
sorts = []
if fieldstr in {"remaining_weight", "remaining_length"}:
sorts.append(models.Filament.weight - models.Spool.used_weight)
sorts.append(models.Spool.initial_weight - models.Spool.spool_weight - models.Spool.used_weight)
elif fieldstr == "filament.combined_name":
sorts.append(models.Vendor.name)
sorts.append(models.Filament.name)
@@ -181,10 +197,14 @@ async def update(
for k, v in data.items():
if k == "filament_id":
spool.filament = await filament.get_by_id(db, v)
# If there is no initial_weight, calculate it from the filament weight
if spool.initial_weight is None and spool.filament.weight is not None:
spool.initial_weight = spool.filament.weight
elif k == "remaining_weight":
if spool.filament.weight is None:
raise ItemCreateError("remaining_weight can only be used if the filament type has a weight set.")
spool.used_weight = max(spool.filament.weight - v, 0)
if spool.initial_weight is None:
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
spool.used_weight = max(spool.initial_weight - v, 0)
elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v))
elif k == "extra":
@@ -217,6 +237,7 @@ async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> Non
db (AsyncSession): Database session
spool_id (int): Spool ID
weight (float): Filament weight to consume, in grams
"""
await db.execute(
sqlalchemy.update(models.Spool)
@@ -243,6 +264,7 @@ async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.S
Returns:
models.Spool: Updated spool object
"""
await use_weight_safe(db, spool_id, weight)
@@ -270,6 +292,7 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
Returns:
models.Spool: Updated spool object
"""
# Get filament diameter and density
result = await db.execute(
@@ -302,6 +325,74 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
return spool
async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
"""Record usage based on current gross weight of spool.
Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate.
Args:
db (AsyncSession): Database session
spool_id (int): Spool ID
weight (float): Length of filament to consume, in mm
Returns:
models.Spool: Updated spool object
"""
spool_result = await db.execute(
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
models.Spool.id == spool_id,
),
)
try:
spool_info = spool_result.one()
except NoResultFound as exc:
raise SpoolMeasureError("Spool not found.") from exc
initial_weight = spool_info[0]
spool_weight = spool_info[2]
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight
result = await db.execute(
sqlalchemy.select(models.Filament.weight, models.Filament.spool_weight)
.join(models.Spool, models.Spool.filament_id == models.Filament.id)
.where(models.Spool.id == spool_id),
)
try:
filament_info = result.one()
except NoResultFound as exc:
raise ItemNotFoundError("Filament not found for spool.") from exc
if spool_weight is None or spool_weight == 0:
spool_weight = filament_info[1]
if initial_weight is None or initial_weight == 0:
initial_weight = filament_info[0] if filament_info[0] is not None else 0
if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.")
initial_gross_weight = initial_weight + spool_weight
# if the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight:
return await reset_initial_weight(db, spool_id, weight - spool_weight)
# Calculate the current net weight
current_use = initial_gross_weight - spool_info[1]
# Calculate the weight used since last measure
weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_use - spool_weight
return await use_weight(db, spool_id, weight_to_use)
async def find_locations(
*,
db: AsyncSession,
@@ -333,3 +424,14 @@ async def spool_changed(spool: models.Spool, typ: EventType) -> None:
payload=Spool.from_db(spool),
),
)
async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
"""Reset inital weight to new weight and used_weight to 0."""
spool = await get_by_id(db, spool_id)
spool.initial_weight = weight
spool.used_weight = 0
await db.commit()
await spool_changed(spool, EventType.UPDATED)
return spool

View File

@@ -19,6 +19,7 @@ async def create(
db: AsyncSession,
name: Optional[str] = None,
comment: Optional[str] = None,
empty_spool_weight: Optional[float] = None,
extra: Optional[dict[str, str]] = None,
) -> models.Vendor:
"""Add a new vendor to the database."""
@@ -26,6 +27,7 @@ async def create(
name=name,
registered=datetime.utcnow().replace(microsecond=0),
comment=comment,
empty_spool_weight=empty_spool_weight,
extra=[models.VendorField(key=k, value=v) for k, v in (extra or {}).items()],
)
db.add(vendor)

View File

@@ -11,3 +11,7 @@ class ItemDeleteError(Exception):
class ItemCreateError(Exception):
pass
class SpoolMeasureError(Exception):
pass

View File

@@ -58,7 +58,10 @@ def random_vendor_impl():
# Add vendor
result = httpx.post(
f"{URL}/api/v1/vendor",
json={"name": "John"},
json={
"name": "John",
"empty_spool_weight": 246,
},
)
result.raise_for_status()

View File

@@ -195,3 +195,145 @@ def test_add_spool_both_used_and_remaining_weight(random_filament: dict[str, Any
},
)
assert result.status_code == 400 # Cannot set both used and remaining weight
def test_add_spool_initial_weight(random_filament: dict[str, Any]):
"""Test adding a spool to the database."""
# Execute
remaining_weight = 750
initial_weight = 1010
location = "The Pantry"
lot_nr = "123456789"
comment = "abcdefghåäö"
archived = True
price = 25
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"first_used": "2023-01-02T12:00:00+01:00",
"last_used": "2023-01-02T11:00:00Z",
"filament_id": random_filament["id"],
"remaining_weight": remaining_weight,
"initial_weight": initial_weight,
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"archived": archived,
"price": price,
},
)
result.raise_for_status()
# Verify
used_weight = initial_weight - remaining_weight
used_length = length_from_weight(
weight=used_weight,
density=random_filament["density"],
diameter=random_filament["diameter"],
)
remaining_length = length_from_weight(
weight=remaining_weight,
density=random_filament["density"],
diameter=random_filament["diameter"],
)
spool = result.json()
assert_dicts_compatible(
spool,
{
"id": spool["id"],
"registered": spool["registered"],
"first_used": "2023-01-02T11:00:00Z",
"last_used": "2023-01-02T11:00:00Z",
"filament": random_filament,
"initial_weight": pytest.approx(initial_weight),
"spool_weight": pytest.approx(random_filament["spool_weight"]),
"remaining_weight": pytest.approx(remaining_weight),
"used_weight": pytest.approx(used_weight),
"remaining_length": pytest.approx(remaining_length),
"used_length": pytest.approx(used_length),
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"archived": archived,
"price": price,
},
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
assert diff < 60
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_add_spool_spool_weight(random_filament: dict[str, Any]):
"""Test adding a spool to the database."""
# Execute
remaining_weight = 750
spool_weight = 200
location = "The Pantry"
lot_nr = "123456789"
comment = "abcdefghåäö"
archived = True
price = 25
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"first_used": "2023-01-02T12:00:00+01:00",
"last_used": "2023-01-02T11:00:00Z",
"filament_id": random_filament["id"],
"remaining_weight": remaining_weight,
"spool_weight": spool_weight,
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"archived": archived,
"price": price,
},
)
result.raise_for_status()
# Verify
used_weight = random_filament["weight"] - remaining_weight
used_length = length_from_weight(
weight=used_weight,
density=random_filament["density"],
diameter=random_filament["diameter"],
)
remaining_length = length_from_weight(
weight=remaining_weight,
density=random_filament["density"],
diameter=random_filament["diameter"],
)
spool = result.json()
assert_dicts_compatible(
spool,
{
"id": spool["id"],
"registered": spool["registered"],
"first_used": "2023-01-02T11:00:00Z",
"last_used": "2023-01-02T11:00:00Z",
"filament": random_filament,
"initial_weight": pytest.approx(random_filament["weight"]),
"spool_weight": pytest.approx(spool_weight),
"remaining_weight": pytest.approx(remaining_weight),
"used_weight": pytest.approx(used_weight),
"remaining_length": pytest.approx(remaining_length),
"used_length": pytest.approx(used_length),
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"archived": archived,
"price": price,
},
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
assert diff < 60
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()

View File

@@ -3,6 +3,7 @@
from typing import Any
import httpx
import pytest
from ..conftest import URL
@@ -46,6 +47,94 @@ def test_get_spool(random_filament: dict[str, Any]):
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_get_spool_default_weights(random_filament: dict[str, Any]):
"""Test getting a spool from the database."""
# Setup
first_used = "2023-01-01T00:00:00"
last_used = "2023-01-02T00:00:00"
remaining_weight = 750
location = "The Pantry"
lot_nr = "123456789"
comment = "abcdefghåäö"
price = 25
archived = True
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"first_used": first_used,
"last_used": last_used,
"filament_id": random_filament["id"],
"remaining_weight": remaining_weight,
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"price": price,
"archived": archived,
},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.get(f"{URL}/api/v1/spool/{spool['id']}")
result.raise_for_status()
result_spool = result.json()
# Verify
assert result_spool == spool
assert result_spool["initial_weight"] == pytest.approx(random_filament["weight"])
assert result_spool["spool_weight"] == pytest.approx(random_filament["spool_weight"])
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_get_spool_weights(random_filament: dict[str, Any]):
"""Test getting a spool from the database."""
# Setup
first_used = "2023-01-01T00:00:00"
last_used = "2023-01-02T00:00:00"
remaining_weight = 750
initial_weight = 1255
spool_weight = 246
location = "The Pantry"
lot_nr = "123456789"
comment = "abcdefghåäö"
price = 25
archived = True
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"first_used": first_used,
"last_used": last_used,
"filament_id": random_filament["id"],
"remaining_weight": remaining_weight,
"initial_weight": initial_weight,
"spool_weight": spool_weight,
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"price": price,
"archived": archived,
},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.get(f"{URL}/api/v1/spool/{spool['id']}")
result.raise_for_status()
result_spool = result.json()
# Verify
assert result_spool == spool
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_get_spool_not_found():
"""Test getting a spool that does not exist."""
# Execute

View File

@@ -0,0 +1,208 @@
"""Integration tests for the Spool API endpoint."""
from datetime import datetime, timezone
from typing import Any
import httpx
import pytest
from ..conftest import URL
@pytest.mark.parametrize("measurement", [246, 500, 600, 1000])
def test_measure_spool(random_filament: dict[str, Any], measurement: float):
"""Test using a spool in the database."""
# Setup
random_filament["weight"]
spool_weight = 246
start_weight = 1000
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": start_weight,
"initial_weight": start_weight,
"spool_weight": spool_weight,
},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/measure",
json={
"weight": measurement,
},
)
result.raise_for_status()
# Verify
spool = result.json()
# remaining_weight should be clamped so it's never negative, but used_weight should not be clamped to the net weight
expected_use = min(start_weight - (measurement - spool_weight), start_weight)
assert spool["used_weight"] == pytest.approx(expected_use)
expected_remaining = max(measurement - spool_weight, 0)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
@pytest.mark.parametrize("measurement", [1500]) # 1500 is more than the initial weight
def test_measure_spool_higher_initial(random_filament: dict[str, Any], measurement: float):
"""Test using a spool in the database."""
# Setup
random_filament["weight"]
spool_weight = 246
start_weight = 1000
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": start_weight,
"initial_weight": start_weight,
"spool_weight": spool_weight,
},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/measure",
json={
"weight": measurement,
},
)
# Update is invalid if the weight is more than the initial weight
result.raise_for_status()
# Verify
spool = result.json()
# remaining_weight should be clamped so it's never negative,
# but used_weight should not be clamped to the net weight
expected_use = 0
expected_initial_weight = measurement - spool_weight
expected_remaining = max(measurement - spool_weight, 0)
assert spool["used_weight"] == pytest.approx(expected_use)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
assert spool["initial_weight"] == pytest.approx(expected_initial_weight)
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
@pytest.mark.parametrize(
"measurements",
[[1244, 1233, 1200], [1000, 900, 800], [1000, 1000, 1000], [1000, 900, 800, 815]],
)
def test_measure_spool_sequence(random_filament: dict[str, Any], measurements: list[float]):
"""Test using a spool in the database."""
# Setup
random_filament["weight"]
spool_weight = 246
start_weight = 1009
initial_weight = start_weight + spool_weight
current_weight = initial_weight
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": start_weight,
"initial_weight": start_weight,
"spool_weight": spool_weight,
},
)
result.raise_for_status()
spool = result.json()
for m in measurements:
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/measure",
json={
"weight": m,
},
)
result.raise_for_status()
# Verify
spool = result.json()
# remaining_weight should be clamped so it's never negative,
# but used_weight should not be clamped to the net weight
expected_use = min(initial_weight - m, initial_weight - spool_weight)
assert spool["used_weight"] == pytest.approx(expected_use)
expected_remaining = max(m - spool_weight, 0)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
current_weight = current_weight - expected_use
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
@pytest.mark.parametrize("measurements", [[1244, 1233, 1200], [1000, 900, 800], [1000, 1000, 1000]])
def test_measure_spool_empty(random_empty_filament: dict[str, Any], measurements: list[float]):
"""Test using a spool in the database."""
# Setup
spool_weight = 246
start_weight = 1000
initial_weight = start_weight + spool_weight
current_weight = initial_weight
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_empty_filament["id"],
"remaining_weight": start_weight,
"initial_weight": start_weight,
"spool_weight": spool_weight,
},
)
result.raise_for_status()
spool = result.json()
for m in measurements:
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/measure",
json={
"weight": m,
},
)
result.raise_for_status()
# Verify
spool = result.json()
# remaining_weight should be clamped so it's never negative,
# but used_weight should not be clamped to the net weight
expected_use = min(initial_weight - m, initial_weight - spool_weight)
assert spool["used_weight"] == pytest.approx(expected_use)
expected_remaining = max(m - spool_weight, 0)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
current_weight = current_weight - expected_use
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()

View File

@@ -77,6 +77,83 @@ def test_update_spool(random_filament: dict[str, Any]):
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_update_spool_weights(random_filament: dict[str, Any]):
"""Test updating a spool in the database."""
# Setup
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": 1000,
"initial_weight": 1255,
"spool_weight": 246,
"location": "The Pantry",
"lot_nr": "123456789",
},
)
result.raise_for_status()
spool = result.json()
# Execute
first_used = "2023-01-01T12:00:00+02:00"
last_used = "2023-01-02T12:00:00+02:00"
remaining_weight = 750
initial_weight = 1005
spool_weight = 245
location = "Living Room"
lot_nr = "987654321"
comment = "abcdefghåäö"
archived = True
price = 25
result = httpx.patch(
f"{URL}/api/v1/spool/{spool['id']}",
json={
"first_used": first_used,
"last_used": last_used,
"remaining_weight": remaining_weight,
"location": location,
"lot_nr": lot_nr,
"comment": comment,
"archived": archived,
"price": price,
"initial_weight": initial_weight,
"spool_weight": spool_weight,
},
)
result.raise_for_status()
# Verify
used_weight = initial_weight - remaining_weight
used_length = length_from_weight(
weight=used_weight,
density=random_filament["density"],
diameter=random_filament["diameter"],
)
remaining_length = length_from_weight(
weight=remaining_weight,
density=random_filament["density"],
diameter=random_filament["diameter"],
)
spool = result.json()
assert spool["first_used"] == "2023-01-01T10:00:00Z"
assert spool["last_used"] == "2023-01-02T10:00:00Z"
assert spool["initial_weight"] == pytest.approx(initial_weight)
assert spool["spool_weight"] == pytest.approx(spool_weight)
assert spool["remaining_weight"] == pytest.approx(remaining_weight)
assert spool["used_weight"] == pytest.approx(used_weight)
assert spool["remaining_length"] == pytest.approx(remaining_length)
assert spool["used_length"] == pytest.approx(used_length)
assert spool["location"] == location
assert spool["lot_nr"] == lot_nr
assert spool["comment"] == comment
assert spool["archived"] == archived
assert spool["price"] == price
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_update_spool_both_used_and_remaining_weight(random_filament: dict[str, Any]):
"""Test updating a spool in the database."""
# Setup