Merge pull request #339 from spyder007/feature/spool_revisions
Add additional handling for spool weights
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
client/src/pages/vendors/create.tsx
vendored
18
client/src/pages/vendors/create.tsx
vendored
@@ -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} />
|
||||
|
||||
18
client/src/pages/vendors/edit.tsx
vendored
18
client/src/pages/vendors/edit.tsx
vendored
@@ -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} />
|
||||
|
||||
13
client/src/pages/vendors/list.tsx
vendored
13
client/src/pages/vendors/list.tsx
vendored
@@ -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({
|
||||
|
||||
1
client/src/pages/vendors/model.tsx
vendored
1
client/src/pages/vendors/model.tsx
vendored
@@ -3,6 +3,7 @@ export interface IVendor {
|
||||
registered: string;
|
||||
name: string;
|
||||
comment?: string;
|
||||
empty_spool_weight?: number;
|
||||
extra: { [key: string]: string };
|
||||
}
|
||||
|
||||
|
||||
2
client/src/pages/vendors/show.tsx
vendored
2
client/src/pages/vendors/show.tsx
vendored
@@ -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]} />
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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."""
|
||||
@@ -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 ###
|
||||
@@ -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,
|
||||
|
||||
@@ -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]},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,3 +11,7 @@ class ItemDeleteError(Exception):
|
||||
|
||||
class ItemCreateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SpoolMeasureError(Exception):
|
||||
pass
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
208
tests_integration/tests/spool/test_measure.py
Normal file
208
tests_integration/tests/spool/test_measure.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user