diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 23e3f97..21c1326 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -41,7 +41,9 @@
"deleteError": "Error when deleting {{resource}} (status code: {{statusCode}})",
"editSuccess": "Successfully edited {{resource}}",
"editError": "Error when editing {{resource}} (status code: {{statusCode}})",
- "importProgress": "Importing: {{processed}}/{{total}}"
+ "importProgress": "Importing: {{processed}}/{{total}}",
+ "saveSuccessful": "Save successful!",
+ "validationError": "Validation error: {{error}}"
},
"kofi": "Tip me on Ko-fi",
"loading": "Loading",
@@ -252,6 +254,46 @@
"table": {
"actions": "Actions"
},
+ "settings": {
+ "header": "Settings",
+ "general": {
+ "tab": "General",
+ "currency": {
+ "label": "Currency"
+ }
+ },
+ "extra_fields": {
+ "tab": "Extra Fields",
+ "description": "
Here you can add extra custom fields to your entities.
Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.
The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.
Extra fields can not be sorted or filtered in the table views.
",
+ "params": {
+ "key": "Key",
+ "name": "Name",
+ "field_type": "Type",
+ "unit": "Unit",
+ "order": "Order",
+ "default_value": "Default Value",
+ "choices": "Choices",
+ "multi_choice": "Multi Choice"
+ },
+ "field_type": {
+ "text": "Text",
+ "integer": "Integer",
+ "integer_range": "Integer Range",
+ "float": "Float",
+ "float_range": "Float Range",
+ "datetime": "Datetime",
+ "boolean": "Boolean",
+ "choice": "Choice"
+ },
+ "boolean_true": "Yes",
+ "boolean_false": "No",
+ "choices_missing_error": "You cannot remove choices. The following choices are missing: {{choices}}",
+ "non_unique_key_error": "The key must be unique.",
+ "key_not_changed": "Please change the key to something else.",
+ "delete_confirm": "Delete field {{name}}?",
+ "delete_confirm_description": "This will delete the field and all associated data for all entities."
+ }
+ },
"documentTitle": {
"default": "Spoolman",
"suffix": " | Spoolman",
diff --git a/client/public/locales/sv/common.json b/client/public/locales/sv/common.json
index ba75663..a21903b 100644
--- a/client/public/locales/sv/common.json
+++ b/client/public/locales/sv/common.json
@@ -41,7 +41,8 @@
"deleteError": "Det gick inte att ta bort {{resource}} (statuskod: {{statusCode}})",
"editSuccess": "Lyckades ändra {{resource}}",
"editError": "Det gick inte att ändra {{resource}} (statuskod: {{statusCode}})",
- "importProgress": "Importerar: {{processed}}/{{total}}"
+ "importProgress": "Importerar: {{processed}}/{{total}}",
+ "saveSuccessful": "Sparning lyckades!"
},
"loading": "Laddar",
"version": "Version",
@@ -240,6 +241,18 @@
"table": {
"actions": "Åtgärder"
},
+ "settings": {
+ "header": "Inställningar",
+ "generic": {
+ "tab": "Allmänt",
+ "currency": {
+ "label": "Valuta"
+ }
+ },
+ "extra_fields": {
+ "tab": "Extra Fält"
+ }
+ },
"documentTitle": {
"default": "Spoolman",
"suffix": " | Spoolman",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index bc2c03e..fbcddf3 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -10,7 +10,14 @@ import dataProvider from "./components/dataProvider";
import { useTranslation } from "react-i18next";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { ColorModeContextProvider } from "./contexts/color-mode";
-import { FileOutlined, HighlightOutlined, HomeOutlined, QuestionOutlined, UserOutlined } from "@ant-design/icons";
+import {
+ FileOutlined,
+ HighlightOutlined,
+ HomeOutlined,
+ QuestionOutlined,
+ ToolOutlined,
+ UserOutlined,
+} from "@ant-design/icons";
import { ConfigProvider } from "antd";
import React from "react";
import { Locale } from "antd/es/locale";
@@ -139,6 +146,14 @@ function App() {
icon: ,
},
},
+ {
+ name: "settings",
+ list: "/settings",
+ meta: {
+ canDelete: false,
+ icon: ,
+ },
+ },
{
name: "help",
list: "/help",
@@ -202,6 +217,7 @@ function App() {
} />
} />
+ } />
} />
} />
diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx
index 53f89a7..a773e3d 100644
--- a/client/src/components/column.tsx
+++ b/client/src/components/column.tsx
@@ -3,7 +3,7 @@ import { ColumnFilterItem, ColumnType } from "antd/es/table/interface";
import { getFiltersForField, typeFilters } from "../utils/filtering";
import { TableState } from "../utils/saveload";
import { getSortOrderForField, typeSorters } from "../utils/sorting";
-import { NumberFieldUnit } from "./numberField";
+import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { DateField, TextField } from "@refinedev/antd";
@@ -12,7 +12,8 @@ import SpoolIcon from "../icon_spool.svg?react";
import { useTranslate } from "@refinedev/core";
import { enrichText } from "../utils/parsing";
import { UseQueryResult } from "@tanstack/react-query";
-import { Link, useNavigate } from "react-router-dom";
+import { Link } from "react-router-dom";
+import { Field, FieldType } from "../utils/queryFields";
dayjs.extend(utc);
@@ -39,14 +40,19 @@ export interface Action {
}
interface BaseColumnProps {
- id: keyof Obj & string;
+ id: string | string[];
dataId?: keyof Obj & string;
i18ncat?: string;
i18nkey?: string;
+ title?: string;
+ sorter?: boolean;
+ t: (key: string) => string;
+ navigate: (link: string) => void;
dataSource: Obj[];
tableState: TableState;
width?: number;
actions?: (record: Obj) => Action[];
+ transform?: (value: unknown) => unknown;
}
interface FilteredColumnProps {
@@ -70,24 +76,32 @@ interface CustomColumnProps {
function Column(
props: BaseColumnProps & FilteredColumnProps & CustomColumnProps
): ColumnType | undefined {
- const t = useTranslate();
- const navigate = useNavigate();
+ const t = props.t;
+ const navigate = props.navigate;
// Hide if not in showColumns
- if (props.tableState.showColumns && !props.tableState.showColumns.includes(props.id)) {
+ const id = Array.isArray(props.id) ? props.id.join(".") : props.id;
+ if (props.tableState.showColumns && !props.tableState.showColumns.includes(id)) {
return undefined;
}
const columnProps: ColumnType = {
dataIndex: props.id,
- title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
- sorter: true,
- sortOrder: getSortOrderForField(typeSorters(props.tableState.sorters), props.dataId ?? props.id),
+ title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
filterMultiple: props.allowMultipleFilters ?? true,
width: props.width ?? undefined,
onCell: props.onCell ?? undefined,
};
+ // Sorting
+ if (props.sorter) {
+ columnProps.sorter = true;
+ columnProps.sortOrder = getSortOrderForField(
+ typeSorters(props.tableState.sorters),
+ props.dataId ?? (props.id as keyof Obj)
+ );
+ }
+
// Filter
if (props.filters && props.filteredValue) {
columnProps.filters = props.filters;
@@ -106,7 +120,12 @@ function Column(
}
// Render
- const render = props.render ?? ((value) => <>{value}>);
+ const render =
+ props.render ??
+ ((rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
+ return <>{value}>;
+ });
columnProps.render = (value, record, index) => {
if (!props.actions) {
return render(value, record, index);
@@ -144,13 +163,19 @@ function Column(
}
export function SortedColumn(props: BaseColumnProps) {
- return Column(props);
-}
-
-export function RichColumn(props: BaseColumnProps) {
return Column({
...props,
- render: (value: string | undefined) => {
+ sorter: true,
+ });
+}
+
+export function RichColumn(
+ props: Omit, "transform"> & { transform?: (value: unknown) => string }
+) {
+ return Column({
+ ...props,
+ render: (rawValue: string | undefined) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
return enrichText(value);
},
});
@@ -182,7 +207,7 @@ export function FilteredQueryColumn(props: FilteredQueryColu
});
const typedFilters = typeFilters(props.tableState.filters);
- const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
+ const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
@@ -193,14 +218,16 @@ export function FilteredQueryColumn(props: FilteredQueryColu
interface NumberColumnProps extends BaseColumnProps {
unit: string;
- decimals?: number;
+ maxDecimals?: number;
+ minDecimals?: number;
defaultText?: string;
}
export function NumberColumn(props: NumberColumnProps) {
return Column({
...props,
- render: (value) => {
+ render: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return ;
}
@@ -209,8 +236,8 @@ export function NumberColumn(props: NumberColumnProps)
value={value}
unit={props.unit}
options={{
- maximumFractionDigits: props.decimals ?? 0,
- minimumFractionDigits: props.decimals ?? 0,
+ maximumFractionDigits: props.maxDecimals ?? 0,
+ minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}}
/>
);
@@ -221,7 +248,8 @@ export function NumberColumn(props: NumberColumnProps)
export function DateColumn(props: BaseColumnProps) {
return Column({
...props,
- render: (value) => {
+ render: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
return (
(props: SpoolIconColumnProps<
});
const typedFilters = typeFilters(props.tableState.filters);
- const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
+ const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
@@ -312,7 +340,8 @@ export function SpoolIconColumn(props: SpoolIconColumnProps<
},
};
},
- render: (value, record: Obj) => {
+ render: (rawValue, record: Obj) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
const colorStr = props.color(record);
return (
@@ -334,3 +363,121 @@ export function SpoolIconColumn(props: SpoolIconColumnProps<
},
});
}
+
+export function NumberRangeColumn(props: NumberColumnProps) {
+ return Column({
+ ...props,
+ render: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
+ if (value === null || value === undefined) {
+ return ;
+ }
+ if (!Array.isArray(value) || value.length !== 2) {
+ return ;
+ }
+
+ return (
+
+ );
+ },
+ });
+}
+
+export function CustomFieldColumn(props: Omit, "id"> & { field: Field }) {
+ const field = props.field;
+ const commonProps = {
+ ...props,
+ id: ["extra", field.key],
+ title: field.name,
+ sorter: false,
+ transform: (value: unknown) => {
+ if (value === null || value === undefined) {
+ return undefined;
+ }
+ return JSON.parse(value as string);
+ },
+ };
+
+ if (field.field_type === FieldType.integer) {
+ return NumberColumn({
+ ...commonProps,
+ unit: field.unit ?? "",
+ maxDecimals: 0,
+ });
+ } else if (field.field_type === FieldType.float) {
+ return NumberColumn({
+ ...commonProps,
+ unit: field.unit ?? "",
+ minDecimals: 0,
+ maxDecimals: 3,
+ });
+ } else if (field.field_type === FieldType.integer_range) {
+ return NumberRangeColumn({
+ ...commonProps,
+ unit: field.unit ?? "",
+ maxDecimals: 0,
+ });
+ } else if (field.field_type === FieldType.float_range) {
+ return NumberRangeColumn({
+ ...commonProps,
+ unit: field.unit ?? "",
+ minDecimals: 0,
+ maxDecimals: 3,
+ });
+ } else if (field.field_type === FieldType.text) {
+ return RichColumn({
+ ...commonProps,
+ });
+ } else if (field.field_type === FieldType.datetime) {
+ return DateColumn({
+ ...commonProps,
+ });
+ } else if (field.field_type === FieldType.boolean) {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ let text;
+ if (value === undefined || value === null) {
+ text = "";
+ } else if (value) {
+ text = props.t("yes");
+ } else {
+ text = props.t("no");
+ }
+ return ;
+ },
+ });
+ } else if (field.field_type === FieldType.choice && !field.multi_choice) {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ return ;
+ },
+ });
+ } else if (field.field_type === FieldType.choice && field.multi_choice) {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ return ;
+ },
+ });
+ } else {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ return ;
+ },
+ });
+ }
+}
diff --git a/client/src/components/dateTimePicker.tsx b/client/src/components/dateTimePicker.tsx
new file mode 100644
index 0000000..4097682
--- /dev/null
+++ b/client/src/components/dateTimePicker.tsx
@@ -0,0 +1,33 @@
+import { DatePicker } from "antd";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import advancedFormat from "dayjs/plugin/advancedFormat";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(advancedFormat);
+
+// Localized date time format with timezone
+const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
+
+export function DateTimePicker(props: { value?: T; onChange?: (value?: T) => void }) {
+ return (
+ {
+ if (value) {
+ if (typeof props.value === "string") {
+ props.onChange?.(value.toISOString() as T);
+ } else {
+ props.onChange?.(value as T);
+ }
+ } else {
+ props.onChange?.(undefined);
+ }
+ }}
+ />
+ );
+}
diff --git a/client/src/components/extraFields.tsx b/client/src/components/extraFields.tsx
new file mode 100644
index 0000000..ab2bacc
--- /dev/null
+++ b/client/src/components/extraFields.tsx
@@ -0,0 +1,211 @@
+import { DateField, TextField } from "@refinedev/antd";
+import { Field, FieldType } from "../utils/queryFields";
+import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
+import { enrichText } from "../utils/parsing";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import { Checkbox, Form, Input, InputNumber, Select, Typography } from "antd";
+import { FormItemProps, Rule } from "antd/es/form";
+import { DateTimePicker } from "./dateTimePicker";
+import { InputNumberRange } from "./inputNumberRange";
+
+dayjs.extend(utc);
+
+const { Title } = Typography;
+
+/**
+ * Title and value to display an extra field. Used for the show pages.
+ * @param props
+ * @returns
+ */
+export function ExtraFieldDisplay(props: { field: Field; value: string | undefined }) {
+ const { field, value } = props;
+
+ let item;
+ if (value !== undefined) {
+ const parsedValue = JSON.parse(value);
+
+ if (field.field_type === FieldType.integer) {
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.float) {
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.integer_range || field.field_type === FieldType.float_range) {
+ if (!Array.isArray(parsedValue) || parsedValue.length !== 2) {
+ return ;
+ }
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.text) {
+ item = ;
+ } else if (field.field_type === FieldType.datetime) {
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.boolean) {
+ item = ;
+ } else if (field.field_type === FieldType.choice && !field.multi_choice) {
+ item = ;
+ } else if (field.field_type === FieldType.choice && field.multi_choice) {
+ item = ;
+ } else {
+ throw new Error(`Unknown field type: ${field.field_type}`);
+ }
+ } else {
+ item = <>>;
+ }
+
+ return (
+ <>
+ {field.name}
+ {item}
+ >
+ );
+}
+
+/**
+ * Form item for an extra field. Used for the edit pages.
+ * @param props
+ * @returns
+ */
+export function ExtraFieldFormItem(props: { field: Field; setDefaultValue?: boolean }) {
+ const { field } = props;
+
+ let inputNode;
+ const rules: Rule[] = [
+ {
+ required: false,
+ },
+ ];
+ const formItemProps: FormItemProps = {};
+ if (field.field_type === FieldType.integer) {
+ inputNode = ;
+ rules.push({
+ type: "integer",
+ });
+ } else if (field.field_type === FieldType.float) {
+ inputNode = ;
+ rules.push({
+ type: "number",
+ });
+ } else if (field.field_type === FieldType.integer_range) {
+ inputNode = ;
+ } else if (field.field_type === FieldType.float_range) {
+ inputNode = ;
+ } else if (field.field_type === FieldType.text) {
+ inputNode = ;
+ rules.push({
+ type: "string",
+ });
+ } else if (field.field_type === FieldType.datetime) {
+ inputNode = ;
+ } else if (field.field_type === FieldType.boolean) {
+ inputNode = ;
+ formItemProps.valuePropName = "checked";
+ rules.push({
+ type: "boolean",
+ });
+ } else if (field.field_type === FieldType.choice) {
+ inputNode = (
+ ({ label: choice, value: choice }))}
+ />
+ );
+ rules.push({
+ type: field.multi_choice ? "array" : "string",
+ });
+ } else {
+ throw new Error(`Unknown field type: ${field.field_type}`);
+ }
+
+ if (props.setDefaultValue) {
+ formItemProps.initialValue = field.default_value;
+ }
+
+ return (
+
+ {inputNode}
+
+ );
+}
+
+/**
+ * Convert the string-based value extra key-values of an entity to their JSON-parsed values.
+ * @param obj
+ * @returns
+ */
+export function ParsedExtras(
+ obj: T
+): Omit & { extra?: { [key: string]: unknown } } {
+ if (obj.extra) {
+ const newExtra: { [key: string]: unknown } = {};
+ Object.entries(obj.extra).forEach(([key, value]) => {
+ try {
+ newExtra[key] = JSON.parse(value);
+ } catch (e) {
+ newExtra[key] = value;
+ }
+ });
+ return {
+ ...obj,
+ extra: newExtra,
+ };
+ } else {
+ return obj;
+ }
+}
+
+/**
+ * Convert the JSON-parsed value extra key-values of an entity to their string values.
+ * @param obj
+ * @returns
+ */
+export function StringifiedExtras(
+ obj: T
+): Omit & { extra?: { [key: string]: string } } {
+ if (obj.extra) {
+ const newExtra: { [key: string]: string } = {};
+ Object.entries(obj.extra).forEach(([key, value]) => {
+ newExtra[key] = JSON.stringify(value);
+ });
+ return {
+ ...obj,
+ extra: newExtra,
+ };
+ } else {
+ return {
+ ...obj,
+ extra: undefined,
+ };
+ }
+}
diff --git a/client/src/components/inputNumberRange.tsx b/client/src/components/inputNumberRange.tsx
new file mode 100644
index 0000000..23da031
--- /dev/null
+++ b/client/src/components/inputNumberRange.tsx
@@ -0,0 +1,57 @@
+import { InputNumber } from "antd";
+
+function parseValue(value?: (number | null)[]): [number | null, number | null] {
+ if (value === undefined) {
+ return [null, null];
+ }
+
+ if (!Array.isArray(value) || value.length !== 2) {
+ return [null, null];
+ }
+
+ const [min, max] = value;
+
+ return [min, max];
+}
+
+/**
+ * An Ant Design compatible form input for a number range.
+ * @param props
+ * @returns
+ */
+export function InputNumberRange(props: {
+ /** Decimal precision */ precision?: number;
+ /** Unit to display */ unit?: string;
+ /** Value state */ value?: (number | null)[] | undefined;
+ /** Called when input changes. Used to update value state. */ onChange?: (value: (number | null)[]) => void;
+}) {
+ const [min, max] = parseValue(props.value);
+
+ return (
+ <>
+ {
+ if (props.onChange) {
+ props.onChange([value, max]);
+ }
+ }}
+ />
+ {" \u2013 "}
+ {
+ if (props.onChange) {
+ props.onChange([min, value]);
+ }
+ }}
+ />
+ >
+ );
+}
diff --git a/client/src/components/numberField.tsx b/client/src/components/numberField.tsx
index b8bf0da..facb258 100644
--- a/client/src/components/numberField.tsx
+++ b/client/src/components/numberField.tsx
@@ -26,3 +26,36 @@ export const NumberFieldUnit: React.FC = ({ value, locale, options, ...re
);
};
+
+/**
+ * Like a {@link NumberFieldUnit} but for a range of numbers.
+ * @param props
+ * @returns
+ */
+export function NumberFieldUnitRange(props: {
+ value: (number | null)[] | undefined;
+ unit?: string;
+ options?: Intl.NumberFormatOptions;
+}) {
+ const { value, unit, options } = props;
+
+ if (value === undefined) {
+ console.warn("NumberFieldUnitRange received undefined value");
+ return <>>;
+ }
+
+ if (!Array.isArray(value) || value.length !== 2) {
+ console.warn("NumberFieldUnitRange received invalid value", value);
+ return <>>;
+ }
+
+ const [min, max] = value;
+
+ return (
+ <>
+ {min === null ? <>> : }
+ {" \u2013 "}
+ {max === null ? <>> : }
+ >
+ );
+}
diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx
index 882b40a..3a95512 100644
--- a/client/src/components/spoolSelectModal.tsx
+++ b/client/src/components/spoolSelectModal.tsx
@@ -7,6 +7,7 @@ import { useTable } from "@refinedev/antd";
import { t } from "i18next";
import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels";
import { removeUndefined } from "../utils/filtering";
+import { useNavigate } from "react-router-dom";
interface Props {
visible: boolean;
@@ -40,6 +41,7 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC
const [selectedItems, setSelectedItems] = useState([]);
const [showArchived, setShowArchived] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
+ const navigate = useNavigate();
const { tableProps, sorters, filters, current, pageSize } = useTable({
meta: {
@@ -102,6 +104,17 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC
const isSomeButNotAllFilteredSelected =
dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected;
+ const commonProps = {
+ t,
+ navigate,
+ actions: () => {
+ return [];
+ },
+ dataSource,
+ tableState,
+ sorter: true,
+ };
+
return (
= ({ visible, description, onCancel, onC
),
},
SortedColumn({
+ ...commonProps,
id: "id",
i18ncat: "spool",
- dataSource,
- tableState,
width: 80,
}),
SpoolIconColumn({
+ ...commonProps,
id: "combined_name",
dataId: "filament.id",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanFilamentFilter(),
}),
FilteredQueryColumn({
+ ...commonProps,
id: "filament.material",
i18nkey: "spool.fields.material",
- dataSource,
- tableState,
filterValueQuery: useSpoolmanMaterials(),
}),
])}
diff --git a/client/src/pages/filaments/create.tsx b/client/src/pages/filaments/create.tsx
index 4ce1813..664391d 100644
--- a/client/src/pages/filaments/create.tsx
+++ b/client/src/pages/filaments/create.tsx
@@ -1,11 +1,17 @@
import React from "react";
-import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd";
-import { Form, Input, Select, InputNumber, ColorPicker, Button } from "antd";
+import { Form, Input, Select, InputNumber, ColorPicker, Button, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model";
-import { IFilament } from "./model";
+import { IFilament, IFilamentParsedExtras } from "./model";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+
+dayjs.extend(utc);
interface CreateOrCloneProps {
mode: "create" | "clone";
@@ -13,10 +19,20 @@ interface CreateOrCloneProps {
export const FilamentCreate: React.FC = (props) => {
const t = useTranslate();
+ const extraFields = useGetFields(EntityType.filament);
- const { form, formProps, formLoading, onFinish, redirect } = useForm();
+ const { form, formProps, formLoading, onFinish, redirect } = useForm<
+ IFilament,
+ HttpError,
+ IFilamentParsedExtras,
+ IFilamentParsedExtras
+ >();
- if (props.mode === "clone" && formProps.initialValues) {
+ if (!formProps.initialValues) {
+ formProps.initialValues = {};
+ }
+
+ if (props.mode === "clone") {
// Fix the vendor_id
if (formProps.initialValues.vendor) {
formProps.initialValues.vendor_id = formProps.initialValues.vendor.id;
@@ -24,7 +40,7 @@ export const FilamentCreate: React.FC {
- const values = await form.validateFields();
+ const values = StringifiedExtras(await form.validateFields());
await onFinish(values);
redirect(redirectTo, (values as IFilament).id);
};
@@ -34,6 +50,17 @@ export const FilamentCreate: React.FC {
+ extraFields.data?.forEach((field) => {
+ if (formProps.initialValues && field.default_value) {
+ const parsedValue = JSON.parse(field.default_value as string);
+ form.setFieldsValue({ extra: { [field.key]: parsedValue } });
+ }
+ });
+ }, [form, extraFields.data, formProps.initialValues]);
+
return (
+ {t("settings.extra_fields.tab")}
+ {extraFields.data?.map((field, index) => (
+
+ ))}
);
diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx
index 8b44a6f..6acd816 100644
--- a/client/src/pages/filaments/edit.tsx
+++ b/client/src/pages/filaments/edit.tsx
@@ -1,36 +1,65 @@
import React, { useState } from "react";
-import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm, useSelect } from "@refinedev/antd";
-import { Form, Input, DatePicker, Select, InputNumber, ColorPicker, message, Alert } from "antd";
+import { Form, Input, DatePicker, Select, InputNumber, ColorPicker, message, Alert, Typography } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model";
-import { IFilament } from "./model";
+import { IFilament, IFilamentParsedExtras } from "./model";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import { ParsedExtras } from "../../components/extraFields";
+
+/*
+The API returns the extra fields as JSON values, but we need to parse them into their real types
+in order for Ant design's form to work properly. ParsedExtras does this for us.
+We also need to stringify them again before sending them back to the API, which is done by overriding
+the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
+*/
export const FilamentEdit: React.FC = () => {
const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
+ const extraFields = useGetFields(EntityType.filament);
- const { formProps, saveButtonProps } = useForm({
+ const { formProps, saveButtonProps } = useForm({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened
messageApi.warning(t("filament.form.filament_updated"));
setHasChanged(true);
},
+ queryOptions: {
+ select(data) {
+ return {
+ data: ParsedExtras(data.data),
+ };
+ },
+ },
});
+ // Get vendor selection options
const { selectProps } = useSelect({
resource: "vendor",
optionLabel: "name",
});
+ // Add the vendor_id field to the form
if (formProps.initialValues) {
formProps.initialValues["vendor_id"] = formProps.initialValues["vendor"]?.id;
}
+ // Override the form's onFinish method to stringify the extra fields
+ const originalOnFinish = formProps.onFinish;
+ formProps.onFinish = (allValues: IFilamentParsedExtras) => {
+ if (allValues !== undefined && allValues !== null) {
+ allValues = StringifiedExtras(allValues);
+ }
+ originalOnFinish?.(allValues);
+ };
+
return (
{contextHolder}
@@ -246,6 +275,10 @@ export const FilamentEdit: React.FC = () => {
>
+ {t("settings.extra_fields.tab")}
+ {extraFields.data?.map((field, index) => (
+
+ ))}
{hasChanged && }
diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx
index 09b1356..a825e26 100644
--- a/client/src/pages/filaments/list.tsx
+++ b/client/src/pages/filaments/list.tsx
@@ -15,6 +15,7 @@ import {
SortedColumn,
SpoolIconColumn,
ActionsColumn,
+ CustomFieldColumn,
} from "../../components/column";
import {
useSpoolmanArticleNumbers,
@@ -24,6 +25,8 @@ import {
} from "../../components/otherModels";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { useNavigate } from "react-router-dom";
dayjs.extend(utc);
@@ -71,6 +74,10 @@ const defaultColumns = allColumns.filter(
export const FilamentList: React.FC = () => {
const t = useTranslate();
const invalidate = useInvalidate();
+ const navigate = useNavigate();
+ const extraFields = useGetFields(EntityType.filament);
+
+ const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state
const initialState = useInitialTableState(namespace);
@@ -145,6 +152,15 @@ export const FilamentList: React.FC = () => {
{ name: t("buttons.clone"), icon: , link: cloneUrl("filament", record.id) },
];
+ const commonProps = {
+ t,
+ navigate,
+ actions,
+ dataSource,
+ tableState,
+ sorter: true,
+ };
+
return (
(
@@ -163,10 +179,20 @@ export const FilamentList: React.FC = () => {
({
- key: column_id,
- label: t(translateColumnI18nKey(column_id)),
- })),
+ items: allColumnsWithExtraFields.map((column_id) => {
+ if (column_id.indexOf("extra.") === 0) {
+ const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
+ return {
+ key: column_id,
+ label: extraField?.name ?? column_id,
+ };
+ }
+
+ return {
+ key: column_id,
+ label: t(translateColumnI18nKey(column_id)),
+ };
+ }),
selectedKeys: showColumns,
selectable: true,
multiple: true,
@@ -195,129 +221,107 @@ export const FilamentList: React.FC = () => {
rowKey="id"
columns={removeUndefined([
SortedColumn({
+ ...commonProps,
id: "id",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
width: 70,
}),
FilteredQueryColumn({
+ ...commonProps,
id: "vendor.name",
i18nkey: "filament.fields.vendor_name",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanVendors(),
}),
SpoolIconColumn({
+ ...commonProps,
id: "name",
i18ncat: "filament",
color: (record: IFilamentCollapsed) => record.color_hex,
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanFilamentNames(),
}),
FilteredQueryColumn({
+ ...commonProps,
id: "material",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanMaterials(),
width: 110,
}),
SortedColumn({
+ ...commonProps,
id: "price",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
width: 80,
}),
NumberColumn({
+ ...commonProps,
id: "density",
i18ncat: "filament",
unit: "g/cm³",
- decimals: 2,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 2,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "diameter",
i18ncat: "filament",
unit: "mm",
- decimals: 2,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 2,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "weight",
i18ncat: "filament",
unit: "g",
- decimals: 1,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 1,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "spool_weight",
i18ncat: "filament",
unit: "g",
- decimals: 1,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 1,
width: 100,
}),
FilteredQueryColumn({
+ ...commonProps,
id: "article_number",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanArticleNumbers(),
width: 130,
}),
NumberColumn({
+ ...commonProps,
id: "settings_extruder_temp",
i18ncat: "filament",
unit: "°C",
- decimals: 0,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 0,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "settings_bed_temp",
i18ncat: "filament",
unit: "°C",
- decimals: 0,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 0,
width: 100,
}),
DateColumn({
+ ...commonProps,
id: "registered",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
}),
+ ...(extraFields.data?.map((field) => {
+ return CustomFieldColumn({
+ ...commonProps,
+ field,
+ });
+ }) ?? []),
RichColumn({
+ ...commonProps,
id: "comment",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
width: 150,
}),
ActionsColumn(actions),
diff --git a/client/src/pages/filaments/model.tsx b/client/src/pages/filaments/model.tsx
index 4c3207c..20b693f 100644
--- a/client/src/pages/filaments/model.tsx
+++ b/client/src/pages/filaments/model.tsx
@@ -16,4 +16,8 @@ export interface IFilament {
settings_extruder_temp?: number;
settings_bed_temp?: number;
color_hex?: string;
+ extra: { [key: string]: string };
}
+
+// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
+export type IFilamentParsedExtras = Omit & { extra?: { [key: string]: unknown } };
diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx
index d30513d..6c6f002 100644
--- a/client/src/pages/filaments/show.tsx
+++ b/client/src/pages/filaments/show.tsx
@@ -1,13 +1,15 @@
import React from "react";
import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core";
import { Show, NumberField, DateField, TextField } from "@refinedev/antd";
-import { Typography } from "antd";
+import { Button, Typography } from "antd";
import { NumberFieldUnit } from "../../components/numberField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { IFilament } from "./model";
import { enrichText } from "../../utils/parsing";
import { useNavigate } from "react-router-dom";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc);
const { Title } = Typography;
@@ -15,6 +17,7 @@ const { Title } = Typography;
export const FilamentShow: React.FC = () => {
const t = useTranslate();
const navigate = useNavigate();
+ const extraFields = useGetFields(EntityType.filament);
const { queryResult } = useShow({
liveMode: "auto",
});
@@ -45,7 +48,18 @@ export const FilamentShow: React.FC = () => {
};
return (
-
+ (
+ <>
+
+ {t("filament.fields.spools")}
+
+ {defaultButtons}
+ >
+ )}
+ >
{t("filament.fields.id")}
{t("filament.fields.vendor")}
@@ -63,8 +77,6 @@ export const FilamentShow: React.FC = () => {
/>
{t("filament.fields.name")}
- {/* {t("filament.fields.id")}
- {vendorIsLoading ? <>Loading...> : <>{vendorData?.data?.id}>} */}
{t("filament.fields.color_hex")}
{t("filament.fields.material")}
@@ -123,13 +135,10 @@ export const FilamentShow: React.FC = () => {
{t("filament.fields.comment")}
- {t("filament.fields.spools")}
-
- {record ? formatTitle(record) : ""}
-
+ {t("settings.extra_fields.tab")}
+ {extraFields?.data?.map((field, index) => (
+
+ ))}
);
};
diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx
new file mode 100644
index 0000000..7528a8f
--- /dev/null
+++ b/client/src/pages/settings/extraFieldsSettings.tsx
@@ -0,0 +1,636 @@
+import {
+ Button,
+ Checkbox,
+ Flex,
+ Form,
+ FormInstance,
+ Input,
+ InputNumber,
+ Popconfirm,
+ Select,
+ Space,
+ Table,
+ message,
+} from "antd";
+import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields";
+import { useTranslate } from "@refinedev/core";
+import { useState } from "react";
+import { ColumnType } from "antd/es/table";
+import { Trans } from "react-i18next";
+import { useParams } from "react-router-dom";
+import { FormItemProps, Rule } from "antd/es/form";
+import { PlusOutlined } from "@ant-design/icons";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import advancedFormat from "dayjs/plugin/advancedFormat";
+import { DateTimePicker } from "../../components/dateTimePicker";
+import { InputNumberRange } from "../../components/inputNumberRange";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(advancedFormat);
+
+// Localized date time format with timezone
+const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
+
+interface EditableCellProps extends React.HTMLAttributes {
+ record: FieldHolder;
+ editing: boolean;
+ dataIndex: string;
+ form: FormInstance;
+ children: React.ReactNode;
+}
+
+interface FieldHolder {
+ key: string;
+ field: Field;
+ is_new: boolean;
+}
+
+const canEditField = (dataIndex: string, isNew: boolean) => {
+ if (isNew) {
+ return true;
+ }
+ return dataIndex !== "key" && dataIndex !== "field_type" && dataIndex !== "multi_choice";
+};
+
+const EditableCell: React.FC = ({ record, editing, dataIndex, children, form, ...restProps }) => {
+ const t = useTranslate();
+
+ if (!editing || !canEditField(dataIndex, record.is_new)) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ const fieldType = form.getFieldValue("field_type") as FieldType;
+ const choices = form.getFieldValue("choices") as string[];
+
+ const title = t(`settings.extra_fields.params.${dataIndex}`);
+
+ let inputNode;
+ const rules: Rule[] = [];
+ const formItemProps: FormItemProps = {};
+ if (dataIndex === "key") {
+ inputNode = ;
+ rules.push({
+ required: true,
+ min: 1,
+ max: 64,
+ pattern: /^[a-z0-9_]+$/,
+ });
+ rules.push({
+ validator: async (_, value) => {
+ // Ensure key is not new_field
+ if (value === "new_field") {
+ throw new Error(t("settings.extra_fields.key_not_changed"));
+ }
+ },
+ });
+ } else if (dataIndex === "field_type") {
+ inputNode = (
+ {
+ // Reset default_value when changing field_type
+ form.setFieldsValue({
+ default_value: undefined,
+ });
+ }}
+ />
+ );
+ rules.push({
+ required: true,
+ });
+ } else if (dataIndex === "name") {
+ inputNode = ;
+ rules.push({
+ required: true,
+ min: 1,
+ max: 128,
+ });
+ } else if (dataIndex === "order") {
+ inputNode = ;
+ rules.push({
+ required: true,
+ min: 0,
+ type: "integer",
+ });
+ } else if (dataIndex === "unit") {
+ if (
+ fieldType === FieldType.integer ||
+ fieldType === FieldType.integer_range ||
+ fieldType === FieldType.float ||
+ fieldType === FieldType.float_range
+ ) {
+ inputNode = ;
+ } else {
+ inputNode = null;
+ }
+ rules.push({
+ required: false,
+ max: 16,
+ });
+ } else if (dataIndex === "default_value") {
+ if (fieldType === FieldType.boolean) {
+ inputNode = ;
+ formItemProps.valuePropName = "checked";
+ rules.push({
+ type: "boolean",
+ });
+ } else if (fieldType === FieldType.text) {
+ inputNode = ;
+ rules.push({
+ type: "string",
+ });
+ } else if (fieldType === FieldType.integer) {
+ inputNode = ;
+ rules.push({
+ type: "integer",
+ });
+ } else if (fieldType === FieldType.float) {
+ inputNode = ;
+ rules.push({
+ type: "number",
+ });
+ } else if (fieldType === FieldType.integer_range) {
+ inputNode = ;
+ rules.push({
+ type: "array",
+ len: 2,
+ });
+ } else if (fieldType === FieldType.float_range) {
+ inputNode = ;
+ rules.push({
+ type: "array",
+ len: 2,
+ });
+ } else if (fieldType === FieldType.datetime) {
+ inputNode = ;
+ } else if (fieldType === FieldType.choice) {
+ inputNode = (
+ ({ label: choice, value: choice }))}
+ />
+ );
+ rules.push({
+ type: form.getFieldValue("multi_choice") ? "array" : "string",
+ });
+ }
+ } else if (dataIndex === "choices") {
+ if (fieldType === FieldType.choice) {
+ inputNode = ;
+ rules.push({
+ required: true,
+ min: 1,
+ type: "array",
+ validator: async (_, value) => {
+ // Verify that all values in record.choices are in value
+ const recordChoices = record.field.choices || [];
+ const valueChoices = value || [];
+ const missingChoices = recordChoices.filter((choice) => !valueChoices.includes(choice));
+ if (missingChoices.length > 0) {
+ throw new Error(
+ t("settings.extra_fields.choices_missing_error", {
+ choices: missingChoices.join(", "),
+ })
+ );
+ }
+ },
+ });
+ } else {
+ inputNode = null;
+ }
+ } else if (dataIndex === "multi_choice") {
+ if (fieldType === FieldType.choice) {
+ inputNode = (
+ {
+ // Reset default_value when changing multi_choice
+ form.setFieldsValue({
+ default_value: undefined,
+ });
+ }}
+ >
+ {title}
+
+ );
+ formItemProps.valuePropName = "checked";
+ rules.push({
+ type: "boolean",
+ });
+ } else {
+ inputNode = null;
+ }
+ } else {
+ inputNode = null;
+ }
+
+ const formItem = inputNode ? (
+
+ {inputNode}
+
+ ) : null;
+
+ return {formItem} ;
+};
+
+export function ExtraFieldsSettings() {
+ const { entityType } = useParams<{ entityType: EntityType }>();
+ const t = useTranslate();
+ const [form] = Form.useForm();
+ const fields = useGetFields(entityType as EntityType);
+ const setField = useSetField(entityType as EntityType);
+ const deleteField = useDeleteField(entityType as EntityType);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [newField, setNewField] = useState(null);
+
+ const [messageApi, contextHolder] = message.useMessage();
+
+ const [editingKey, setEditingKey] = useState("");
+
+ const isEditing = (record: FieldHolder) => record.field.key === editingKey;
+
+ const edit = (record: Partial & { key: React.Key }) => {
+ const values = { ...record };
+
+ if (values.default_value && typeof values.default_value === "string") {
+ const def = JSON.parse(values.default_value);
+ if (values.field_type === FieldType.boolean) {
+ values.default_value = def ? true : false;
+ } else {
+ values.default_value = def;
+ }
+ } else {
+ values.default_value = undefined;
+ }
+ form.setFieldsValue(values);
+ setEditingKey(record.key);
+ };
+
+ const cancel = () => {
+ setEditingKey("");
+ setNewField(null);
+ };
+
+ const del = async (field: Field) => {
+ try {
+ await deleteField.mutateAsync(field.key);
+ } catch (errInfo) {
+ if (errInfo instanceof Error) {
+ messageApi.error(errInfo.message);
+ }
+ }
+ };
+
+ const addNewField = () => {
+ // Calculate new order by getting the highest order and adding 1
+ const newOrder = Math.max(...(fields.data?.map((field) => field.order) || []), 0) + 1;
+
+ const newFieldData: Field = {
+ key: "new_field",
+ name: "",
+ entity_type: entityType as EntityType,
+ field_type: FieldType.text,
+ unit: "",
+ order: newOrder,
+ default_value: "",
+ choices: [],
+ multi_choice: false,
+ };
+
+ setNewField({
+ key: "new_field",
+ field: newFieldData,
+ is_new: true,
+ });
+ form.setFieldsValue(newFieldData);
+ setEditingKey("new_field");
+ };
+
+ const save = async (record: FieldHolder) => {
+ let row;
+ try {
+ row = (await form.validateFields()) as Field;
+ } catch (errInfo) {
+ // Ignore these errors because they are already handled by the form
+ return;
+ }
+
+ const updatedField = {
+ ...record.field,
+ ...row,
+ };
+
+ // Do some value conversions
+ try {
+ // Convert float and integer range to array using the validation regex
+ if (
+ (updatedField.field_type === FieldType.float_range || updatedField.field_type === FieldType.integer_range) &&
+ updatedField.default_value !== undefined
+ ) {
+ if (Array.isArray(updatedField.default_value)) {
+ const val1 = updatedField.default_value[0];
+ const val2 = updatedField.default_value[1];
+ updatedField.default_value = JSON.stringify([val1, val2]);
+ } else {
+ console.warn("Invalid default_value for range", updatedField.default_value);
+ }
+ } else {
+ // Just stringify all other values
+ updatedField.default_value = JSON.stringify(updatedField.default_value);
+ }
+
+ // Set multi_choice if it's not set and field_type is choice
+ if (updatedField.field_type === FieldType.choice && updatedField.multi_choice === undefined) {
+ updatedField.multi_choice = false;
+ }
+
+ // If it's not choice, remove choices and multi_choice
+ if (updatedField.field_type !== FieldType.choice) {
+ updatedField.choices = undefined;
+ updatedField.multi_choice = undefined;
+ }
+
+ // If unit is an empty string, set it to null instead
+ if (updatedField.unit === "") {
+ updatedField.unit = undefined;
+ }
+ } catch (errInfo) {
+ if (errInfo instanceof Error) {
+ messageApi.error(t("notifications.validationError", { error: errInfo.message }));
+ // Check if errInfo has the errorFields property, then we should skip the error message
+ }
+ return;
+ }
+
+ // Validate that updatedField.key is unique and not among the other keys
+ if (record.is_new) {
+ const keys = new Set(fields.data?.map((field) => field.key) || []);
+ if (keys.has(updatedField.key)) {
+ messageApi.error(t("settings.extra_fields.non_unique_key_error"));
+ return;
+ }
+ }
+
+ // Submit it!
+ try {
+ setIsSubmitting(true);
+
+ setField.reset();
+
+ await setField.mutateAsync({
+ key: updatedField.key,
+ params: updatedField,
+ });
+
+ setEditingKey("");
+ setNewField(null);
+ } catch (errInfo) {
+ if (errInfo instanceof Error) {
+ messageApi.error(errInfo.message);
+ }
+ }
+ setIsSubmitting(false);
+ };
+
+ const niceName = t(`${entityType}.${entityType}`);
+
+ const columns: ColumnType[] = [
+ {
+ title: t("settings.extra_fields.params.key"),
+ dataIndex: ["field", "key"],
+ key: "key",
+ width: "10%",
+ },
+ {
+ title: t("settings.extra_fields.params.order"),
+ dataIndex: ["field", "order"],
+ key: "order",
+ width: "3%",
+ },
+ {
+ title: t("settings.extra_fields.params.name"),
+ dataIndex: ["field", "name"],
+ },
+ {
+ title: t("settings.extra_fields.params.field_type"),
+ dataIndex: ["field", "field_type"],
+ render(value) {
+ return t(`settings.extra_fields.field_type.${value}`);
+ },
+ width: "15%",
+ },
+ {
+ title: t("settings.extra_fields.params.unit"),
+ dataIndex: ["field", "unit"],
+ width: "6%",
+ },
+ {
+ title: t("settings.extra_fields.params.default_value"),
+ dataIndex: ["field", "default_value"],
+ render(value, record) {
+ const val = JSON.parse(value || "null");
+ if (typeof val === "boolean") {
+ return val ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false");
+ } else if (typeof val === "string" && record.field.field_type === FieldType.datetime) {
+ return dayjs(val).format(dateTimeFormat);
+ } else if (typeof val === "number" || typeof val === "string") {
+ return val;
+ } else if (Array.isArray(val) && record.field.field_type === FieldType.choice) {
+ return val.join(", ");
+ } else if (
+ (Array.isArray(val) && record.field.field_type === FieldType.integer_range) ||
+ record.field.field_type === FieldType.float_range
+ ) {
+ return `${val[0]} \u2013 ${val[1]}`;
+ } else {
+ return null;
+ }
+ },
+ width: "15%",
+ },
+ {
+ title: t("settings.extra_fields.params.choices"),
+ dataIndex: ["field", "choices"],
+ render(value, record) {
+ if (record.field.field_type === FieldType.choice && Array.isArray(value)) {
+ return value.join(", ");
+ } else {
+ return null;
+ }
+ },
+ width: "15%",
+ },
+ {
+ title: t("settings.extra_fields.params.multi_choice"),
+ dataIndex: ["field", "multi_choice"],
+ render(value, record) {
+ if (record.field.field_type === FieldType.choice) {
+ return value ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false");
+ } else {
+ return null;
+ }
+ },
+ width: "10%",
+ },
+ {
+ title: "",
+ dataIndex: "operation",
+ render: (_: unknown, record: FieldHolder) => {
+ const editing = isEditing(record);
+ return editing ? (
+
+ save(record)} size="small" type="primary">
+ {t("buttons.save")}
+
+ cancel()} size="small">
+ {t("buttons.cancel")}
+
+
+ ) : (
+ <>
+
+ edit(record.field)} size="small">
+ {t("buttons.edit")}
+
+ del(record.field)}
+ disabled={editingKey !== ""}
+ okText={t("buttons.delete")}
+ cancelText={t("buttons.cancel")}
+ >
+
+ {t("buttons.delete")}
+
+
+
+ >
+ );
+ },
+ width: "10%",
+ },
+ ];
+
+ const mergedColumns = columns.map((col) => {
+ if (col.dataIndex === "operation") {
+ return col;
+ }
+ return {
+ ...col,
+ onCell: function (record: FieldHolder): EditableCellProps {
+ return {
+ record,
+ editing: isEditing(record),
+ dataIndex: (col.dataIndex?.toString() || "").split(",").pop() || "",
+ form: form,
+ children: [],
+ };
+ },
+ };
+ });
+
+ const tableFields: FieldHolder[] = [
+ ...(fields.data || [])
+ .sort((a, b) => a.order - b.order)
+ .map((field) => ({
+ key: field.key,
+ field,
+ is_new: false,
+ })),
+ ...(newField ? [newField] : []),
+ ];
+
+ return (
+ <>
+
+ {t("settings.extra_fields.tab")} - {niceName}
+
+ ,
+ }}
+ />
+
+ {newField == null && (
+
+ }
+ size="large"
+ style={{
+ margin: "1em",
+ }}
+ onClick={() => addNewField()}
+ />
+
+ )}
+ {contextHolder}
+ >
+ );
+}
diff --git a/client/src/pages/settings/generalSettings.tsx b/client/src/pages/settings/generalSettings.tsx
new file mode 100644
index 0000000..c8c2f42
--- /dev/null
+++ b/client/src/pages/settings/generalSettings.tsx
@@ -0,0 +1,79 @@
+import React from "react";
+import { useTranslate } from "@refinedev/core";
+import { Button, Form, Input, message } from "antd";
+import { useGetSettings, useSetSetting } from "../../utils/querySettings";
+
+export function GeneralSettings() {
+ const settings = useGetSettings();
+ const setSetting = useSetSetting();
+ const [form] = Form.useForm();
+ const [messageApi, contextHolder] = message.useMessage();
+ const t = useTranslate();
+
+ // Set initial form values
+ React.useEffect(() => {
+ if (settings.data) {
+ form.setFieldsValue({
+ currency: JSON.parse(settings.data.currency.value),
+ });
+ }
+ }, [settings.data, form]);
+
+ // Popup message if setSetting is successful
+ React.useEffect(() => {
+ if (setSetting.isSuccess) {
+ messageApi.success(t("notifications.saveSuccessful"));
+ }
+ }, [setSetting.isSuccess, messageApi, t]);
+
+ // Handle form submit
+ const onFinish = (values: { currency: string }) => {
+ // Check if the currency has changed
+ if (settings.data?.currency.value !== JSON.stringify(values.currency)) {
+ setSetting.mutate({
+ key: "currency",
+ value: JSON.stringify(values.currency),
+ });
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {t("buttons.save")}
+
+
+
+ {contextHolder}
+ >
+ );
+}
diff --git a/client/src/pages/settings/index.tsx b/client/src/pages/settings/index.tsx
new file mode 100644
index 0000000..70a5435
--- /dev/null
+++ b/client/src/pages/settings/index.tsx
@@ -0,0 +1,99 @@
+import React from "react";
+import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import { Content } from "antd/es/layout/layout";
+import { Menu, theme } from "antd";
+import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
+import { GeneralSettings } from "./generalSettings";
+import { ExtraFieldsSettings } from "./extraFieldsSettings";
+import { Route, Routes, useNavigate } from "react-router-dom";
+
+dayjs.extend(utc);
+
+const { useToken } = theme;
+
+export const Settings: React.FC = () => {
+ const { token } = useToken();
+ const t = useTranslate();
+ const navigate = useNavigate();
+
+ const getCurrentKey = () => {
+ const path = window.location.pathname.replace("/settings", "");
+ // Remove starting slash and ending slash if exists and return
+ return path.replace(/^\/|\/$/g, "");
+ };
+
+ return (
+ <>
+
+ {t("settings.header")}
+
+
+ {
+ if (e.key === "") {
+ return navigate("/settings");
+ } else {
+ return navigate(`/settings/${e.key}`);
+ }
+ }}
+ items={[
+ { key: "", label: t("settings.general.tab"), icon: },
+ {
+ key: "extra",
+ label: t("settings.extra_fields.tab"),
+ icon: ,
+ children: [
+ {
+ label: t("spool.spool"),
+ key: "extra/spool",
+ icon: ,
+ },
+ {
+ label: t("filament.filament"),
+ key: "extra/filament",
+ icon: ,
+ },
+ {
+ label: t("vendor.vendor"),
+ key: "extra/vendor",
+ icon: ,
+ },
+ ],
+ },
+ ]}
+ style={{
+ marginBottom: "1em",
+ }}
+ />
+
+
+ } />
+ } />
+
+
+
+ >
+ );
+};
+
+export default Settings;
diff --git a/client/src/pages/spools/create.tsx b/client/src/pages/spools/create.tsx
index d33d6ff..830b41a 100644
--- a/client/src/pages/spools/create.tsx
+++ b/client/src/pages/spools/create.tsx
@@ -1,15 +1,20 @@
import React, { useState } from "react";
-import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd";
-import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Button } from "antd";
+import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Button, Typography } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model";
-import { ISpool } from "./model";
+import { ISpool, ISpoolParsedExtras } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import "../../utils/overrides.css";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import utc from "dayjs/plugin/utc";
+
+dayjs.extend(utc);
interface CreateOrCloneProps {
mode: "create" | "clone";
@@ -17,13 +22,22 @@ interface CreateOrCloneProps {
export const SpoolCreate: React.FC = (props) => {
const t = useTranslate();
+ const extraFields = useGetFields(EntityType.spool);
- const { form, formProps, formLoading, onFinish, redirect } = useForm({
+ const { form, formProps, formLoading, onFinish, redirect } = useForm<
+ ISpool,
+ HttpError,
+ ISpoolParsedExtras,
+ ISpoolParsedExtras
+ >({
redirect: false,
warnWhenUnsavedChanges: false,
});
+ if (!formProps.initialValues) {
+ formProps.initialValues = {};
+ }
- if (props.mode === "clone" && formProps.initialValues) {
+ if (props.mode === "clone") {
// Clear out the values that we don't want to clone
formProps.initialValues.first_used = null;
formProps.initialValues.last_used = null;
@@ -34,7 +48,7 @@ export const SpoolCreate: React.FC {
- const values = await form.validateFields();
+ const values = StringifiedExtras(await form.validateFields());
if (quantity > 1) {
const submit = Array(quantity).fill(values);
// queue multiple creates this way for now Refine doesn't seem to map Arrays to createMany or multiple creates like it says it does
@@ -49,6 +63,17 @@ export const SpoolCreate: React.FC {
+ extraFields.data?.forEach((field) => {
+ if (formProps.initialValues && field.default_value) {
+ const parsedValue = JSON.parse(field.default_value as string);
+ form.setFieldsValue({ extra: { [field.key]: parsedValue } });
+ }
+ });
+ }, [form, extraFields.data, formProps.initialValues]);
+
const filamentOptions = queryResult.data?.data.map((item) => {
let vendorPrefix = "";
if (item.vendor) {
@@ -213,7 +238,7 @@ export const SpoolCreate: React.FC
-
+
@@ -336,6 +361,10 @@ export const SpoolCreate: React.FC
+ {t("settings.extra_fields.tab")}
+ {extraFields.data?.map((field, index) => (
+
+ ))}
);
diff --git a/client/src/pages/spools/edit.tsx b/client/src/pages/spools/edit.tsx
index 86b84ce..966b02b 100644
--- a/client/src/pages/spools/edit.tsx
+++ b/client/src/pages/spools/edit.tsx
@@ -1,33 +1,61 @@
import React, { useEffect, useState } from "react";
-import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm, useSelect } from "@refinedev/antd";
-import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert } from "antd";
+import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert, Typography } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model";
-import { ISpool } from "./model";
+import { ISpool, ISpoolParsedExtras } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels";
import { message } from "antd/lib";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import { ParsedExtras } from "../../components/extraFields";
+
+/*
+The API returns the extra fields as JSON values, but we need to parse them into their real types
+in order for Ant design's form to work properly. ParsedExtras does this for us.
+We also need to stringify them again before sending them back to the API, which is done by overriding
+the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
+*/
export const SpoolEdit: React.FC = () => {
const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
+ const extraFields = useGetFields(EntityType.spool);
- const { form, formProps, saveButtonProps } = useForm({
+ const { form, formProps, saveButtonProps } = useForm({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the spool has been updated since the form was opened
messageApi.warning(t("spool.form.spool_updated"));
setHasChanged(true);
},
+ queryOptions: {
+ select(data) {
+ return {
+ data: ParsedExtras(data.data),
+ };
+ },
+ },
});
+ // Get filament selection options
const { queryResult } = useSelect({
resource: "filament",
});
+ // Override the form's onFinish method to stringify the extra fields
+ const originalOnFinish = formProps.onFinish;
+ formProps.onFinish = (allValues: ISpoolParsedExtras) => {
+ if (allValues !== undefined && allValues !== null) {
+ allValues = StringifiedExtras(allValues);
+ }
+ originalOnFinish?.(allValues);
+ };
+
const filamentOptions = queryResult.data?.data.map((item) => {
let vendorPrefix = "";
if (item.vendor) {
@@ -306,6 +334,10 @@ export const SpoolEdit: React.FC = () => {
>
+ {t("settings.extra_fields.tab")}
+ {extraFields.data?.map((field, index) => (
+
+ ))}
{hasChanged && }
diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx
index 714a6f8..a81a661 100644
--- a/client/src/pages/spools/list.tsx
+++ b/client/src/pages/spools/list.tsx
@@ -23,6 +23,7 @@ import {
SpoolIconColumn,
Action,
ActionsColumn,
+ CustomFieldColumn,
} from "../../components/column";
import { setSpoolArchived } from "./functions";
import SelectAndPrint from "../../components/selectAndPrintDialog";
@@ -34,6 +35,8 @@ import {
} from "../../components/otherModels";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { useNavigate } from "react-router-dom";
dayjs.extend(utc);
@@ -52,7 +55,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
} else {
filament_name = element.filament.name ?? element.filament.id.toString();
}
- if (!element.price){
+ if (!element.price) {
element.price = element.filament.price;
}
return {
@@ -95,6 +98,10 @@ const defaultColumns = allColumns.filter(
export const SpoolList: React.FC = () => {
const t = useTranslate();
const invalidate = useInvalidate();
+ const navigate = useNavigate();
+ const extraFields = useGetFields(EntityType.spool);
+
+ const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state
const initialState = useInitialTableState(namespace);
@@ -217,6 +224,15 @@ export const SpoolList: React.FC = () => {
return actions;
};
+ const commonProps = {
+ t,
+ navigate,
+ actions,
+ dataSource,
+ tableState,
+ sorter: true,
+ };
+
return (
(
@@ -245,10 +261,20 @@ export const SpoolList: React.FC = () => {
({
- key: column_id,
- label: t(translateColumnI18nKey(column_id)),
- })),
+ items: allColumnsWithExtraFields.map((column_id) => {
+ if (column_id.indexOf("extra.") === 0) {
+ const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
+ return {
+ key: column_id,
+ label: extraField?.name ?? column_id,
+ };
+ }
+
+ return {
+ key: column_id,
+ label: t(translateColumnI18nKey(column_id)),
+ };
+ }),
selectedKeys: showColumns,
selectable: true,
multiple: true,
@@ -290,127 +316,105 @@ export const SpoolList: React.FC = () => {
}}
columns={removeUndefined([
SortedColumn({
+ ...commonProps,
id: "id",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
width: 70,
}),
SpoolIconColumn({
+ ...commonProps,
id: "combined_name",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex,
- actions,
- dataSource,
- tableState,
dataId: "filament.id",
filterValueQuery: useSpoolmanFilamentFilter(),
}),
FilteredQueryColumn({
+ ...commonProps,
id: "filament.material",
i18nkey: "spool.fields.material",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanMaterials(),
width: 120,
}),
SortedColumn({
+ ...commonProps,
id: "price",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
width: 80,
}),
NumberColumn({
+ ...commonProps,
id: "used_weight",
i18ncat: "spool",
unit: "g",
- decimals: 1,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 1,
width: 110,
}),
NumberColumn({
+ ...commonProps,
id: "remaining_weight",
i18ncat: "spool",
unit: "g",
- decimals: 1,
+ maxDecimals: 1,
defaultText: t("unknown"),
- actions,
- dataSource,
- tableState,
width: 110,
}),
NumberColumn({
+ ...commonProps,
id: "used_length",
i18ncat: "spool",
unit: "mm",
- decimals: 1,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 1,
width: 120,
}),
NumberColumn({
+ ...commonProps,
id: "remaining_length",
i18ncat: "spool",
unit: "mm",
- decimals: 1,
+ maxDecimals: 1,
defaultText: t("unknown"),
- actions,
- dataSource,
- tableState,
width: 120,
}),
FilteredQueryColumn({
+ ...commonProps,
id: "location",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanLocations(),
width: 120,
}),
FilteredQueryColumn({
+ ...commonProps,
id: "lot_nr",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanLotNumbers(),
width: 120,
}),
DateColumn({
+ ...commonProps,
id: "first_used",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
}),
DateColumn({
+ ...commonProps,
id: "last_used",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
}),
DateColumn({
+ ...commonProps,
id: "registered",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
}),
+ ...(extraFields.data?.map((field) => {
+ return CustomFieldColumn({
+ ...commonProps,
+ field,
+ });
+ }) ?? []),
RichColumn({
+ ...commonProps,
id: "comment",
i18ncat: "spool",
- actions,
- dataSource,
- tableState,
width: 150,
}),
ActionsColumn(actions),
diff --git a/client/src/pages/spools/model.tsx b/client/src/pages/spools/model.tsx
index f365d03..a98a49f 100644
--- a/client/src/pages/spools/model.tsx
+++ b/client/src/pages/spools/model.tsx
@@ -15,4 +15,8 @@ export interface ISpool {
lot_nr?: string;
comment?: string;
archived: boolean;
+ extra: { [key: string]: string };
}
+
+// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
+export type ISpoolParsedExtras = Omit & { extra?: { [key: string]: unknown } };
diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx
index f068b00..b68d172 100644
--- a/client/src/pages/spools/show.tsx
+++ b/client/src/pages/spools/show.tsx
@@ -8,6 +8,8 @@ import utc from "dayjs/plugin/utc";
import { ISpool } from "./model";
import { enrichText } from "../../utils/parsing";
import { IFilament } from "../filaments/model";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc);
@@ -15,6 +17,7 @@ const { Title } = Typography;
export const SpoolShow: React.FC = () => {
const t = useTranslate();
+ const extraFields = useGetFields(EntityType.spool);
const { queryResult } = useShow({
liveMode: "auto",
@@ -25,12 +28,12 @@ export const SpoolShow: React.FC = () => {
const spoolPrice = (item: ISpool) => {
let spoolPrice = "";
- if (!item.price){
+ if (!item.price) {
spoolPrice = `${item.filament.price}`;
return spoolPrice;
}
return item.price;
- }
+ };
const formatFilament = (item: IFilament) => {
let vendorPrefix = "";
@@ -133,6 +136,10 @@ export const SpoolShow: React.FC = () => {
{t("spool.fields.archived")}
+ {t("settings.extra_fields.tab")}
+ {extraFields?.data?.map((field, index) => (
+
+ ))}
);
};
diff --git a/client/src/pages/vendors/create.tsx b/client/src/pages/vendors/create.tsx
index 4ea110a..9649ccd 100644
--- a/client/src/pages/vendors/create.tsx
+++ b/client/src/pages/vendors/create.tsx
@@ -1,9 +1,15 @@
import React from "react";
-import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd";
-import { Button, Form, Input } from "antd";
+import { Button, Form, Input, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
-import { IVendor } from "./model";
+import { IVendor, IVendorParsedExtras } from "./model";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+
+dayjs.extend(utc);
interface CreateOrCloneProps {
mode: "create" | "clone";
@@ -11,15 +17,36 @@ interface CreateOrCloneProps {
export const VendorCreate: React.FC = (props) => {
const t = useTranslate();
+ const extraFields = useGetFields(EntityType.vendor);
- const { form, formProps, formLoading, onFinish, redirect } = useForm();
+ const { form, formProps, formLoading, onFinish, redirect } = useForm<
+ IVendor,
+ HttpError,
+ IVendorParsedExtras,
+ IVendorParsedExtras
+ >();
+
+ if (!formProps.initialValues) {
+ formProps.initialValues = {};
+ }
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
- const values = await form.validateFields();
+ const values = StringifiedExtras(await form.validateFields());
await onFinish(values);
redirect(redirectTo, (values as IVendor).id);
};
+ // Use useEffect to update the form's initialValues when the extra fields are loaded
+ // This is necessary because the form is rendered before the extra fields are loaded
+ React.useEffect(() => {
+ extraFields.data?.forEach((field) => {
+ if (formProps.initialValues && field.default_value) {
+ const parsedValue = JSON.parse(field.default_value as string);
+ form.setFieldsValue({ extra: { [field.key]: parsedValue } });
+ }
+ });
+ }, [form, extraFields.data, formProps.initialValues]);
+
return (
+ {t("settings.extra_fields.tab")}
+ {extraFields.data?.map((field, index) => (
+
+ ))}
);
diff --git a/client/src/pages/vendors/edit.tsx b/client/src/pages/vendors/edit.tsx
index 8f84548..b6f3d78 100644
--- a/client/src/pages/vendors/edit.tsx
+++ b/client/src/pages/vendors/edit.tsx
@@ -1,25 +1,52 @@
import React, { useState } from "react";
-import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
-import { Form, Input, DatePicker, message, Alert } from "antd";
+import { Form, Input, DatePicker, message, Alert, Typography } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
-import { IVendor } from "./model";
+import { IVendor, IVendorParsedExtras } from "./model";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import { ParsedExtras } from "../../components/extraFields";
+
+/*
+The API returns the extra fields as JSON values, but we need to parse them into their real types
+in order for Ant design's form to work properly. ParsedExtras does this for us.
+We also need to stringify them again before sending them back to the API, which is done by overriding
+the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
+*/
export const VendorEdit: React.FC = () => {
const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
+ const extraFields = useGetFields(EntityType.vendor);
- const { formProps, saveButtonProps } = useForm({
+ const { formProps, saveButtonProps } = useForm({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the vendor has been updated since the form was opened
messageApi.warning(t("vendor.form.vendor_updated"));
setHasChanged(true);
},
+ queryOptions: {
+ select(data) {
+ return {
+ data: ParsedExtras(data.data),
+ };
+ },
+ },
});
+ // Override the form's onFinish method to stringify the extra fields
+ const originalOnFinish = formProps.onFinish;
+ formProps.onFinish = (allValues: IVendorParsedExtras) => {
+ if (allValues !== undefined && allValues !== null) {
+ allValues = StringifiedExtras(allValues);
+ }
+ originalOnFinish?.(allValues);
+ };
+
return (
{contextHolder}
@@ -71,6 +98,10 @@ export const VendorEdit: React.FC = () => {
>
+ {t("settings.extra_fields.tab")}
+ {extraFields.data?.map((field, index) => (
+
+ ))}
{hasChanged && }
diff --git a/client/src/pages/vendors/list.tsx b/client/src/pages/vendors/list.tsx
index e8615c2..a79e16d 100644
--- a/client/src/pages/vendors/list.tsx
+++ b/client/src/pages/vendors/list.tsx
@@ -7,9 +7,11 @@ 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 } from "../../components/column";
+import { DateColumn, RichColumn, SortedColumn, ActionsColumn, CustomFieldColumn } from "../../components/column";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { useNavigate } from "react-router-dom";
dayjs.extend(utc);
@@ -20,6 +22,10 @@ const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "com
export const VendorList: React.FC = () => {
const t = useTranslate();
const invalidate = useInvalidate();
+ const navigate = useNavigate();
+ const extraFields = useGetFields(EntityType.vendor);
+
+ const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state
const initialState = useInitialTableState(namespace);
@@ -82,6 +88,15 @@ export const VendorList: React.FC = () => {
{ name: t("buttons.clone"), icon: , link: cloneUrl("vendor", record.id) },
];
+ const commonProps = {
+ t,
+ navigate,
+ actions,
+ dataSource,
+ tableState,
+ sorter: true,
+ };
+
return (
(
@@ -100,10 +115,20 @@ export const VendorList: React.FC = () => {
({
- key: column,
- label: t(`vendor.fields.${column}`),
- })),
+ items: allColumnsWithExtraFields.map((column_id) => {
+ if (column_id.indexOf("extra.") === 0) {
+ const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
+ return {
+ key: column_id,
+ label: extraField?.name ?? column_id,
+ };
+ }
+
+ return {
+ key: column_id,
+ label: t(`vendor.fields.${column_id}`),
+ };
+ }),
selectedKeys: showColumns,
selectable: true,
multiple: true,
@@ -132,33 +157,31 @@ export const VendorList: React.FC = () => {
rowKey="id"
columns={removeUndefined([
SortedColumn({
+ ...commonProps,
id: "id",
i18ncat: "vendor",
- actions,
- dataSource,
- tableState,
width: 70,
}),
SortedColumn({
+ ...commonProps,
id: "name",
i18ncat: "vendor",
- actions,
- dataSource,
- tableState,
}),
DateColumn({
+ ...commonProps,
id: "registered",
i18ncat: "vendor",
- actions,
- dataSource,
- tableState,
}),
+ ...(extraFields.data?.map((field) => {
+ return CustomFieldColumn({
+ ...commonProps,
+ field,
+ });
+ }) ?? []),
RichColumn({
+ ...commonProps,
id: "comment",
i18ncat: "vendor",
- actions,
- dataSource,
- tableState,
}),
ActionsColumn(actions),
])}
diff --git a/client/src/pages/vendors/model.tsx b/client/src/pages/vendors/model.tsx
index 59335a9..ac1bf83 100644
--- a/client/src/pages/vendors/model.tsx
+++ b/client/src/pages/vendors/model.tsx
@@ -3,4 +3,8 @@ export interface IVendor {
registered: string;
name: string;
comment?: string;
+ extra: { [key: string]: string };
}
+
+// IVendorParsedExtras is the same as IVendor, but with the extra field parsed into its real types
+export type IVendorParsedExtras = Omit & { extra?: { [key: string]: unknown } };
diff --git a/client/src/pages/vendors/show.tsx b/client/src/pages/vendors/show.tsx
index ac115b8..c161218 100644
--- a/client/src/pages/vendors/show.tsx
+++ b/client/src/pages/vendors/show.tsx
@@ -6,6 +6,8 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { IVendor } from "./model";
import { enrichText } from "../../utils/parsing";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc);
@@ -13,6 +15,7 @@ const { Title } = Typography;
export const VendorShow: React.FC = () => {
const t = useTranslate();
+ const extraFields = useGetFields(EntityType.vendor);
const { queryResult } = useShow({
liveMode: "auto",
@@ -39,6 +42,10 @@ export const VendorShow: React.FC = () => {
{t("vendor.fields.comment")}
+ {t("settings.extra_fields.tab")}
+ {extraFields?.data?.map((field, index) => (
+
+ ))}
);
};
diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts
new file mode 100644
index 0000000..d569278
--- /dev/null
+++ b/client/src/utils/queryFields.ts
@@ -0,0 +1,132 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import dayjs from "dayjs";
+
+export enum FieldType {
+ text = "text",
+ integer = "integer",
+ integer_range = "integer_range",
+ float = "float",
+ float_range = "float_range",
+ datetime = "datetime",
+ boolean = "boolean",
+ choice = "choice",
+}
+
+export enum EntityType {
+ vendor = "vendor",
+ filament = "filament",
+ spool = "spool",
+}
+
+export interface FieldParameters {
+ name: string;
+ order: number;
+ unit?: string;
+ field_type: FieldType;
+ default_value?: string | (number | null)[] | boolean | dayjs.Dayjs;
+ choices?: string[];
+ multi_choice?: boolean;
+}
+
+export interface Field extends FieldParameters {
+ key: string;
+ entity_type: EntityType;
+}
+
+export function useGetFields(entity_type: EntityType) {
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useQuery({
+ queryKey: ["fields", entity_type],
+ queryFn: async () => {
+ const response = await fetch(`${apiEndpoint}/field/${entity_type}`);
+ return response.json();
+ },
+ });
+}
+
+export function useSetField(entity_type: EntityType) {
+ const queryClient = useQueryClient();
+
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useMutation({
+ mutationFn: async ({ key, params }) => {
+ const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(params),
+ });
+
+ // Throw error if response is not ok
+ if (!response.ok) {
+ throw new Error((await response.json()).message);
+ }
+
+ return response.json();
+ },
+ onMutate: async ({ key, params }) => {
+ // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
+ await queryClient.cancelQueries(["fields", entity_type]);
+
+ // Snapshot the previous value
+ const previousFields = queryClient.getQueryData(["fields", entity_type]);
+
+ // Optimistically update to the new value
+ queryClient.setQueryData(["fields", entity_type], (old) => {
+ if (!old) {
+ return [
+ {
+ key: key,
+ entity_type: entity_type,
+ ...params,
+ },
+ ];
+ }
+ return old.map((field) => {
+ if (field.key === key) {
+ return { ...field, ...params };
+ }
+ return field;
+ });
+ });
+
+ // Return a context object with the snapshotted value
+ return { previousFields };
+ },
+ onError: (_err, _newFields, context) => {
+ // Rollback to the previous value
+ if (context?.previousFields) {
+ queryClient.setQueryData(["fields", entity_type], context.previousFields);
+ }
+ },
+ onSettled: () => {
+ // Invalidate and refetch
+ queryClient.invalidateQueries(["fields", entity_type]);
+ },
+ });
+}
+
+export function useDeleteField(entity_type: EntityType) {
+ const queryClient = useQueryClient();
+
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useMutation({
+ mutationFn: async (key) => {
+ const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
+ method: "DELETE",
+ });
+
+ // Throw error if response is not ok
+ if (!response.ok) {
+ throw new Error((await response.json()).message);
+ }
+
+ return response.json();
+ },
+ onSuccess: () => {
+ // Invalidate and refetch
+ queryClient.invalidateQueries(["fields", entity_type]);
+ },
+ });
+}
diff --git a/client/src/utils/querySettings.ts b/client/src/utils/querySettings.ts
new file mode 100644
index 0000000..87b8ade
--- /dev/null
+++ b/client/src/utils/querySettings.ts
@@ -0,0 +1,61 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+
+interface SettingResponseValue {
+ value: string;
+ is_set: boolean;
+ type: string;
+}
+
+interface SettingsResponse {
+ [key: string]: SettingResponseValue;
+}
+
+export function useGetSettings() {
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useQuery({
+ queryKey: ["settings"],
+ queryFn: async () => {
+ const response = await fetch(`${apiEndpoint}/setting`);
+ return response.json();
+ },
+ });
+}
+
+export function useGetSetting(key: string) {
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useQuery({
+ queryKey: ["settings", key],
+ queryFn: async () => {
+ const response = await fetch(`${apiEndpoint}/setting/${key}`);
+ return response.json();
+ },
+ });
+}
+
+export function useSetSetting() {
+ const queryClient = useQueryClient();
+
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useMutation({
+ mutationFn: async ({ key, value }) => {
+ const response = await fetch(`${apiEndpoint}/setting/${key}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(value),
+ });
+
+ // Throw error if response is not ok
+ if (!response.ok) {
+ throw new Error((await response.json()).message);
+ }
+
+ return response.json();
+ },
+ onSuccess: (_data, { key }) => {
+ // Invalidate and refetch
+ queryClient.invalidateQueries(["settings", key]);
+ },
+ });
+}
diff --git a/migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py b/migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py
new file mode 100644
index 0000000..50a2b79
--- /dev/null
+++ b/migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py
@@ -0,0 +1,74 @@
+"""Added extra fields.
+
+Revision ID: b8881bdb716c
+Revises: ccbb17aeda7c
+Create Date: 2024-01-04 22:09:34.417527
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "b8881bdb716c"
+down_revision = "ccbb17aeda7c"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """Perform the upgrade."""
+ op.create_table(
+ "vendor_field",
+ sa.Column("vendor_id", sa.Integer(), nullable=False),
+ sa.Column("key", sa.String(length=64), nullable=False),
+ sa.Column("value", sa.Text(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["vendor_id"],
+ ["vendor.id"],
+ ),
+ sa.PrimaryKeyConstraint("vendor_id", "key"),
+ )
+ op.create_index(op.f("ix_vendor_field_key"), "vendor_field", ["key"], unique=False)
+ op.create_index(op.f("ix_vendor_field_vendor_id"), "vendor_field", ["vendor_id"], unique=False)
+
+ op.create_table(
+ "filament_field",
+ sa.Column("filament_id", sa.Integer(), nullable=False),
+ sa.Column("key", sa.String(length=64), nullable=False),
+ sa.Column("value", sa.Text(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["filament_id"],
+ ["filament.id"],
+ ),
+ sa.PrimaryKeyConstraint("filament_id", "key"),
+ )
+ op.create_index(op.f("ix_filament_field_filament_id"), "filament_field", ["filament_id"], unique=False)
+ op.create_index(op.f("ix_filament_field_key"), "filament_field", ["key"], unique=False)
+
+ op.create_table(
+ "spool_field",
+ sa.Column("spool_id", sa.Integer(), nullable=False),
+ sa.Column("key", sa.String(length=64), nullable=False),
+ sa.Column("value", sa.Text(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["spool_id"],
+ ["spool.id"],
+ ),
+ sa.PrimaryKeyConstraint("spool_id", "key"),
+ )
+ op.create_index(op.f("ix_spool_field_key"), "spool_field", ["key"], unique=False)
+ op.create_index(op.f("ix_spool_field_spool_id"), "spool_field", ["spool_id"], unique=False)
+
+
+def downgrade() -> None:
+ """Perform the downgrade."""
+ op.drop_index(op.f("ix_spool_field_spool_id"), table_name="spool_field")
+ op.drop_index(op.f("ix_spool_field_key"), table_name="spool_field")
+ op.drop_table("spool_field")
+
+ op.drop_index(op.f("ix_filament_field_key"), table_name="filament_field")
+ op.drop_index(op.f("ix_filament_field_filament_id"), table_name="filament_field")
+ op.drop_table("filament_field")
+
+ op.drop_index(op.f("ix_vendor_field_vendor_id"), table_name="vendor_field")
+ op.drop_index(op.f("ix_vendor_field_key"), table_name="vendor_field")
+ op.drop_table("vendor_field")
diff --git a/pyproject.toml b/pyproject.toml
index bdfc553..199998c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -83,6 +83,7 @@ target-version = "py39"
"S101",
"PLR2004",
"D103",
+ "TID252",
]
"migrations/versions/*" = [
"N999",
diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py
new file mode 100644
index 0000000..9efcba7
--- /dev/null
+++ b/spoolman/api/v1/field.py
@@ -0,0 +1,98 @@
+"""Vendor related endpoints."""
+
+import logging
+from typing import Annotated, Union
+
+from fastapi import APIRouter, Depends, Path
+from fastapi.responses import JSONResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from spoolman.api.v1.models import Message
+from spoolman.database.database import get_db_session
+from spoolman.exceptions import ItemNotFoundError
+from spoolman.extra_fields import (
+ EntityType,
+ ExtraField,
+ ExtraFieldParameters,
+ add_or_update_extra_field,
+ delete_extra_field,
+ get_extra_fields,
+)
+
+router = APIRouter(
+ prefix="/field",
+ tags=["field"],
+)
+
+# ruff: noqa: D103,B008
+
+logger = logging.getLogger(__name__)
+
+
+@router.get(
+ "/{entity_type}",
+ name="Get extra fields",
+ description="Get all extra fields for a specific entity type.",
+ response_model_exclude_none=True,
+)
+async def get(
+ db: Annotated[AsyncSession, Depends(get_db_session)],
+ entity_type: Annotated[EntityType, Path(description="Entity type this field is for")],
+) -> list[ExtraField]:
+ return await get_extra_fields(db, entity_type)
+
+
+@router.post(
+ "/{entity_type}/{key}",
+ name="Add or update extra field",
+ description=(
+ "Add or update an extra field for a specific entity type. "
+ "Returns the full list of extra fields for the entity type."
+ ),
+ response_model_exclude_none=True,
+ response_model=list[ExtraField],
+ responses={400: {"model": Message}},
+)
+async def update(
+ db: Annotated[AsyncSession, Depends(get_db_session)],
+ entity_type: Annotated[EntityType, Path(description="Entity type this field is for")],
+ key: Annotated[str, Path(min_length=1, max_length=64, regex="^[a-z0-9_]+$")],
+ body: ExtraFieldParameters,
+) -> Union[list[ExtraField], JSONResponse]:
+ body_with_key = ExtraField.parse_obj(body.copy(update={"key": key, "entity_type": entity_type}))
+
+ try:
+ await add_or_update_extra_field(db, entity_type, body_with_key)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
+ return await get_extra_fields(db, entity_type)
+
+
+@router.delete(
+ "/{entity_type}/{key}",
+ name="Delete extra field",
+ description=(
+ "Delete an extra field for a specific entity type. "
+ "Returns the full list of extra fields for the entity type."
+ ),
+ response_model_exclude_none=True,
+ response_model=list[ExtraField],
+ responses={404: {"model": Message}},
+)
+async def delete(
+ db: Annotated[AsyncSession, Depends(get_db_session)],
+ entity_type: Annotated[EntityType, Path(description="Entity type this field is for")],
+ key: Annotated[str, Path(min_length=1, max_length=64, regex="^[a-z0-9_]+$")],
+) -> Union[list[ExtraField], JSONResponse]:
+ try:
+ await delete_extra_field(db, entity_type, key)
+ except ItemNotFoundError:
+ return JSONResponse(
+ status_code=404,
+ content=Message(
+ message=f"Extra field with key {key} does not exist for entity type {entity_type.name}",
+ ).dict(),
+ )
+
+ return await get_extra_fields(db, entity_type)
diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py
index 451f446..a414a18 100644
--- a/spoolman/api/v1/filament.py
+++ b/spoolman/api/v1/filament.py
@@ -17,6 +17,7 @@ from spoolman.database import filament
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemDeleteError
+from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__)
@@ -81,6 +82,10 @@ class FilamentParameters(BaseModel):
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000",
)
+ extra: Optional[dict[str, str]] = Field(
+ None,
+ description="Extra fields for this filament.",
+ )
@validator("color_hex")
@classmethod
@@ -323,11 +328,20 @@ async def notify(
name="Add filament",
description="Add a new filament to the database.",
response_model_exclude_none=True,
+ response_model=Filament,
+ responses={400: {"model": Message}},
)
-async def create(
+async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
body: FilamentParameters,
-) -> Filament:
+):
+ if body.extra:
+ all_fields = await get_extra_fields(db, EntityType.filament)
+ try:
+ validate_extra_field_dict(all_fields, body.extra)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
db_item = await filament.create(
db=db,
density=body.density,
@@ -343,6 +357,7 @@ async def create(
settings_extruder_temp=body.settings_extruder_temp,
settings_bed_temp=body.settings_bed_temp,
color_hex=body.color_hex,
+ extra=body.extra,
)
return Filament.from_db(db_item)
@@ -351,15 +366,22 @@ async def create(
@router.patch(
"/{filament_id}",
name="Update filament",
- description="Update any attribute of a filament. Only fields specified in the request will be affected.",
+ description=(
+ "Update any attribute of a filament. Only fields specified in the request will be affected. "
+ "If extra is set, all existing extra fields will be removed and replaced with the new ones."
+ ),
response_model_exclude_none=True,
- responses={404: {"model": Message}},
+ response_model=Filament,
+ responses={
+ 400: {"model": Message},
+ 404: {"model": Message},
+ },
)
-async def update(
+async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
filament_id: int,
body: FilamentUpdateParameters,
-) -> Filament:
+):
patch_data = body.dict(exclude_unset=True)
if "density" in patch_data and body.density is None:
@@ -367,6 +389,13 @@ async def update(
if "diameter" in patch_data and body.diameter is None:
raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))])
+ if body.extra:
+ all_fields = await get_extra_fields(db, EntityType.filament)
+ try:
+ validate_extra_field_dict(all_fields, body.extra)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
db_item = await filament.update(
db=db,
filament_id=filament_id,
diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py
index 4288e12..3d36e9b 100644
--- a/spoolman/api/v1/models.py
+++ b/spoolman/api/v1/models.py
@@ -60,6 +60,12 @@ 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="")
+ extra: dict[str, str] = Field(
+ description=(
+ "Extra fields for this vendor. All values are JSON-encoded data. "
+ "Query the /fields endpoint for more details about the fields."
+ ),
+ )
@staticmethod
def from_db(item: models.Vendor) -> "Vendor":
@@ -69,6 +75,7 @@ class Vendor(BaseModel):
registered=item.registered,
name=item.name,
comment=item.comment,
+ extra={field.key: field.value for field in item.extra},
)
@@ -128,6 +135,12 @@ class Filament(BaseModel):
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000",
)
+ extra: dict[str, str] = Field(
+ description=(
+ "Extra fields for this filament. All values are JSON-encoded data. "
+ "Query the /fields endpoint for more details about the fields."
+ ),
+ )
@staticmethod
def from_db(item: models.Filament) -> "Filament":
@@ -148,6 +161,7 @@ class Filament(BaseModel):
settings_extruder_temp=item.settings_extruder_temp,
settings_bed_temp=item.settings_bed_temp,
color_hex=item.color_hex,
+ extra={field.key: field.value for field in item.extra},
)
@@ -198,6 +212,12 @@ class Spool(BaseModel):
example="",
)
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
+ extra: dict[str, str] = Field(
+ description=(
+ "Extra fields for this spool. All values are JSON-encoded data. "
+ "Query the /fields endpoint for more details about the fields."
+ ),
+ )
@staticmethod
def from_db(item: models.Spool) -> "Spool":
@@ -235,6 +255,7 @@ class Spool(BaseModel):
lot_nr=item.lot_nr,
comment=item.comment,
archived=item.archived if item.archived is not None else False,
+ extra={field.key: field.value for field in item.extra},
)
diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py
index 812db8a..fd9949e 100644
--- a/spoolman/api/v1/router.py
+++ b/spoolman/api/v1/router.py
@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
-from . import filament, models, other, setting, spool, vendor
+from . import field, filament, models, other, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -108,4 +108,5 @@ app.include_router(filament.router)
app.include_router(spool.router)
app.include_router(vendor.router)
app.include_router(setting.router)
+app.include_router(field.router)
app.include_router(other.router)
diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py
index 35ec5ea..1391dfd 100644
--- a/spoolman/api/v1/spool.py
+++ b/spoolman/api/v1/spool.py
@@ -18,6 +18,7 @@ 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.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__)
@@ -59,6 +60,10 @@ class SpoolParameters(BaseModel):
example="",
)
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
+ extra: Optional[dict[str, str]] = Field(
+ None,
+ description="Extra fields for this spool.",
+ )
class SpoolUpdateParameters(SpoolParameters):
@@ -337,6 +342,13 @@ async def create( # noqa: ANN201
content={"message": "Only specify either remaining_weight or used_weight."},
)
+ if body.extra:
+ all_fields = await get_extra_fields(db, EntityType.spool)
+ try:
+ validate_extra_field_dict(all_fields, body.extra)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
try:
db_item = await spool.create(
db=db,
@@ -350,6 +362,7 @@ async def create( # noqa: ANN201
lot_nr=body.lot_nr,
comment=body.comment,
archived=body.archived,
+ extra=body.extra,
)
return Spool.from_db(db_item)
except ItemCreateError:
@@ -366,7 +379,8 @@ async def create( # noqa: ANN201
description=(
"Update any attribute of a spool. "
"Only fields specified in the request will be affected. "
- "remaining_weight and used_weight can't be set at the same time."
+ "remaining_weight and used_weight can't be set at the same time. "
+ "If extra is set, all existing extra fields will be removed and replaced with the new ones."
),
response_model_exclude_none=True,
response_model=Spool,
@@ -388,6 +402,13 @@ async def update( # noqa: ANN201
content={"message": "Only specify either remaining_weight or used_weight."},
)
+ if body.extra:
+ all_fields = await get_extra_fields(db, EntityType.spool)
+ try:
+ validate_extra_field_dict(all_fields, body.extra)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
if "filament_id" in patch_data and body.filament_id is None:
raise RequestValidationError(
[ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))],
diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py
index e3b72fe..260e609 100644
--- a/spoolman/api/v1/vendor.py
+++ b/spoolman/api/v1/vendor.py
@@ -15,6 +15,7 @@ from spoolman.api.v1.models import Message, Vendor, VendorEvent
from spoolman.database import vendor
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
+from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager
router = APIRouter(
@@ -32,6 +33,10 @@ class VendorParameters(BaseModel):
description="Free text comment about this vendor.",
example="",
)
+ extra: Optional[dict[str, str]] = Field(
+ None,
+ description="Extra fields for this vendor.",
+ )
class VendorUpdateParameters(VendorParameters):
@@ -167,15 +172,25 @@ async def notify(
name="Add vendor",
description="Add a new vendor to the database.",
response_model_exclude_none=True,
+ response_model=Vendor,
+ responses={400: {"model": Message}},
)
-async def create(
+async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
body: VendorParameters,
-) -> Vendor:
+):
+ if body.extra:
+ all_fields = await get_extra_fields(db, EntityType.vendor)
+ try:
+ validate_extra_field_dict(all_fields, body.extra)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
db_item = await vendor.create(
db=db,
name=body.name,
comment=body.comment,
+ extra=body.extra,
)
return Vendor.from_db(db_item)
@@ -184,20 +199,34 @@ async def create(
@router.patch(
"/{vendor_id}",
name="Update vendor",
- description="Update any attribute of a vendor. Only fields specified in the request will be affected.",
+ description=(
+ "Update any attribute of a vendor. Only fields specified in the request will be affected. "
+ "If extra is set, all existing extra fields will be removed and replaced with the new ones."
+ ),
response_model_exclude_none=True,
- responses={404: {"model": Message}},
+ response_model=Vendor,
+ responses={
+ 400: {"model": Message},
+ 404: {"model": Message},
+ },
)
-async def update(
+async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
vendor_id: int,
body: VendorUpdateParameters,
-) -> Vendor:
+):
patch_data = body.dict(exclude_unset=True)
if "name" in patch_data and body.name is None:
raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))])
+ if body.extra:
+ all_fields = await get_extra_fields(db, EntityType.vendor)
+ try:
+ validate_extra_field_dict(all_fields, body.extra)
+ except ValueError as e:
+ return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
+
db_item = await vendor.update(
db=db,
vendor_id=vendor_id,
diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py
index 05ace5f..a87f27a 100644
--- a/spoolman/database/filament.py
+++ b/spoolman/database/filament.py
@@ -5,6 +5,7 @@ from collections.abc import Sequence
from datetime import datetime
from typing import Optional, Union
+import sqlalchemy
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,6 +42,7 @@ async def create(
settings_extruder_temp: Optional[int] = None,
settings_bed_temp: Optional[int] = None,
color_hex: Optional[str] = None,
+ extra: Optional[dict[str, str]] = None,
) -> models.Filament:
"""Add a new filament to the database."""
vendor_item: Optional[models.Vendor] = None
@@ -62,6 +64,7 @@ async def create(
settings_extruder_temp=settings_extruder_temp,
settings_bed_temp=settings_bed_temp,
color_hex=color_hex,
+ extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()],
)
db.add(filament)
await db.commit()
@@ -131,7 +134,7 @@ async def find(
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt)
- result = list(rows.scalars().all())
+ result = list(rows.unique().scalars().all())
if total_count is None:
total_count = len(result)
@@ -152,6 +155,8 @@ async def update(
filament.vendor = None
else:
filament.vendor = await vendor.get_by_id(db, v)
+ elif k == "extra":
+ filament.extra = [models.FilamentField(key=k, value=v) for k, v in v.items()]
else:
setattr(filament, k, v)
await db.commit()
@@ -171,6 +176,13 @@ async def delete(db: AsyncSession, filament_id: int) -> None:
raise ItemDeleteError("Failed to delete filament.") from exc
+async def clear_extra_field(db: AsyncSession, key: str) -> None:
+ """Delete all extra fields with a specific key."""
+ await db.execute(
+ sqlalchemy.delete(models.FilamentField).where(models.FilamentField.key == key),
+ )
+
+
logger = logging.getLogger(__name__)
diff --git a/spoolman/database/models.py b/spoolman/database/models.py
index 685deb0..f06ac6f 100644
--- a/spoolman/database/models.py
+++ b/spoolman/database/models.py
@@ -4,10 +4,11 @@ from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text
+from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
-class Base(DeclarativeBase):
+class Base(AsyncAttrs, DeclarativeBase):
pass
@@ -19,6 +20,11 @@ class Vendor(Base):
name: Mapped[str] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
+ extra: Mapped[list["VendorField"]] = relationship(
+ back_populates="vendor",
+ cascade="save-update, merge, delete, delete-orphan",
+ lazy="joined",
+ )
class Filament(Base):
@@ -41,6 +47,11 @@ class Filament(Base):
settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.")
settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.")
color_hex: Mapped[Optional[str]] = mapped_column(String(8))
+ extra: Mapped[list["FilamentField"]] = relationship(
+ back_populates="filament",
+ cascade="save-update, merge, delete, delete-orphan",
+ lazy="joined",
+ )
class Spool(Base):
@@ -58,6 +69,11 @@ class Spool(Base):
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column()
+ extra: Mapped[list["SpoolField"]] = relationship(
+ back_populates="spool",
+ cascade="save-update, merge, delete, delete-orphan",
+ lazy="joined",
+ )
class Setting(Base):
@@ -66,3 +82,30 @@ class Setting(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
last_updated: Mapped[datetime] = mapped_column()
+
+
+class VendorField(Base):
+ __tablename__ = "vendor_field"
+
+ vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True)
+ vendor: Mapped["Vendor"] = relationship(back_populates="extra")
+ key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
+ value: Mapped[str] = mapped_column(Text())
+
+
+class FilamentField(Base):
+ __tablename__ = "filament_field"
+
+ filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True)
+ filament: Mapped["Filament"] = relationship(back_populates="extra")
+ key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
+ value: Mapped[str] = mapped_column(Text())
+
+
+class SpoolField(Base):
+ __tablename__ = "spool_field"
+
+ spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True)
+ spool: Mapped["Spool"] = relationship(back_populates="extra")
+ key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
+ value: Mapped[str] = mapped_column(Text())
diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py
index dc29d32..7710231 100644
--- a/spoolman/database/spool.py
+++ b/spoolman/database/spool.py
@@ -43,6 +43,7 @@ async def create(
lot_nr: Optional[str] = None,
comment: Optional[str] = None,
archived: bool = False,
+ extra: Optional[dict[str, str]] = None,
) -> 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)
@@ -71,6 +72,7 @@ async def create(
lot_nr=lot_nr,
comment=comment,
archived=archived,
+ extra=[models.SpoolField(key=k, value=v) for k, v in (extra or {}).items()],
)
db.add(spool)
await db.commit()
@@ -158,7 +160,7 @@ async def find(
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt)
- result = list(rows.scalars().all())
+ result = list(rows.unique().scalars().all())
if total_count is None:
total_count = len(result)
@@ -182,6 +184,8 @@ async def update(
spool.used_weight = max(spool.filament.weight - v, 0)
elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v))
+ elif k == "extra":
+ spool.extra = [models.SpoolField(key=k, value=v) for k, v in v.items()]
else:
setattr(spool, k, v)
await db.commit()
@@ -196,6 +200,13 @@ async def delete(db: AsyncSession, spool_id: int) -> None:
await spool_changed(spool, EventType.DELETED)
+async def clear_extra_field(db: AsyncSession, key: str) -> None:
+ """Delete all extra fields with a specific key."""
+ await db.execute(
+ sqlalchemy.delete(models.SpoolField).where(models.SpoolField.key == key),
+ )
+
+
async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> None:
"""Consume filament from a spool by weight in a way that is safe against race conditions.
diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py
index db7be09..67ba082 100644
--- a/spoolman/database/vendor.py
+++ b/spoolman/database/vendor.py
@@ -3,6 +3,7 @@
from datetime import datetime
from typing import Optional
+import sqlalchemy
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,12 +19,14 @@ async def create(
db: AsyncSession,
name: Optional[str] = None,
comment: Optional[str] = None,
+ extra: Optional[dict[str, str]] = None,
) -> models.Vendor:
"""Add a new vendor to the database."""
vendor = models.Vendor(
name=name,
registered=datetime.utcnow().replace(microsecond=0),
comment=comment,
+ extra=[models.VendorField(key=k, value=v) for k, v in (extra or {}).items()],
)
db.add(vendor)
await db.commit()
@@ -72,7 +75,7 @@ async def find(
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt)
- result = list(rows.scalars().all())
+ result = list(rows.unique().scalars().all())
if total_count is None:
total_count = len(result)
@@ -88,7 +91,10 @@ async def update(
"""Update the fields of a vendor object."""
vendor = await get_by_id(db, vendor_id)
for k, v in data.items():
- setattr(vendor, k, v)
+ if k == "extra":
+ vendor.extra = [models.VendorField(key=k, value=v) for k, v in v.items()]
+ else:
+ setattr(vendor, k, v)
await db.commit()
await vendor_changed(vendor, EventType.UPDATED)
return vendor
@@ -101,6 +107,13 @@ async def delete(db: AsyncSession, vendor_id: int) -> None:
await vendor_changed(vendor, EventType.DELETED)
+async def clear_extra_field(db: AsyncSession, key: str) -> None:
+ """Delete all extra fields with a specific key."""
+ await db.execute(
+ sqlalchemy.delete(models.VendorField).where(models.VendorField.key == key),
+ )
+
+
async def vendor_changed(vendor: models.Vendor, typ: EventType) -> None:
"""Notify websocket clients that a vendor has changed."""
await websocket_manager.send(
diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py
new file mode 100644
index 0000000..ba773cc
--- /dev/null
+++ b/spoolman/extra_fields.py
@@ -0,0 +1,246 @@
+"""Custom/extra fields for spoolman entities."""
+
+import json
+import logging
+from enum import Enum
+from typing import Optional
+
+from fastapi.encoders import jsonable_encoder
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from spoolman.database import filament as db_filament
+from spoolman.database import setting as db_setting
+from spoolman.database import spool as db_spool
+from spoolman.database import vendor as db_vendor
+from spoolman.exceptions import ItemNotFoundError
+from spoolman.settings import parse_setting
+
+logger = logging.getLogger(__name__)
+
+
+class EntityType(Enum):
+ vendor = "vendor"
+ filament = "filament"
+ spool = "spool"
+
+
+class ExtraFieldType(Enum):
+ text = "text"
+ integer = "integer"
+ integer_range = "integer_range"
+ float = "float"
+ float_range = "float_range"
+ datetime = "datetime"
+ boolean = "boolean"
+ choice = "choice"
+
+
+class ExtraFieldParameters(BaseModel):
+ name: str = Field(description="Nice name", min_length=1, max_length=128)
+ order: int = Field(0, description="Order of the field")
+ unit: Optional[str] = Field(None, description="Unit of the value", min_length=1, max_length=16)
+ field_type: ExtraFieldType = Field(description="Type of the field")
+ default_value: Optional[str] = Field(None, description="Default value of the field")
+ choices: Optional[list[str]] = Field(
+ None,
+ description="Choices for the field, only for field type choice",
+ min_items=1,
+ )
+ multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected")
+
+
+class ExtraField(ExtraFieldParameters):
+ key: str = Field(description="Unique key", regex="^[a-z0-9_]+$", min_length=1, max_length=64)
+ entity_type: EntityType = Field(description="Entity type this field is for")
+
+
+def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None: # noqa: C901, PLR0912
+ """Validate that the value has the correct type."""
+ try:
+ data = json.loads(value)
+ except json.JSONDecodeError:
+ raise ValueError("Value is not valid JSON.") from None
+
+ if field.field_type == ExtraFieldType.text:
+ if not isinstance(data, str):
+ raise ValueError("Value is not a string.")
+ elif field.field_type == ExtraFieldType.integer:
+ if not isinstance(data, int):
+ raise ValueError("Value is not an integer.")
+ elif field.field_type == ExtraFieldType.integer_range:
+ if not isinstance(data, list):
+ raise ValueError("Value is not a list.")
+ if len(data) != 2: # noqa: PLR2004
+ raise ValueError("Value list must have exactly two values.")
+ if not all(isinstance(value, int) or value is None for value in data):
+ raise ValueError("Value list must contain only integers or null.")
+ elif field.field_type == ExtraFieldType.float:
+ if not isinstance(data, (float, int)) or isinstance(data, bool):
+ raise ValueError("Value is not a float.")
+ elif field.field_type == ExtraFieldType.float_range:
+ if not isinstance(data, list):
+ raise ValueError("Value is not a list.")
+ if len(data) != 2: # noqa: PLR2004
+ raise ValueError("Value list must have exactly two values.")
+ if not all(
+ (isinstance(value, (float, int)) or value is None) and not isinstance(value, bool) for value in data
+ ):
+ raise ValueError("Value list must contain only floats or null.")
+ elif field.field_type == ExtraFieldType.datetime:
+ if not isinstance(data, str):
+ raise ValueError("Value is not a string.")
+ elif field.field_type == ExtraFieldType.boolean:
+ if not isinstance(data, bool):
+ raise ValueError("Value is not a boolean.")
+ elif field.field_type == ExtraFieldType.choice:
+ if field.multi_choice:
+ if not isinstance(data, list):
+ raise ValueError("Value is not a list.")
+ if not all(isinstance(value, str) for value in data):
+ raise ValueError("Value list must contain only strings.")
+ if field.choices is not None and not all(value in field.choices for value in data):
+ raise ValueError("Value list contains invalid choices.")
+ else:
+ if not isinstance(data, str):
+ raise ValueError("Value is not a string.")
+ if field.choices is not None and data not in field.choices:
+ raise ValueError("Value is not a valid choice.")
+ else:
+ raise ValueError(f"Unknown field type {field.field_type}.")
+
+
+def validate_extra_field(field: ExtraFieldParameters) -> None:
+ """Validate an extra field."""
+ # Validate choices exist if field type is choice
+ if field.field_type == ExtraFieldType.choice:
+ if field.choices is None:
+ raise ValueError("Choices must be set for field type choice.")
+ if field.multi_choice is None:
+ raise ValueError("Multi choice must be set for field type choice.")
+ else:
+ if field.choices is not None:
+ raise ValueError("Choices must not be set for field type other than choice.")
+ if field.multi_choice is not None:
+ raise ValueError("Multi choice must not be set for field type other than choice.")
+
+ # Validate default value data type
+ if field.default_value is not None:
+ try:
+ validate_extra_field_value(field, field.default_value)
+ except ValueError as e:
+ raise ValueError(f"Default value is not valid: {e}") from None
+
+
+def validate_extra_field_dict(all_fields: list[ExtraField], fields_input: dict[str, str]) -> None:
+ """Validate a dict of extra fields."""
+ all_field_lookup = {field.key: field for field in all_fields}
+ for key, value in fields_input.items():
+ if key not in all_field_lookup:
+ raise ValueError(f"Unknown extra field {key}.")
+ field = all_field_lookup[key]
+ try:
+ validate_extra_field_value(field, value)
+ except ValueError as e:
+ raise ValueError(f"Invalid extra field for key {key}: {e!s}") from None
+
+
+extra_field_cache = {}
+
+
+async def get_extra_fields(db: AsyncSession, entity_type: EntityType) -> list[ExtraField]:
+ """Get all extra fields for a specific entity type."""
+ if entity_type in extra_field_cache:
+ return extra_field_cache[entity_type]
+
+ setting_def = parse_setting(f"extra_fields_{entity_type.name}")
+ try:
+ setting = await db_setting.get(db, setting_def)
+ setting_value = setting.value
+ except ItemNotFoundError:
+ setting_value = setting_def.default
+
+ setting_array = json.loads(setting_value)
+ if not isinstance(setting_array, list):
+ logger.warning("Setting %s is not a list, using default.", setting_def.key)
+ setting_array = []
+
+ fields = [ExtraField.parse_obj(obj) for obj in setting_array]
+ extra_field_cache[entity_type] = fields
+ return fields
+
+
+async def add_or_update_extra_field(db: AsyncSession, entity_type: EntityType, extra_field: ExtraField) -> None:
+ """Add or update an extra field for a specific entity type."""
+ validate_extra_field(extra_field)
+
+ extra_fields = await get_extra_fields(db, entity_type)
+
+ # If the field already exists, verify that we don't do anything that would break existing data
+ existing_field = next((field for field in extra_fields if field.key == extra_field.key), None)
+ if existing_field is not None:
+ if existing_field.field_type != extra_field.field_type:
+ raise ValueError("Field type cannot be changed.")
+ if extra_field.field_type == ExtraFieldType.choice:
+ # Can't change multi choice since that would break existing data
+ if existing_field.multi_choice != extra_field.multi_choice:
+ raise ValueError("Multi choice cannot be changed.")
+
+ # Verify that we have only added new choices, not removed any
+ if (
+ existing_field.choices is not None
+ and extra_field.choices is not None
+ and not all(choice in extra_field.choices for choice in existing_field.choices)
+ ):
+ raise ValueError("Cannot remove existing choices.")
+
+ extra_fields = [field for field in extra_fields if field.key != extra_field.key]
+ extra_fields.append(extra_field)
+
+ setting_def = parse_setting(f"extra_fields_{entity_type.name}")
+ await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields)))
+
+ # Update cache
+ extra_field_cache[entity_type] = extra_fields
+
+ logger.info("Added/updated extra field %s for entity type %s.", extra_field.key, entity_type.name)
+
+
+async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str) -> None:
+ """Delete an extra field for a specific entity type."""
+ extra_fields = await get_extra_fields(db, entity_type)
+
+ # Check if the field exists
+ if not any(field.key == key for field in extra_fields):
+ raise ItemNotFoundError(f"Extra field with key {key} does not exist.")
+
+ extra_fields = [field for field in extra_fields if field.key != key]
+
+ setting_def = parse_setting(f"extra_fields_{entity_type.name}")
+ await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields)))
+
+ # Update cache
+ extra_field_cache[entity_type] = extra_fields
+
+ # Delete the extra field for all entities
+ if entity_type == EntityType.vendor:
+ await db_vendor.clear_extra_field(db, key)
+ elif entity_type == EntityType.filament:
+ await db_filament.clear_extra_field(db, key)
+ elif entity_type == EntityType.spool:
+ await db_spool.clear_extra_field(db, key)
+ else:
+ raise ValueError(f"Unknown entity type {entity_type.name}.")
+
+ logger.info("Deleted extra field %s for entity type %s.", key, entity_type.name)
+
+
+async def populate_with_defaults(db: AsyncSession, entity_type: EntityType, existing: dict[str, str]) -> None:
+ """Populate the given list of extra fields with defaults."""
+ extra_fields = await get_extra_fields(db, entity_type)
+ for extra_field in extra_fields:
+ if extra_field.default_value is None:
+ continue
+ if extra_field.key in existing:
+ continue
+ existing[extra_field.key] = extra_field.default_value
diff --git a/spoolman/settings.py b/spoolman/settings.py
index 4f35191..b3600bc 100644
--- a/spoolman/settings.py
+++ b/spoolman/settings.py
@@ -62,3 +62,7 @@ def parse_setting(key: str) -> SettingDefinition:
register_setting("currency", SettingType.STRING, json.dumps("EUR"))
+
+register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
+register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
+register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([]))
diff --git a/tests_integration/tests/conftest.py b/tests_integration/tests/conftest.py
index a87de9f..e8e100b 100644
--- a/tests_integration/tests/conftest.py
+++ b/tests_integration/tests/conftest.py
@@ -3,6 +3,7 @@
import math
import os
import time
+from collections.abc import Iterable
from contextlib import contextmanager
from enum import StrEnum
from typing import Any
@@ -240,3 +241,54 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
volume_cm3 = weight / density
volume_mm3 = volume_cm3 * 1000
return volume_mm3 / (math.pi * (diameter / 2) ** 2)
+
+
+def assert_dicts_compatible(actual: Any, expected: Any, path: str = "") -> None: # noqa: ANN401
+ """Assert that two dictionaries are compatible for unit testing a REST API.
+
+ Args:
+ actual (dict): The actual dictionary.
+ expected (dict): The expected dictionary.
+ path (str): The path to the current level in the dictionary (used for error messages).
+
+ Raises:
+ AssertionError: If dictionaries are not compatible.
+ """
+ # Check if both inputs are dictionaries
+ if not (isinstance(actual, dict) and isinstance(expected, dict)):
+ raise TypeError(f"At {path}: Actual and expected values must be dictionaries.")
+
+ # Check if actual dictionary contains all keys of the expected dictionary
+ missing_keys = [key for key in expected if key not in actual]
+ if missing_keys:
+ raise AssertionError(f"At {path}: Missing keys in actual dictionary: {missing_keys}")
+
+ # Recursively check values if the corresponding keys exist
+ for key, expected_value in expected.items():
+ actual_value = actual[key]
+ subpath = f"{path}.{key}" if path else key # Update the path for the current level
+
+ # If the value is another dictionary, recurse into it
+ if isinstance(expected_value, dict):
+ assert_dicts_compatible(actual_value, expected_value, path=subpath)
+ elif actual_value != expected_value: # Check if values are equal
+ raise AssertionError(
+ f"At {subpath}: Values do not match. Expected: {expected_value}, Actual: {actual_value}",
+ )
+
+
+def assert_lists_compatible(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]], sort_key: str = "id") -> None:
+ """Compare two lists of items where the order of the items is not guaranteed."""
+ a_sorted = sorted(a, key=lambda x: x[sort_key])
+ b_sorted = sorted(b, key=lambda x: x[sort_key])
+ if len(a_sorted) != len(b_sorted):
+ pytest.fail(f"Lists have different lengths: {len(a_sorted)} != {len(b_sorted)}")
+
+ for a_filament, b_filament in zip(a_sorted, b_sorted):
+ assert_dicts_compatible(a_filament, b_filament)
+
+
+def assert_httpx_success(response: httpx.Response) -> None:
+ """Assert that a response is successful."""
+ if not response.is_success:
+ pytest.fail(f"Request failed: {response.status_code} {response.text}")
diff --git a/tests_integration/tests/fields/__init__.py b/tests_integration/tests/fields/__init__.py
new file mode 100644
index 0000000..a59459b
--- /dev/null
+++ b/tests_integration/tests/fields/__init__.py
@@ -0,0 +1 @@
+"""Integration tests for the custom extra fields system."""
diff --git a/tests_integration/tests/fields/test_create.py b/tests_integration/tests/fields/test_create.py
new file mode 100644
index 0000000..23718be
--- /dev/null
+++ b/tests_integration/tests/fields/test_create.py
@@ -0,0 +1,429 @@
+"""Integration tests for the custom extra fields system."""
+
+import json
+from datetime import datetime, timezone
+
+import httpx
+import pytest
+
+from ..conftest import assert_httpx_success, assert_lists_compatible
+
+URL = "http://spoolman:8000"
+
+
+def test_add_text_field():
+ """Test adding a text field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
+ assert_httpx_success(result)
+
+
+def test_add_text_field_filament():
+ """Test adding a text field for filaments."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/filament/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/filament/mytextfield")
+ assert_httpx_success(result)
+
+
+def test_add_text_field_vendor():
+ """Test adding a text field for vendors."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/vendor/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
+ assert_httpx_success(result)
+
+
+def test_add_integer_field():
+ """Test adding an integer field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/myintegerfield",
+ json={
+ "name": "My integer field",
+ "unit": "mm",
+ "field_type": "integer",
+ "default_value": json.dumps(42),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/myintegerfield")
+ assert_httpx_success(result)
+
+
+def test_add_integer_range_field():
+ """Test adding an integer range field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/myintegerrangefield",
+ json={
+ "name": "My integer range field",
+ "field_type": "integer_range",
+ "default_value": json.dumps([0, 100]),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/myintegerrangefield")
+ assert_httpx_success(result)
+
+
+def test_add_float_field():
+ """Test adding a float field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/myfloatfield",
+ json={
+ "name": "My float field",
+ "field_type": "float",
+ "default_value": json.dumps(3.14),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/myfloatfield")
+ assert_httpx_success(result)
+
+
+def test_add_float_range_field():
+ """Test adding a float range field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/myfloatrangefield",
+ json={
+ "name": "My float range field",
+ "field_type": "float_range",
+ "default_value": json.dumps([0.0, 1.0]),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/myfloatrangefield")
+ assert_httpx_success(result)
+
+
+def test_add_datetime_field():
+ """Test adding a datetime field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mydatetimefield",
+ json={
+ "name": "My datetime field",
+ "field_type": "datetime",
+ "default_value": json.dumps(datetime.now(timezone.utc).isoformat()),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mydatetimefield")
+ assert_httpx_success(result)
+
+
+def test_add_boolean_field():
+ """Test adding a boolean field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mybooleanfield",
+ json={
+ "name": "My boolean field",
+ "field_type": "boolean",
+ "default_value": json.dumps(True), # noqa: FBT003
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mybooleanfield")
+ assert_httpx_success(result)
+
+
+@pytest.mark.parametrize(
+ "multi_choice",
+ [True, False],
+)
+def test_add_choice_field(multi_choice: bool): # noqa: FBT001
+ """Test adding a choice field for spools."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps(["foo"]) if multi_choice else json.dumps("foo"),
+ "multi_choice": multi_choice,
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
+ assert_httpx_success(result)
+
+
+def test_add_text_field_invalid_data():
+ """Test adding a text field with invalid default value."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps(42),
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Default value is not valid: Value is not a string."
+
+
+def test_add_choice_field_without_multi_choice():
+ """Test adding a choice field without multi_choice set."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps("foo"),
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Multi choice must be set for field type choice."
+
+
+def test_add_choice_field_invalid_choices():
+ """Test adding a choice field with invalid choices."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz", {"foo": "bar"}],
+ "default_value": json.dumps(["foo"]),
+ "multi_choice": True,
+ },
+ )
+ assert result.status_code == 422
+
+
+def test_add_choice_field_invalid_default_value():
+ """Test adding a choice field with invalid default value."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps(42),
+ "multi_choice": False,
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Default value is not valid: Value is not a string."
+
+
+def test_add_choice_field_no_choices():
+ """Test adding a choice field without choices set."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "default_value": json.dumps("foo"),
+ "multi_choice": True,
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Choices must be set for field type choice."
+
+
+def test_update_existing_field():
+ """Test updating an existing field."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Goodbye World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Verify that the default value was updated, and that it is still the only field
+ result = httpx.get(f"{URL}/api/v1/field/spool")
+ assert_httpx_success(result)
+ assert_lists_compatible(
+ result.json(),
+ [
+ {
+ "name": "My text field",
+ "key": "mytextfield",
+ "field_type": "text",
+ "default_value": json.dumps("Goodbye World"),
+ },
+ ],
+ sort_key="key",
+ )
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
+ assert_httpx_success(result)
+
+
+def test_update_field_change_field_type():
+ """Test updating an existing field and changing the field type, should not be allowed."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "integer",
+ "default_value": json.dumps(42),
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Field type cannot be changed."
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
+ assert_httpx_success(result)
+
+
+def test_update_field_add_choice():
+ """Test updating an existing field and adding a choice, should be allowed."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps(["foo"]),
+ "multi_choice": True,
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz", "qux"],
+ "default_value": json.dumps(["foo"]),
+ "multi_choice": True,
+ },
+ )
+ assert_httpx_success(result)
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
+ assert_httpx_success(result)
+
+
+def test_update_field_remove_choice():
+ """Test updating an existing field and removing a choice, should not be allowed."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps(["foo"]),
+ "multi_choice": True,
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "baz"],
+ "default_value": json.dumps(["foo"]),
+ "multi_choice": True,
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Cannot remove existing choices."
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
+ assert_httpx_success(result)
+
+
+def test_update_field_change_multi_choice():
+ """Test updating an existing field and changing the multi_choice setting, should not be allowed."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps(["foo"]),
+ "multi_choice": True,
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mychoicefield",
+ json={
+ "name": "My choice field",
+ "field_type": "choice",
+ "choices": ["foo", "bar", "baz"],
+ "default_value": json.dumps("foo"),
+ "multi_choice": False,
+ },
+ )
+ assert result.status_code == 400
+ assert result.json()["message"] == "Multi choice cannot be changed."
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
+ assert_httpx_success(result)
diff --git a/tests_integration/tests/fields/test_delete.py b/tests_integration/tests/fields/test_delete.py
new file mode 100644
index 0000000..3c540a5
--- /dev/null
+++ b/tests_integration/tests/fields/test_delete.py
@@ -0,0 +1,31 @@
+"""Integration tests for the custom extra fields system."""
+
+import json
+
+import httpx
+
+from ..conftest import assert_httpx_success
+
+URL = "http://spoolman:8000"
+
+
+def test_delete_field():
+ """Test adding a field, deleting it, and making sure it's gone."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ # Delete
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
+ assert_httpx_success(result)
+
+ # Verify
+ result = httpx.get(f"{URL}/api/v1/field/spool")
+ assert_httpx_success(result)
+ assert result.json() == []
diff --git a/tests_integration/tests/fields/test_get.py b/tests_integration/tests/fields/test_get.py
new file mode 100644
index 0000000..26da655
--- /dev/null
+++ b/tests_integration/tests/fields/test_get.py
@@ -0,0 +1,60 @@
+"""Integration tests for the custom extra fields system."""
+
+import json
+
+import httpx
+
+from ..conftest import assert_httpx_success, assert_lists_compatible
+
+URL = "http://spoolman:8000"
+
+
+def test_get_field():
+ """Test adding a couple of fields to the spool and then getting them."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/spool/myintfield",
+ json={
+ "name": "My int field",
+ "field_type": "integer",
+ "default_value": json.dumps(42),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.get(f"{URL}/api/v1/field/spool")
+ assert_httpx_success(result)
+ assert_lists_compatible(
+ result.json(),
+ [
+ {
+ "name": "My text field",
+ "key": "mytextfield",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ {
+ "name": "My int field",
+ "key": "myintfield",
+ "field_type": "integer",
+ "default_value": json.dumps(42),
+ },
+ ],
+ sort_key="key",
+ )
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
+ assert_httpx_success(result)
+
+ result = httpx.delete(f"{URL}/api/v1/field/spool/myintfield")
+ assert_httpx_success(result)
diff --git a/tests_integration/tests/fields/test_utilize.py b/tests_integration/tests/fields/test_utilize.py
new file mode 100644
index 0000000..f7a370c
--- /dev/null
+++ b/tests_integration/tests/fields/test_utilize.py
@@ -0,0 +1,156 @@
+"""Integration tests for the custom extra fields system."""
+
+import json
+
+import httpx
+
+from ..conftest import assert_httpx_success
+
+URL = "http://spoolman:8000"
+
+
+def test_add_vendor_with_extra_field():
+ """Test adding a vendor with a custom field."""
+ result = httpx.post(
+ f"{URL}/api/v1/field/vendor/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/vendor",
+ json={
+ "name": "My Vendor",
+ "extra": {
+ "mytextfield": '"My Value"',
+ },
+ },
+ )
+ assert_httpx_success(result)
+
+ # Verify
+ result = httpx.get(f"{URL}/api/v1/vendor/{result.json()['id']}")
+ assert_httpx_success(result)
+ vendor = result.json()
+ assert vendor["name"] == "My Vendor"
+ assert vendor["extra"] == {"mytextfield": '"My Value"'}
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
+ assert_httpx_success(result)
+
+ result = httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}")
+ assert_httpx_success(result)
+
+
+def test_add_vendor_with_invalid_extra_field():
+ """Test adding a vendor with an invalid custom field."""
+ result = httpx.post(
+ f"{URL}/api/v1/vendor",
+ json={
+ "name": "My Vendor",
+ "extra": {
+ "somefield": 42,
+ },
+ },
+ )
+ assert result.status_code == 400
+ assert "somefield" in result.json()["message"].lower()
+
+
+def test_add_vendor_with_extra_field_then_delete():
+ """Test adding a vendor with an extra field, then delete the field.
+
+ Vendor GET response should then not contain the extra field.
+ """
+ result = httpx.post(
+ f"{URL}/api/v1/field/vendor/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.post(
+ f"{URL}/api/v1/vendor",
+ json={
+ "name": "My Vendor",
+ "extra": {
+ "mytextfield": '"My Value"',
+ },
+ },
+ )
+ assert_httpx_success(result)
+
+ # Verify
+ result = httpx.get(f"{URL}/api/v1/vendor/{result.json()['id']}")
+ assert_httpx_success(result)
+ vendor = result.json()
+ assert vendor["name"] == "My Vendor"
+ assert vendor["extra"] == {"mytextfield": '"My Value"'}
+
+ # Remove field
+ result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
+ assert_httpx_success(result)
+
+ # Verify
+ result = httpx.get(f"{URL}/api/v1/vendor/{vendor['id']}")
+ assert_httpx_success(result)
+ vendor = result.json()
+ assert vendor["name"] == "My Vendor"
+ assert "extra" not in vendor or vendor["extra"] == {}
+
+ result = httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}")
+ assert_httpx_success(result)
+
+
+def test_update_existing_vendor_with_new_extra_field():
+ """Test updating an existing vendor with a new extra field."""
+ result = httpx.post(
+ f"{URL}/api/v1/vendor",
+ json={
+ "name": "My Vendor",
+ },
+ )
+ assert_httpx_success(result)
+ vendor_id = result.json()["id"]
+
+ result = httpx.post(
+ f"{URL}/api/v1/field/vendor/mytextfield",
+ json={
+ "name": "My text field",
+ "field_type": "text",
+ "default_value": json.dumps("Hello World"),
+ },
+ )
+ assert_httpx_success(result)
+
+ result = httpx.patch(
+ f"{URL}/api/v1/vendor/{vendor_id}",
+ json={
+ "extra": {
+ "mytextfield": '"My Value"',
+ },
+ },
+ )
+ assert_httpx_success(result)
+
+ # Verify
+ result = httpx.get(f"{URL}/api/v1/vendor/{vendor_id}")
+ assert_httpx_success(result)
+ vendor = result.json()
+ assert vendor["name"] == "My Vendor"
+ assert vendor["extra"] == {"mytextfield": '"My Value"'}
+
+ # Clean up
+ result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
+ assert_httpx_success(result)
+
+ result = httpx.delete(f"{URL}/api/v1/vendor/{vendor_id}")
+ assert_httpx_success(result)
diff --git a/tests_integration/tests/filament/test_add.py b/tests_integration/tests/filament/test_add.py
index 13d3ff5..d5d1213 100644
--- a/tests_integration/tests/filament/test_add.py
+++ b/tests_integration/tests/filament/test_add.py
@@ -5,6 +5,8 @@ from typing import Any
import httpx
+from ..conftest import assert_dicts_compatible
+
URL = "http://spoolman:8000"
@@ -45,23 +47,26 @@ def test_add_filament(random_vendor: dict[str, Any]):
# Verify
filament = result.json()
- assert filament == {
- "id": filament["id"],
- "registered": filament["registered"],
- "name": name,
- "vendor": random_vendor,
- "material": material,
- "price": price,
- "density": density,
- "diameter": diameter,
- "weight": weight,
- "spool_weight": spool_weight,
- "article_number": article_number,
- "comment": comment,
- "settings_extruder_temp": settings_extruder_temp,
- "settings_bed_temp": settings_bed_temp,
- "color_hex": color_hex,
- }
+ assert_dicts_compatible(
+ filament,
+ {
+ "id": filament["id"],
+ "registered": filament["registered"],
+ "name": name,
+ "vendor": random_vendor,
+ "material": material,
+ "price": price,
+ "density": density,
+ "diameter": diameter,
+ "weight": weight,
+ "spool_weight": spool_weight,
+ "article_number": article_number,
+ "comment": comment,
+ "settings_extruder_temp": settings_extruder_temp,
+ "settings_bed_temp": settings_bed_temp,
+ "color_hex": color_hex,
+ },
+ )
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(filament["registered"])).total_seconds())
@@ -87,12 +92,15 @@ def test_add_filament_required():
# Verify
filament = result.json()
- assert filament == {
- "id": filament["id"],
- "registered": filament["registered"],
- "density": density,
- "diameter": diameter,
- }
+ assert_dicts_compatible(
+ filament,
+ {
+ "id": filament["id"],
+ "registered": filament["registered"],
+ "density": density,
+ "diameter": diameter,
+ },
+ )
# Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py
index a00c985..a1083ea 100644
--- a/tests_integration/tests/filament/test_find.py
+++ b/tests_integration/tests/filament/test_find.py
@@ -7,14 +7,11 @@ from typing import Any
import httpx
import pytest
+from ..conftest import assert_lists_compatible
+
URL = "http://spoolman:8000"
-def filament_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
- """Compare two lists of filaments where the order of the filaments is not guaranteed."""
- return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
-
-
@dataclass
class Fixture:
filaments: list[dict[str, Any]]
@@ -120,7 +117,7 @@ def test_find_all_filaments(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments)
+ assert_lists_compatible(filaments_result, filaments.filaments)
def test_find_all_filaments_sort_asc(filaments: Fixture):
@@ -279,7 +276,7 @@ def test_find_filaments_by_empty_name(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[3:])
+ assert_lists_compatible(filaments_result, filaments.filaments[3:])
def test_find_filaments_by_material(filaments: Fixture):
@@ -305,7 +302,7 @@ def test_find_filaments_by_multiple_materials(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[:2])
+ assert_lists_compatible(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_empty_material(filaments: Fixture):
@@ -318,7 +315,7 @@ def test_find_filaments_by_empty_material(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[3:])
+ assert_lists_compatible(filaments_result, filaments.filaments[3:])
@pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"])
@@ -332,7 +329,7 @@ def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[:2])
+ assert_lists_compatible(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture):
@@ -347,7 +344,7 @@ def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(
+ assert_lists_compatible(
filaments_result,
[
filaments.filaments[0],
@@ -367,7 +364,7 @@ def test_find_filaments_by_empty_vendor_id(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[2:4])
+ assert_lists_compatible(filaments_result, filaments.filaments[2:4])
@pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"])
@@ -381,7 +378,7 @@ def test_find_filaments_by_vendor_name(filaments: Fixture, field_name: str):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[:2])
+ assert_lists_compatible(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_empty_vendor_name(filaments: Fixture):
@@ -420,7 +417,7 @@ def test_find_filaments_by_empty_article_number(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, filaments.filaments[3:])
+ assert_lists_compatible(filaments_result, filaments.filaments[3:])
def test_find_filaments_by_similar_color(filaments: Fixture):
@@ -436,7 +433,7 @@ def test_find_filaments_by_similar_color(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(filaments_result, [filaments.filaments[0], filaments.filaments[1]])
+ assert_lists_compatible(filaments_result, [filaments.filaments[0], filaments.filaments[1]])
def test_find_filaments_by_similar_color_100(filaments: Fixture):
@@ -452,7 +449,7 @@ def test_find_filaments_by_similar_color_100(filaments: Fixture):
# Verify
filaments_result = result.json()
- assert filament_lists_equal(
+ assert_lists_compatible(
filaments_result,
[filaments.filaments[0], filaments.filaments[1], filaments.filaments[2]],
)
diff --git a/tests_integration/tests/filament/test_get.py b/tests_integration/tests/filament/test_get.py
index 604fc3c..c2c8261 100644
--- a/tests_integration/tests/filament/test_get.py
+++ b/tests_integration/tests/filament/test_get.py
@@ -4,6 +4,8 @@ from typing import Any
import httpx
+from ..conftest import assert_dicts_compatible
+
URL = "http://spoolman:8000"
@@ -51,23 +53,26 @@ def test_get_filament(random_vendor: dict[str, Any]):
# Verify
filament = result.json()
- assert filament == {
- "id": added_filament["id"],
- "registered": added_filament["registered"],
- "name": name,
- "vendor": random_vendor,
- "material": material,
- "price": price,
- "density": density,
- "diameter": diameter,
- "weight": weight,
- "spool_weight": spool_weight,
- "article_number": article_number,
- "comment": comment,
- "settings_extruder_temp": settings_extruder_temp,
- "settings_bed_temp": settings_bed_temp,
- "color_hex": color_hex,
- }
+ assert_dicts_compatible(
+ filament,
+ {
+ "id": added_filament["id"],
+ "registered": added_filament["registered"],
+ "name": name,
+ "vendor": random_vendor,
+ "material": material,
+ "price": price,
+ "density": density,
+ "diameter": diameter,
+ "weight": weight,
+ "spool_weight": spool_weight,
+ "article_number": article_number,
+ "comment": comment,
+ "settings_extruder_temp": settings_extruder_temp,
+ "settings_bed_temp": settings_bed_temp,
+ "color_hex": color_hex,
+ },
+ )
# Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
diff --git a/tests_integration/tests/filament/test_update.py b/tests_integration/tests/filament/test_update.py
index a1cff92..af15bbb 100644
--- a/tests_integration/tests/filament/test_update.py
+++ b/tests_integration/tests/filament/test_update.py
@@ -4,6 +4,8 @@ from typing import Any
import httpx
+from ..conftest import assert_dicts_compatible
+
URL = "http://spoolman:8000"
@@ -66,23 +68,26 @@ def test_update_filament(random_vendor: dict[str, Any]):
# Verify
filament = result.json()
- assert filament == {
- "id": added_filament["id"],
- "registered": added_filament["registered"],
- "name": new_name,
- "vendor": random_vendor,
- "material": new_material,
- "price": new_price,
- "density": new_density,
- "diameter": new_diameter,
- "weight": new_weight,
- "spool_weight": new_spool_weight,
- "article_number": new_article_number,
- "comment": new_comment,
- "settings_extruder_temp": new_settings_extruder_temp,
- "settings_bed_temp": new_settings_bed_temp,
- "color_hex": new_color_hex,
- }
+ assert_dicts_compatible(
+ filament,
+ {
+ "id": added_filament["id"],
+ "registered": added_filament["registered"],
+ "name": new_name,
+ "vendor": random_vendor,
+ "material": new_material,
+ "price": new_price,
+ "density": new_density,
+ "diameter": new_diameter,
+ "weight": new_weight,
+ "spool_weight": new_spool_weight,
+ "article_number": new_article_number,
+ "comment": new_comment,
+ "settings_extruder_temp": new_settings_extruder_temp,
+ "settings_bed_temp": new_settings_bed_temp,
+ "color_hex": new_color_hex,
+ },
+ )
# Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
diff --git a/tests_integration/tests/setting/test_get.py b/tests_integration/tests/setting/test_get.py
index bb8c006..36c5b05 100644
--- a/tests_integration/tests/setting/test_get.py
+++ b/tests_integration/tests/setting/test_get.py
@@ -35,10 +35,9 @@ def test_get_all():
# Verify
settings = result.json()
- assert settings == {
- "currency": {
- "value": '"EUR"',
- "is_set": False,
- "type": "string",
- },
+ assert "currency" in settings
+ assert settings["currency"] == {
+ "value": '"EUR"',
+ "is_set": False,
+ "type": "string",
}
diff --git a/tests_integration/tests/spool/test_add.py b/tests_integration/tests/spool/test_add.py
index fa64f48..edd81de 100644
--- a/tests_integration/tests/spool/test_add.py
+++ b/tests_integration/tests/spool/test_add.py
@@ -6,7 +6,7 @@ from typing import Any
import httpx
import pytest
-from ..conftest import length_from_weight # noqa: TID252
+from ..conftest import assert_dicts_compatible, length_from_weight
URL = "http://spoolman:8000"
@@ -50,22 +50,25 @@ def test_add_spool_remaining_weight(random_filament: dict[str, Any]):
)
spool = result.json()
- assert spool == {
- "id": spool["id"],
- "registered": spool["registered"],
- "first_used": "2023-01-02T11:00:00Z",
- "last_used": "2023-01-02T11:00:00Z",
- "filament": random_filament,
- "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,
- }
+ 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,
+ "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())
@@ -114,21 +117,24 @@ def test_add_spool_used_weight(random_filament: dict[str, Any]):
)
spool = result.json()
- assert spool == {
- "id": spool["id"],
- "registered": spool["registered"],
- "first_used": first_used,
- "last_used": last_used,
- "filament": random_filament,
- "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,
- }
+ assert_dicts_compatible(
+ spool,
+ {
+ "id": spool["id"],
+ "registered": spool["registered"],
+ "first_used": first_used,
+ "last_used": last_used,
+ "filament": random_filament,
+ "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,
+ },
+ )
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
@@ -161,16 +167,19 @@ def test_add_spool_required(random_filament: dict[str, Any]):
)
spool = result.json()
- assert spool == {
- "id": spool["id"],
- "registered": spool["registered"],
- "filament": random_filament,
- "used_weight": pytest.approx(used_weight),
- "remaining_weight": pytest.approx(remaining_weight),
- "used_length": pytest.approx(used_length),
- "remaining_length": pytest.approx(remaining_length),
- "archived": False,
- }
+ assert_dicts_compatible(
+ spool,
+ {
+ "id": spool["id"],
+ "registered": spool["registered"],
+ "filament": random_filament,
+ "used_weight": pytest.approx(used_weight),
+ "remaining_weight": pytest.approx(remaining_weight),
+ "used_length": pytest.approx(used_length),
+ "remaining_length": pytest.approx(remaining_length),
+ "archived": False,
+ },
+ )
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py
index 4368695..0ba8b50 100644
--- a/tests_integration/tests/spool/test_find.py
+++ b/tests_integration/tests/spool/test_find.py
@@ -7,14 +7,11 @@ from typing import Any
import httpx
import pytest
+from ..conftest import assert_lists_compatible
+
URL = "http://spoolman:8000"
-def spool_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
- """Compare two lists of spools where the order of the spools is not guaranteed."""
- return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
-
-
@dataclass
class Fixture:
spools: list[dict[str, Any]]
@@ -101,7 +98,10 @@ def test_find_all_spools(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4]))
+ assert_lists_compatible(
+ spools_result,
+ (spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4]),
+ )
def test_find_all_spools_including_archived(spools: Fixture):
@@ -111,7 +111,7 @@ def test_find_all_spools_including_archived(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(
+ assert_lists_compatible(
spools_result,
(
spools.spools[0],
@@ -272,7 +272,7 @@ def test_find_spools_by_filament_name(spools: Fixture, field_name: str):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_name(spools: Fixture):
@@ -285,7 +285,7 @@ def test_find_spools_by_empty_filament_name(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
+ assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
@pytest.mark.parametrize("field_name", ["filament_id", "filament.id"])
@@ -299,7 +299,7 @@ def test_find_spools_by_filament_id(spools: Fixture, field_name: str):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_multiple_filament_ids(spools: Fixture):
@@ -315,7 +315,7 @@ def test_find_spools_by_multiple_filament_ids(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3]))
@pytest.mark.parametrize("field_name", ["filament_material", "filament.material"])
@@ -329,7 +329,7 @@ def test_find_spools_by_filament_material(spools: Fixture, field_name: str):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_material(spools: Fixture):
@@ -342,7 +342,7 @@ def test_find_spools_by_empty_filament_material(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
+ assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
@pytest.mark.parametrize("field_name", ["vendor_name", "filament.vendor.name"])
@@ -356,7 +356,7 @@ def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_vendor_name(spools: Fixture):
@@ -383,7 +383,7 @@ def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_multiple_vendor_ids(spools: Fixture):
@@ -403,7 +403,10 @@ def test_find_spools_by_multiple_vendor_ids(spools: Fixture):
# Verify
spools_result = result.json()
assert len(spools_result) == 4
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4]))
+ assert_lists_compatible(
+ spools_result,
+ (spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4]),
+ )
def test_find_spools_by_empty_filament_vendor_id(spools: Fixture):
@@ -442,7 +445,7 @@ def test_find_spools_by_empty_location(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
+ assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
def test_find_spools_by_empty_and_filled_location(spools: Fixture):
@@ -455,7 +458,7 @@ def test_find_spools_by_empty_and_filled_location(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[3], spools.spools[4]))
+ assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[3], spools.spools[4]))
def test_find_spools_by_lot_nr(spools: Fixture):
@@ -481,4 +484,4 @@ def test_find_spools_by_empty_lot_nr(spools: Fixture):
# Verify
spools_result = result.json()
- assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
+ assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
diff --git a/tests_integration/tests/spool/test_update.py b/tests_integration/tests/spool/test_update.py
index feaad44..ffcaac1 100644
--- a/tests_integration/tests/spool/test_update.py
+++ b/tests_integration/tests/spool/test_update.py
@@ -5,7 +5,7 @@ from typing import Any
import httpx
import pytest
-from ..conftest import length_from_weight # noqa: TID252
+from ..conftest import length_from_weight
URL = "http://spoolman:8000"
diff --git a/tests_integration/tests/vendor/test_add.py b/tests_integration/tests/vendor/test_add.py
index 547a60e..aceff76 100644
--- a/tests_integration/tests/vendor/test_add.py
+++ b/tests_integration/tests/vendor/test_add.py
@@ -4,6 +4,8 @@ from datetime import datetime, timezone
import httpx
+from ..conftest import assert_dicts_compatible
+
URL = "http://spoolman:8000"
@@ -20,12 +22,15 @@ def test_add_vendor():
# Verify
vendor = result.json()
- assert vendor == {
- "id": vendor["id"],
- "registered": vendor["registered"],
- "name": name,
- "comment": comment,
- }
+ assert_dicts_compatible(
+ vendor,
+ {
+ "id": vendor["id"],
+ "registered": vendor["registered"],
+ "name": name,
+ "comment": comment,
+ },
+ )
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(vendor["registered"])).total_seconds())
@@ -47,11 +52,14 @@ def test_add_vendor_required():
# Verify
vendor = result.json()
- assert vendor == {
- "id": vendor["id"],
- "registered": vendor["registered"],
- "name": name,
- }
+ assert_dicts_compatible(
+ vendor,
+ {
+ "id": vendor["id"],
+ "registered": vendor["registered"],
+ "name": name,
+ },
+ )
# Clean up
httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status()
diff --git a/tests_integration/tests/vendor/test_find.py b/tests_integration/tests/vendor/test_find.py
index 49dff8a..f9e8cad 100644
--- a/tests_integration/tests/vendor/test_find.py
+++ b/tests_integration/tests/vendor/test_find.py
@@ -7,14 +7,11 @@ from typing import Any
import httpx
import pytest
+from ..conftest import assert_lists_compatible
+
URL = "http://spoolman:8000"
-def vendor_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
- """Compare two lists of vendors where the order of the vendors is not guaranteed."""
- return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
-
-
@dataclass
class Fixture:
vendors: list[dict[str, Any]]
@@ -62,7 +59,7 @@ def test_find_all_vendors(vendors: Fixture):
# Verify
vendors_result = result.json()
- assert vendor_lists_equal(vendors_result, vendors.vendors)
+ assert_lists_compatible(vendors_result, vendors.vendors)
def test_find_all_vendors_sort_asc(vendors: Fixture):