Merge pull request #99 from Donkie/sorting

Sorting
This commit is contained in:
Donkie
2023-09-16 13:38:38 +02:00
committed by GitHub
27 changed files with 2204 additions and 593 deletions

View File

@@ -16,6 +16,7 @@
"@refinedev/react-router-v6": "^4.5.0", "@refinedev/react-router-v6": "^4.5.0",
"@refinedev/simple-rest": "^4.5.0", "@refinedev/simple-rest": "^4.5.0",
"@tanstack/react-query": "^4.32.6", "@tanstack/react-query": "^4.32.6",
"@tanstack/react-query-devtools": "^4.33.0",
"@types/loadable__component": "^5.13.4", "@types/loadable__component": "^5.13.4",
"@yudiel/react-qr-scanner": "^1.1.10", "@yudiel/react-qr-scanner": "^1.1.10",
"antd": "^5.8.3", "antd": "^5.8.3",
@@ -2408,6 +2409,21 @@
"url": "https://github.com/sponsors/gregberge" "url": "https://github.com/sponsors/gregberge"
} }
}, },
"node_modules/@tanstack/match-sorter-utils": {
"version": "8.8.4",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz",
"integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==",
"dependencies": {
"remove-accents": "0.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kentcdodds"
}
},
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "4.32.6", "version": "4.32.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.32.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.32.6.tgz",
@@ -2443,6 +2459,25 @@
} }
} }
}, },
"node_modules/@tanstack/react-query-devtools": {
"version": "4.33.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.33.0.tgz",
"integrity": "sha512-6gegkuDmOoiY5e6ZKj1id48vlCXchjfE/6tIpYO8dFlVMQ7t1bYna/Ce6qQJ69+kfEHbYiTTn2lj+FDjIBH7Hg==",
"dependencies": {
"@tanstack/match-sorter-utils": "^8.7.0",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^4.33.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
@@ -3920,6 +3955,20 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"dev": true "dev": true
}, },
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/copy-descriptor": { "node_modules/copy-descriptor": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
@@ -6107,6 +6156,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-what": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz",
"integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-windows": { "node_modules/is-windows": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
@@ -8855,6 +8915,11 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
},
"node_modules/repeat-element": { "node_modules/repeat-element": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
@@ -9829,6 +9894,17 @@
"react-dom": ">=16.9.0" "react-dom": ">=16.9.0"
} }
}, },
"node_modules/superjson": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.1.tgz",
"integrity": "sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -15,6 +15,7 @@
"@refinedev/react-router-v6": "^4.5.0", "@refinedev/react-router-v6": "^4.5.0",
"@refinedev/simple-rest": "^4.5.0", "@refinedev/simple-rest": "^4.5.0",
"@tanstack/react-query": "^4.32.6", "@tanstack/react-query": "^4.32.6",
"@tanstack/react-query-devtools": "^4.33.0",
"@types/loadable__component": "^5.13.4", "@types/loadable__component": "^5.13.4",
"@yudiel/react-qr-scanner": "^1.1.10", "@yudiel/react-qr-scanner": "^1.1.10",
"antd": "^5.8.3", "antd": "^5.8.3",

View File

@@ -1,5 +1,6 @@
import { Refine } from "@refinedev/core"; import { Refine } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ErrorComponent, ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd"; import { ErrorComponent, ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd";
import "@refinedev/antd/dist/reset.css"; import "@refinedev/antd/dist/reset.css";
@@ -185,6 +186,7 @@ function App() {
<RefineKbar /> <RefineKbar />
<UnsavedChangesNotifier /> <UnsavedChangesNotifier />
<DocumentTitleHandler /> <DocumentTitleHandler />
<ReactQueryDevtools />
</Refine> </Refine>
</ConfigProvider> </ConfigProvider>
</ColorModeContextProvider> </ColorModeContextProvider>

View File

@@ -1,7 +1,7 @@
import { Col, Row, Table } from "antd"; import { Col, Row, Spin, Table } from "antd";
import { ColumnProps as AntdColumnProps } from "antd/es/table"; import { ColumnProps as AntdColumnProps } from "antd/es/table";
import { ColumnFilterItem } from "antd/es/table/interface"; import { ColumnFilterItem } from "antd/es/table/interface";
import { getFiltersForField, typeFilters, useListFiltersForField } from "../utils/filtering"; import { getFiltersForField, typeFilters } from "../utils/filtering";
import { TableState } from "../utils/saveload"; import { TableState } from "../utils/saveload";
import { getSortOrderForField, typeSorters } from "../utils/sorting"; import { getSortOrderForField, typeSorters } from "../utils/sorting";
import { NumberFieldUnit } from "./numberField"; import { NumberFieldUnit } from "./numberField";
@@ -12,19 +12,37 @@ import Icon from "@ant-design/icons";
import { ReactComponent as SpoolIcon } from "../icon_spool.svg"; import { ReactComponent as SpoolIcon } from "../icon_spool.svg";
import { useTranslate } from "@refinedev/core"; import { useTranslate } from "@refinedev/core";
import { enrichText } from "../utils/parsing"; import { enrichText } from "../utils/parsing";
import { UseQueryResult } from "@tanstack/react-query";
dayjs.extend(utc); dayjs.extend(utc);
const FilterDropdownLoading = () => {
return (
<Row justify="center">
<Col>
Loading...
<Spin style={{ margin: 10 }} />
</Col>
</Row>
);
};
interface BaseColumnProps<Obj> { interface BaseColumnProps<Obj> {
id: keyof Obj & string; id: keyof Obj & string;
i18ncat: string; dataId?: keyof Obj & string;
i18ncat?: string;
i18nkey?: string;
dataSource: Obj[]; dataSource: Obj[];
tableState: TableState; tableState: TableState;
width?: number;
} }
interface FilteredColumnProps { interface FilteredColumnProps {
filters?: ColumnFilterItem[]; filters?: ColumnFilterItem[];
filteredValue?: string[]; filteredValue?: string[];
allowMultipleFilters?: boolean;
onFilterDropdownOpen?: () => void;
loadingFilters?: boolean;
} }
interface CustomColumnProps<Obj> { interface CustomColumnProps<Obj> {
@@ -45,13 +63,28 @@ function Column<Obj>(props: BaseColumnProps<Obj> & FilteredColumnProps & CustomC
const typedSorters = typeSorters<Obj>(props.tableState.sorters); const typedSorters = typeSorters<Obj>(props.tableState.sorters);
const columnProps: AntdColumnProps<Obj> = { const columnProps: AntdColumnProps<Obj> = {
dataIndex: props.id, dataIndex: props.id,
title: t(`${props.i18ncat}.fields.${props.id}`), title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
sorter: true, sorter: true,
sortOrder: getSortOrderForField(typedSorters, props.id), sortOrder: getSortOrderForField(typedSorters, props.dataId ?? props.id),
filterMultiple: props.allowMultipleFilters ?? true,
}; };
if (props.width) {
columnProps.width = props.width;
}
if (props.filters && props.filteredValue) { if (props.filters && props.filteredValue) {
columnProps.filters = props.filters; columnProps.filters = props.filters;
columnProps.filteredValue = props.filteredValue; columnProps.filteredValue = props.filteredValue;
if (props.loadingFilters) {
columnProps.filterDropdown = <FilterDropdownLoading />;
}
columnProps.onFilterDropdownOpenChange = (open) => {
if (open && props.onFilterDropdownOpen) {
props.onFilterDropdownOpen();
}
};
if (props.dataId) {
columnProps.key = props.dataId;
}
} }
if (props.render) { if (props.render) {
columnProps.render = props.render; columnProps.render = props.render;
@@ -75,13 +108,39 @@ export function RichColumn<Obj>(props: BaseColumnProps<Obj>) {
}); });
} }
export function FilteredColumn<Obj>(props: BaseColumnProps<Obj>) { interface FilteredQueryColumnProps<Obj> extends BaseColumnProps<Obj> {
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[]>;
allowMultipleFilters?: boolean;
}
export function FilteredQueryColumn<Obj>(props: FilteredQueryColumnProps<Obj>) {
const query = props.filterValueQuery;
let filters: ColumnFilterItem[] = [];
if (query.data) {
filters = query.data.map((item) => {
if (typeof item === "string") {
return {
text: item,
value: item,
};
}
return item;
});
}
filters.push({
text: "<empty>",
value: "",
});
const typedFilters = typeFilters<Obj>(props.tableState.filters); const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
const filters = useListFiltersForField(props.dataSource, props.id); const onFilterDropdownOpen = () => {
const filteredValue = getFiltersForField(typedFilters, props.id); query.refetch();
};
return Column({ ...props, filters, filteredValue }); return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
} }
interface NumberColumnProps<Obj> extends BaseColumnProps<Obj> { interface NumberColumnProps<Obj> extends BaseColumnProps<Obj> {
@@ -127,20 +186,43 @@ export function DateColumn<Obj>(props: BaseColumnProps<Obj>) {
}); });
} }
interface SpoolIconColumnProps<Obj> extends BaseColumnProps<Obj> { interface SpoolIconColumnProps<Obj> extends FilteredQueryColumnProps<Obj> {
color: (record: Obj) => string | undefined; color: (record: Obj) => string | undefined;
} }
export function SpoolIconColumn<Obj>(props: SpoolIconColumnProps<Obj>) { export function SpoolIconColumn<Obj>(props: SpoolIconColumnProps<Obj>) {
const typedFilters = typeFilters<Obj>(props.tableState.filters); const query = props.filterValueQuery;
const filters = useListFiltersForField(props.dataSource, props.id); let filters: ColumnFilterItem[] = [];
const filteredValue = getFiltersForField(typedFilters, props.id); if (query.data) {
filters = query.data.map((item) => {
if (typeof item === "string") {
return {
text: item,
value: item,
};
}
return item;
});
}
filters.push({
text: "<empty>",
value: "",
});
const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
const onFilterDropdownOpen = () => {
query.refetch();
};
return Column({ return Column({
...props, ...props,
filters, filters,
filteredValue, filteredValue,
onFilterDropdownOpen,
loadingFilters: query.isLoading,
onCell: () => { onCell: () => {
return { return {
style: { style: {

View File

@@ -13,13 +13,76 @@ const dataProvider = (
Required<DataProvider>, Required<DataProvider>,
"createMany" | "updateMany" | "deleteMany" "createMany" | "updateMany" | "deleteMany"
> => ({ > => ({
getList: async ({ resource, meta }) => { getList: async ({ resource, meta, pagination, sorters, filters }) => {
const url = `${apiUrl}/${resource}`; const url = `${apiUrl}/${resource}`;
const { headers: headersFromMeta, method, queryParams } = meta ?? {}; const { headers: headersFromMeta, method } = meta ?? {};
const queryParams: Record<string, string | number> = meta?.queryParams ?? {};
const requestMethod = (method as MethodTypes) ?? "get"; const requestMethod = (method as MethodTypes) ?? "get";
const { data } = await httpClient[requestMethod]( if (pagination && pagination.mode == "server") {
const pageSize = pagination.pageSize ?? 10;
const offset = ((pagination.current ?? 1) - 1) * pageSize;
queryParams["limit"] = pageSize;
queryParams["offset"] = offset;
}
if (sorters && sorters.length > 0) {
queryParams["sort"] = sorters.map((sort) => {
const field = sort.field
return `${field}:${sort.order}`;
}).join(",")
}
if (filters && filters.length > 0) {
// Pre-process the filters and handle the special case where we want to split the filament name into vendor and filament name
if (resource === "spool") {
filters = structuredClone(filters)
for (let i = 0; i < filters.length; i++) {
const filter = filters[i]
if (("field" in filter) && filter.field === "filament.name") {
const filamentNames: string[] = []
const vendorNames: string[] = []
if (Array.isArray(filter.value)) {
filter.value.forEach((value: string) => {
const split = value.split(" - ")
if (split.length === 1) {
filamentNames.push(split[0])
} else {
vendorNames.push(split[0])
filamentNames.push(split[1])
}
})
} else {
filamentNames.push(filter.value.split(" - ")[1])
vendorNames.push(filter.value.split(" - ")[0])
}
filter.value = filamentNames
filters.push({
field: "vendor.name",
operator: "in",
value: vendorNames,
})
break
}
}
}
filters.forEach(filter => {
if (!("field" in filter)) {
throw Error("Filter must be a LogicalFilter.")
}
const field = filter.field
const fieldValue = Array.isArray(filter.value) ? filter.value.join(",") : filter.value
if (fieldValue.length > 0) {
queryParams[field] = fieldValue
}
});
}
const { data, headers } = await httpClient[requestMethod](
`${url}`, `${url}`,
{ {
headers: headersFromMeta, headers: headersFromMeta,
@@ -27,9 +90,11 @@ const dataProvider = (
}, },
); );
// console.log(url, requestMethod, queryParams, data, headers)
return { return {
data, data,
total: data.length, total: parseInt(headers["x-total-count"]) ?? 100,
}; };
}, },

View File

@@ -0,0 +1,168 @@
import { useQuery } from "@tanstack/react-query";
import { IFilament } from "../pages/filaments/model";
import { IVendor } from "../pages/vendors/model";
import { ColumnFilterItem } from "antd/es/table/interface";
export function useSpoolmanFilamentFilter() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<IFilament[], unknown, ColumnFilterItem[]>({
enabled: false,
queryKey: ["filaments"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/filament");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
// Concatenate vendor name and filament name
const names = data
// Remove empty names
.filter((filament) => {
return filament.name !== null && filament.name !== undefined && filament.name !== "";
})
// Transform to ColumnFilterItem
.map((filament) => {
let name = "";
if (filament.vendor?.name) {
name = `${filament.vendor.name} - ${filament.name ?? "<unknown>"}`;
} else {
name = `${filament.name ?? "<unknown>"}`;
}
return {
text: name,
value: filament.id,
};
})
// Remove duplicates
.filter((item, index, self) => self.findIndex((t) => t.value === item.value) === index)
// Sort by name
.sort((a, b) => a.text.localeCompare(b.text));
return names;
},
});
}
export function useSpoolmanFilamentNames() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<IFilament[], unknown, string[]>({
enabled: false,
queryKey: ["filaments"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/filament");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
// Concatenate vendor name and filament name
let names = data
.filter((filament) => {
return filament.name !== null && filament.name !== undefined && filament.name !== "";
})
.map((filament) => {
return filament.name ?? "<unknown>";
})
.sort();
// Remove duplicates
names = [...new Set(names)];
return names;
},
});
}
export function useSpoolmanVendors() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<IVendor[], unknown, string[]>({
enabled: false,
queryKey: ["vendors"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/vendor");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data
.map((vendor) => {
return vendor.name ?? `ID ${vendor.id}`;
})
.sort();
},
});
}
export function useSpoolmanMaterials() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({
enabled: false,
queryKey: ["materials"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/material");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.sort();
},
});
}
export function useSpoolmanArticleNumbers() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({
enabled: false,
queryKey: ["articleNumbers"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/article-number");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.sort();
},
});
}
export function useSpoolmanLotNumbers() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({
enabled: false,
queryKey: ["lotNumbers"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/lot-number");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.sort();
},
});
}
export function useSpoolmanLocations() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({
enabled: false,
queryKey: ["locations"],
queryFn: async () => {
const response = await fetch(apiEndpoint + "/location");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.sort();
},
});
}

View File

@@ -1,12 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Modal, Table, Checkbox, Space, Row, Col, message } from "antd"; import { Modal, Table, Checkbox, Space, Row, Col, message } from "antd";
import { ISpool } from "../pages/spools/model"; import { ISpool } from "../pages/spools/model";
import { FilteredColumn, SortedColumn, SpoolIconColumn } from "./column"; import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "./column";
import { TableState } from "../utils/saveload"; import { TableState } from "../utils/saveload";
import { useTable } from "@refinedev/antd"; import { useTable } from "@refinedev/antd";
import { genericSorter, typeSorters } from "../utils/sorting";
import { genericFilterer, typeFilters } from "../utils/filtering";
import { t } from "i18next"; import { t } from "i18next";
import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels";
interface Props { interface Props {
visible: boolean; visible: boolean;
@@ -16,8 +15,9 @@ interface Props {
} }
interface ISpoolCollapsed extends ISpool { interface ISpoolCollapsed extends ISpool {
filament_name: string; combined_name: string;
material?: string; "filament.id": number;
"filament.material"?: string;
} }
const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onContinue }) => { const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onContinue }) => {
@@ -38,10 +38,10 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
pageSize: 10, pageSize: 10,
}, },
sorters: { sorters: {
mode: "off", mode: "server",
}, },
filters: { filters: {
mode: "off", mode: "server",
}, },
}); });
@@ -64,28 +64,18 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
} }
return { return {
...element, ...element,
filament_name, combined_name: filament_name,
material: element.filament.material, "filament.id": element.filament.id,
"filament.material": element.filament.material,
}; };
}), }),
[tableProps.dataSource] [tableProps.dataSource]
); );
// Type the sorters and filters
const typedSorters = typeSorters<ISpoolCollapsed>(sorters);
const typedFilters = typeFilters<ISpoolCollapsed>(filters);
// Filter and sort the dataSource
const filteredDataSource = React.useMemo(() => {
const filtered = dataSource.filter(genericFilterer(typedFilters));
filtered.sort(genericSorter(typedSorters));
return filtered;
}, [dataSource, typedFilters, typedSorters]);
// Function to add/remove all filtered items from selected items // Function to add/remove all filtered items from selected items
const selectUnselectFiltered = (select: boolean) => { const selectUnselectFiltered = (select: boolean) => {
setSelectedItems((prevSelected) => { setSelectedItems((prevSelected) => {
const filtered = filteredDataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool)); const filtered = dataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool));
return select ? [...prevSelected, ...filtered] : filtered; return select ? [...prevSelected, ...filtered] : filtered;
}); });
}; };
@@ -98,7 +88,9 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
}; };
// State for the select/unselect all checkbox // State for the select/unselect all checkbox
const isAllFilteredSelected = filteredDataSource.every((spool) => selectedItems.includes(spool.id)); const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id));
const isSomeButNotAllFilteredSelected =
dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected;
return ( return (
<Modal <Modal
@@ -120,13 +112,7 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
{contextHolder} {contextHolder}
<Space direction="vertical" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%" }}>
{description && <div>{description}</div>} {description && <div>{description}</div>}
<Table <Table {...tableProps} rowKey="id" dataSource={dataSource} pagination={false} scroll={{ y: 200 }}>
{...tableProps}
rowKey="id"
dataSource={filteredDataSource}
pagination={false}
scroll={{ x: "max-content", y: 200 }}
>
<Table.Column <Table.Column
width={50} width={50}
render={(_, item: ISpool) => ( render={(_, item: ISpool) => (
@@ -138,25 +124,30 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
i18ncat: "spool", i18ncat: "spool",
dataSource, dataSource,
tableState, tableState,
width: 80,
})} })}
{SpoolIconColumn({ {SpoolIconColumn({
id: "filament_name", id: "combined_name",
i18ncat: "spool", dataId: "filament.id",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex, color: (record: ISpoolCollapsed) => record.filament.color_hex,
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanFilamentFilter(),
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "material", id: "filament.material",
i18ncat: "spool", i18nkey: "spool.fields.material",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanMaterials(),
})} })}
</Table> </Table>
<Row> <Row>
<Col span={12}> <Col span={12}>
<Checkbox <Checkbox
checked={isAllFilteredSelected} checked={isAllFilteredSelected}
indeterminate={isSomeButNotAllFilteredSelected}
onChange={(e) => { onChange={(e) => {
selectUnselectFiltered(e.target.checked); selectUnselectFiltered(e.target.checked);
}} }}

View File

@@ -5,46 +5,51 @@ import { Table, Space, Button, Dropdown } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { IFilament } from "./model"; import { IFilament } from "./model";
import { genericSorter, typeSorters } from "../../utils/sorting";
import { genericFilterer, typeFilters } from "../../utils/filtering";
import { EditOutlined, FilterOutlined } from "@ant-design/icons"; import { EditOutlined, FilterOutlined } from "@ant-design/icons";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { import {
DateColumn, DateColumn,
FilteredColumn, FilteredQueryColumn,
NumberColumn, NumberColumn,
RichColumn, RichColumn,
SortedColumn, SortedColumn,
SpoolIconColumn, SpoolIconColumn,
} from "../../components/column"; } from "../../components/column";
import {
useSpoolmanArticleNumbers,
useSpoolmanFilamentNames,
useSpoolmanMaterials,
useSpoolmanVendors,
} from "../../components/otherModels";
dayjs.extend(utc); dayjs.extend(utc);
interface IFilamentCollapsed extends Omit<IFilament, "vendor"> { interface IFilamentCollapsed extends Omit<IFilament, "vendor"> {
vendor_name: string | null; "vendor.name": string | null;
} }
const namespace = "filamentList-v2";
export const FilamentList: React.FC<IResourceComponentsProps> = () => { export const FilamentList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
// Load initial state // Load initial state
const initialState = useInitialTableState("filamentList"); const initialState = useInitialTableState(namespace);
// Fetch data from the API // Fetch data from the API
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } = const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<IFilament>({
useTable<IFilament>({
syncWithLocation: false, syncWithLocation: false,
pagination: { pagination: {
mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work. mode: "server",
current: initialState.pagination.current, current: initialState.pagination.current,
pageSize: initialState.pagination.pageSize, pageSize: initialState.pagination.pageSize,
}, },
sorters: { sorters: {
mode: "off", // Disable server-side sorting mode: "server",
initial: initialState.sorters, initial: initialState.sorters,
}, },
filters: { filters: {
mode: "off", // Disable server-side filtering mode: "server",
initial: initialState.filters, initial: initialState.filters,
}, },
}); });
@@ -52,7 +57,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
// Create state for the columns to show // Create state for the columns to show
const allColumns: (keyof IFilamentCollapsed & string)[] = [ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"id", "id",
"vendor_name", "vendor.name",
"name", "name",
"material", "material",
"price", "price",
@@ -72,10 +77,6 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? defaultColumns); const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? defaultColumns);
// Type the sorters and filters
const typedSorters = typeSorters<IFilamentCollapsed>(sorters);
const typedFilters = typeFilters<IFilamentCollapsed>(filters);
// Store state in local storage // Store state in local storage
const tableState: TableState = { const tableState: TableState = {
sorters, sorters,
@@ -83,7 +84,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
pagination: { current, pageSize }, pagination: { current, pageSize },
showColumns, showColumns,
}; };
useStoreInitialState("filamentList", tableState); useStoreInitialState(namespace, tableState);
// Collapse the dataSource to a mutable list and add a filament_name field // Collapse the dataSource to a mutable list and add a filament_name field
const dataSource: IFilamentCollapsed[] = React.useMemo( const dataSource: IFilamentCollapsed[] = React.useMemo(
@@ -95,17 +96,14 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
} else { } else {
vendor_name = null; vendor_name = null;
} }
return { ...element, vendor_name }; return { ...element, "vendor.name": vendor_name };
}), }),
[tableProps.dataSource] [tableProps.dataSource]
); );
// Filter and sort the dataSource if (tableProps.pagination) {
const filteredDataSource = React.useMemo(() => { tableProps.pagination.showSizeChanger = true;
const filtered = dataSource.filter(genericFilterer(typedFilters)); }
filtered.sort(genericSorter(typedSorters));
return filtered;
}, [dataSource, typedFilters, typedSorters]);
return ( return (
<List <List
@@ -127,7 +125,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
menu={{ menu={{
items: allColumns.map((column_id) => ({ items: allColumns.map((column_id) => ({
key: column_id, key: column_id,
label: t(`filament.fields.${column_id}`), label: t(`filament.fields.${column_id.replace(".", "_")}`),
})), })),
selectedKeys: showColumns, selectedKeys: showColumns,
selectable: true, selectable: true,
@@ -148,31 +146,19 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
</> </>
)} )}
> >
<Table <Table {...tableProps} dataSource={dataSource} rowKey="id">
{...tableProps}
dataSource={filteredDataSource}
pagination={{
showSizeChanger: true,
current: current,
pageSize: pageSize,
onChange: (page, pageSize) => {
setCurrent(page);
setPageSize(pageSize);
},
}}
rowKey="id"
>
{SortedColumn({ {SortedColumn({
id: "id", id: "id",
i18ncat: "filament", i18ncat: "filament",
dataSource, dataSource,
tableState, tableState,
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "vendor_name", id: "vendor.name",
i18ncat: "filament", i18nkey: "filament.fields.vendor_name",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanVendors(),
})} })}
{SpoolIconColumn({ {SpoolIconColumn({
id: "name", id: "name",
@@ -180,12 +166,14 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
color: (record: IFilamentCollapsed) => record.color_hex, color: (record: IFilamentCollapsed) => record.color_hex,
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanFilamentNames(),
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "material", id: "material",
i18ncat: "filament", i18ncat: "filament",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanMaterials(),
})} })}
{SortedColumn({ {SortedColumn({
id: "price", id: "price",
@@ -225,11 +213,12 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
dataSource, dataSource,
tableState, tableState,
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "article_number", id: "article_number",
i18ncat: "filament", i18ncat: "filament",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanArticleNumbers(),
})} })}
{NumberColumn({ {NumberColumn({
id: "settings_extruder_temp", id: "settings_extruder_temp",

View File

@@ -4,14 +4,12 @@ import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/
import { Table, Space, Button, Dropdown, Modal } from "antd"; import { Table, Space, Button, Dropdown, Modal } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { genericSorter, typeSorters } from "../../utils/sorting";
import { genericFilterer, typeFilters } from "../../utils/filtering";
import { ISpool } from "./model"; import { ISpool } from "./model";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import { EditOutlined, FilterOutlined, InboxOutlined, ToTopOutlined } from "@ant-design/icons"; import { EditOutlined, FilterOutlined, InboxOutlined, ToTopOutlined } from "@ant-design/icons";
import { import {
DateColumn, DateColumn,
FilteredColumn, FilteredQueryColumn,
NumberColumn, NumberColumn,
RichColumn, RichColumn,
SortedColumn, SortedColumn,
@@ -19,29 +17,37 @@ import {
} from "../../components/column"; } from "../../components/column";
import { setSpoolArchived } from "./functions"; import { setSpoolArchived } from "./functions";
import SelectAndPrint from "../../components/selectAndPrintDialog"; import SelectAndPrint from "../../components/selectAndPrintDialog";
import {
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
useSpoolmanMaterials,
} from "../../components/otherModels";
dayjs.extend(utc); dayjs.extend(utc);
const { confirm } = Modal; const { confirm } = Modal;
interface ISpoolCollapsed extends ISpool { interface ISpoolCollapsed extends ISpool {
filament_name: string; combined_name: string; // Eg. "Prusa - PLA Red"
material?: string; "filament.id": number;
"filament.material"?: string;
} }
const namespace = "spoolList-v2";
export const SpoolList: React.FC<IResourceComponentsProps> = () => { export const SpoolList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
// Load initial state // Load initial state
const initialState = useInitialTableState("spoolList"); const initialState = useInitialTableState(namespace);
// State for the switch to show archived spools // State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false); const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// Fetch data from the API // Fetch data from the API
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } = const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<ISpool>({
useTable<ISpool>({
meta: { meta: {
queryParams: { queryParams: {
["allow_archived"]: showArchived, ["allow_archived"]: showArchived,
@@ -49,16 +55,16 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
}, },
syncWithLocation: false, syncWithLocation: false,
pagination: { pagination: {
mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work. mode: "server",
current: initialState.pagination.current, current: initialState.pagination.current,
pageSize: initialState.pagination.pageSize, pageSize: initialState.pagination.pageSize,
}, },
sorters: { sorters: {
mode: "off", // Disable server-side sorting mode: "server",
initial: initialState.sorters, initial: initialState.sorters,
}, },
filters: { filters: {
mode: "off", // Disable server-side filtering mode: "server",
initial: initialState.filters, initial: initialState.filters,
}, },
}); });
@@ -66,8 +72,8 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// Create state for the columns to show // Create state for the columns to show
const allColumns: (keyof ISpoolCollapsed & string)[] = [ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"id", "id",
"filament_name", "combined_name",
"material", "filament.material",
"used_weight", "used_weight",
"remaining_weight", "remaining_weight",
"used_length", "used_length",
@@ -84,10 +90,6 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
); );
const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? defaultColumns); const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? defaultColumns);
// Type the sorters and filters
const typedSorters = typeSorters<ISpoolCollapsed>(sorters);
const typedFilters = typeFilters<ISpoolCollapsed>(filters);
// Store state in local storage // Store state in local storage
const tableState: TableState = { const tableState: TableState = {
sorters, sorters,
@@ -95,7 +97,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
pagination: { current, pageSize }, pagination: { current, pageSize },
showColumns, showColumns,
}; };
useStoreInitialState("spoolList", tableState); useStoreInitialState(namespace, tableState);
// Collapse the dataSource to a mutable list and add a filament_name field // Collapse the dataSource to a mutable list and add a filament_name field
const dataSource: ISpoolCollapsed[] = React.useMemo( const dataSource: ISpoolCollapsed[] = React.useMemo(
@@ -109,20 +111,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
} }
return { return {
...element, ...element,
filament_name, combined_name: filament_name,
material: element.filament.material, "filament.id": element.filament.id,
"filament.material": element.filament.material,
}; };
}), }),
[tableProps.dataSource] [tableProps.dataSource]
); );
// Filter and sort the dataSource
const filteredDataSource = React.useMemo(() => {
const filtered = dataSource.filter(genericFilterer(typedFilters));
filtered.sort(genericSorter(typedSorters));
return filtered;
}, [dataSource, typedFilters, typedSorters]);
// Function for opening an ant design modal that asks for confirmation for archiving a spool // Function for opening an ant design modal that asks for confirmation for archiving a spool
const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => { const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => {
await setSpoolArchived(spool, archive); await setSpoolArchived(spool, archive);
@@ -151,6 +147,10 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
} }
}; };
if (tableProps.pagination) {
tableProps.pagination.showSizeChanger = true;
}
return ( return (
<List <List
headerButtons={({ defaultButtons }) => ( headerButtons={({ defaultButtons }) => (
@@ -181,7 +181,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
menu={{ menu={{
items: allColumns.map((column) => ({ items: allColumns.map((column) => ({
key: column, key: column,
label: t(`spool.fields.${column}`), label: t(`spool.fields.${column.replace(".", "_")}`),
})), })),
selectedKeys: showColumns, selectedKeys: showColumns,
selectable: true, selectable: true,
@@ -204,16 +204,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
> >
<Table <Table
{...tableProps} {...tableProps}
dataSource={filteredDataSource} dataSource={dataSource}
pagination={{
showSizeChanger: true,
current: current,
pageSize: pageSize,
onChange: (page, pageSize) => {
setCurrent(page);
setPageSize(pageSize);
},
}}
rowKey="id" rowKey="id"
// Make archived rows greyed out // Make archived rows greyed out
onRow={(record) => { onRow={(record) => {
@@ -236,17 +227,20 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
tableState, tableState,
})} })}
{SpoolIconColumn({ {SpoolIconColumn({
id: "filament_name", id: "combined_name",
i18ncat: "spool", i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex, color: (record: ISpoolCollapsed) => record.filament.color_hex,
dataSource, dataSource,
tableState, tableState,
dataId: "filament.id",
filterValueQuery: useSpoolmanFilamentFilter(),
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "material", id: "filament.material",
i18ncat: "spool", i18nkey: "spool.fields.material",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanMaterials(),
})} })}
{NumberColumn({ {NumberColumn({
id: "used_weight", id: "used_weight",
@@ -282,17 +276,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
dataSource, dataSource,
tableState, tableState,
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "location", id: "location",
i18ncat: "spool", i18ncat: "spool",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanLocations(),
})} })}
{FilteredColumn({ {FilteredQueryColumn({
id: "lot_nr", id: "lot_nr",
i18ncat: "spool", i18ncat: "spool",
dataSource, dataSource,
tableState, tableState,
filterValueQuery: useSpoolmanLotNumbers(),
})} })}
{DateColumn({ {DateColumn({
id: "first_used", id: "first_used",

View File

@@ -4,8 +4,6 @@ import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/
import { Table, Space, Button, Dropdown } from "antd"; import { Table, Space, Button, Dropdown } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { genericSorter, typeSorters } from "../../utils/sorting";
import { genericFilterer, typeFilters } from "../../utils/filtering";
import { IVendor } from "./model"; import { IVendor } from "./model";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { EditOutlined, FilterOutlined } from "@ant-design/icons"; import { EditOutlined, FilterOutlined } from "@ant-design/icons";
@@ -13,27 +11,28 @@ import { DateColumn, RichColumn, SortedColumn } from "../../components/column";
dayjs.extend(utc); dayjs.extend(utc);
const namespace = "vendorList-v2";
export const VendorList: React.FC<IResourceComponentsProps> = () => { export const VendorList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
// Load initial state // Load initial state
const initialState = useInitialTableState("vendorList"); const initialState = useInitialTableState(namespace);
// Fetch data from the API // Fetch data from the API
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } = const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<IVendor>({
useTable<IVendor>({
syncWithLocation: false, syncWithLocation: false,
pagination: { pagination: {
mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work. mode: "server",
current: initialState.pagination.current, current: initialState.pagination.current,
pageSize: initialState.pagination.pageSize, pageSize: initialState.pagination.pageSize,
}, },
sorters: { sorters: {
mode: "off", // Disable server-side sorting mode: "server",
initial: initialState.sorters, initial: initialState.sorters,
}, },
filters: { filters: {
mode: "off", // Disable server-side filtering mode: "server",
initial: initialState.filters, initial: initialState.filters,
}, },
}); });
@@ -42,10 +41,6 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment"]; const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment"];
const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? allColumns); const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? allColumns);
// Type the sorters and filters
const typedSorters = typeSorters<IVendor>(sorters);
const typedFilters = typeFilters<IVendor>(filters);
// Store state in local storage // Store state in local storage
const tableState: TableState = { const tableState: TableState = {
sorters, sorters,
@@ -53,7 +48,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
pagination: { current, pageSize }, pagination: { current, pageSize },
showColumns, showColumns,
}; };
useStoreInitialState("vendorList", tableState); useStoreInitialState(namespace, tableState);
// Collapse the dataSource to a mutable list // Collapse the dataSource to a mutable list
const dataSource: IVendor[] = React.useMemo( const dataSource: IVendor[] = React.useMemo(
@@ -61,12 +56,9 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
[tableProps.dataSource] [tableProps.dataSource]
); );
// Filter and sort the dataSource if (tableProps.pagination) {
const filteredDataSource = React.useMemo(() => { tableProps.pagination.showSizeChanger = true;
const filtered = dataSource.filter(genericFilterer(typedFilters)); }
filtered.sort(genericSorter(typedSorters));
return filtered;
}, [dataSource, typedFilters, typedSorters]);
return ( return (
<List <List
@@ -109,20 +101,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
</> </>
)} )}
> >
<Table <Table {...tableProps} dataSource={dataSource} rowKey="id">
{...tableProps}
dataSource={filteredDataSource}
pagination={{
showSizeChanger: true,
current: current,
pageSize: pageSize,
onChange: (page, pageSize) => {
setCurrent(page);
setPageSize(pageSize);
},
}}
rowKey="id"
>
{SortedColumn({ {SortedColumn({
id: "id", id: "id",
i18ncat: "vendor", i18ncat: "vendor",

View File

@@ -1,6 +1,4 @@
import { CrudFilter, CrudOperators } from "@refinedev/core"; import { CrudFilter, CrudOperators } from "@refinedev/core";
import { ColumnFilterItem } from "antd/es/table/interface";
import React from "react";
interface TypedCrudFilter<Obj> { interface TypedCrudFilter<Obj> {
field: keyof Obj; field: keyof Obj;
@@ -12,92 +10,6 @@ export function typeFilters<Obj>(filters: CrudFilter[]): TypedCrudFilter<Obj>[]
return filters as TypedCrudFilter<Obj>[]; // <-- Unsafe cast return filters as TypedCrudFilter<Obj>[]; // <-- Unsafe cast
} }
/**
* Returns a filtering function that can be used to filter an array of objects based on the provided filters.
* @param filters An array of `CrudFilter` objects that define the filtering criteria.
* @returns A function that returns a boolean value based on whether the provided object matches the filtering criteria.
*/
export function genericFilterer<Obj>(filters: TypedCrudFilter<Obj>[]) {
return (record: Obj) => {
for (const filter of filters) {
if (!("field" in filter)) {
console.error("Filter must be of type LogicalFilter");
return false;
}
if (!Array.isArray(filter.value)) {
console.error("Filter value must be an array of strings.");
return false;
}
if (!filter.value.length) {
continue;
}
const value = record[filter.field];
let strValue: string;
if (value === undefined || value === null) {
strValue = "";
} else {
if (typeof value !== "string") {
console.error("Only string fields can be filtered, field is of type " + typeof value);
return false;
} else {
strValue = value;
}
}
switch (filter.operator) {
case "in":
if (!filter.value.includes(strValue)) {
return false;
}
break;
default:
console.error(`Not implemented operator: ${filter.operator}`);
return false;
}
}
return true;
}
}
/**
* Populates an array of `ColumnFilterItem` objects based on the unique values of a given field in an array of objects.
* @param dataSource An array of objects to filter.
* @param field The name of the field to filter on.
* @returns An array of `ColumnFilterItem` objects representing the unique values of the specified field in the input array.
*/
export function useListFiltersForField<Obj, Field extends keyof Obj>(
dataSource: Obj[],
field: Field): ColumnFilterItem[] {
return React.useMemo(() => {
const filters: ColumnFilterItem[] = [];
dataSource.forEach((element) => {
const value = element[field];
if (typeof value === "string" && value !== "") {
// Make sure it's not already in the filters
if (filters.find((f) => f.value === value)) {
return;
}
filters.push({
text: value,
value: value,
});
}
});
// Sort the filters
filters.sort((a, b) => {
if (typeof a.text !== "string" || typeof b.text !== "string") {
return 0;
}
return a.text.localeCompare(b.text);
});
filters.push({
text: "<empty>",
value: "",
});
return filters;
}, [dataSource, field]);
}
/** /**
* Returns an array of filter values for a given field based on the provided filters. * Returns an array of filter values for a given field based on the provided filters.
* @param filters An array of `CrudFilter` objects that define the filtering criteria. * @param filters An array of `CrudFilter` objects that define the filtering criteria.

View File

@@ -7,62 +7,6 @@ interface TypedCrudSort<Obj> {
order: "asc" | "desc"; order: "asc" | "desc";
} }
/**
* Returns a sorting function that can be used to sort an array of objects based on the provided sorters.
* @param sorters An array of `CrudSort` objects that define the sorting criteria.
* @returns A sorting function that can be used to sort an array of objects based on the provided sorters.
*/
export function genericSorter<Obj>(sorters: TypedCrudSort<Obj>[]) {
return (a: Obj, b: Obj) => {
for (const sorter of sorters) {
const aValue = a[sorter.field];
const bValue = b[sorter.field];
if (aValue === bValue) {
continue;
}
// Send empty fields to the bottom
if (aValue === undefined || aValue === null) {
return 1;
}
if (bValue === undefined || bValue === null) {
return -1;
}
// Perform sorting based on type of the fields
if (typeof aValue === "string" && typeof bValue === "string") {
// Try to sort them as dates if possible
const aDate = new Date(aValue);
const bDate = new Date(bValue);
if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) {
return sorter.order === "asc"
? aDate.getTime() - bDate.getTime()
: bDate.getTime() - aDate.getTime();
}
return sorter.order === "asc"
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
if (typeof aValue === "number" && typeof bValue === "number") {
return sorter.order === "asc" ? aValue - bValue : bValue - aValue;
}
if (typeof aValue === "boolean" && typeof bValue === "boolean") {
return sorter.order === "asc" ? +aValue - +bValue : +bValue - +aValue;
}
if (aValue instanceof Date && bValue instanceof Date) {
return sorter.order === "asc"
? aValue.getTime() - bValue.getTime()
: bValue.getTime() - aValue.getTime();
}
if (typeof aValue === "object" && typeof bValue === "object") {
return sorter.order === "asc"
? JSON.stringify(aValue).localeCompare(JSON.stringify(bValue))
: JSON.stringify(bValue).localeCompare(JSON.stringify(aValue));
}
}
return 0;
};
}
/** /**
* Returns the sort order for a given field based on the provided sorters. * Returns the sort order for a given field based on the provided sorters.
* @param sorters An array of `CrudSort` objects that define the sorting criteria. * @param sorters An array of `CrudSort` objects that define the sorting criteria.

View File

@@ -39,6 +39,9 @@ call = "spoolman.docs:generate_docs"
[tool.pdm.scripts.bump] [tool.pdm.scripts.bump]
call = "spoolman.bump:bump" call = "spoolman.bump:bump"
[tool.pdm.scripts.app]
cmd = "uvicorn spoolman.main:app"
[tool.ruff] [tool.ruff]
select = ["ALL"] select = ["ALL"]
ignore = [ ignore = [
@@ -58,12 +61,13 @@ ignore = [
"EM102", "EM102",
"DTZ003", "DTZ003",
"PLR0913", "PLR0913",
"SIM108",
] ]
line-length = 120 line-length = 120
target-version = "py39" target-version = "py39"
[tool.ruff.per-file-ignores] [tool.ruff.per-file-ignores]
"tests*/*" = ["ANN201", "S101", "PLR2004"] "tests*/*" = ["ANN201", "S101", "PLR2004", "D103"]
"migrations/versions/*" = ["N999"] "migrations/versions/*" = ["N999"]
[tool.black] [tool.black]

View File

@@ -4,6 +4,7 @@ import logging
from typing import Annotated, Optional from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
@@ -13,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Filament, Message from spoolman.api.v1.models import Filament, Message
from spoolman.database import filament from spoolman.database import filament
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemDeleteError from spoolman.exceptions import ItemDeleteError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -108,45 +110,119 @@ class FilamentUpdateParameters(FilamentParameters):
name="Find filaments", name="Find filaments",
description="Get a list of filaments that matches the search query.", description="Get a list of filaments that matches the search query.",
response_model_exclude_none=True, response_model_exclude_none=True,
response_model=list[Filament],
) )
async def find( async def find(
*, *,
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
vendor_name: Optional[str] = Query( vendor_name_old: Optional[str] = Query(
alias="vendor_name",
default=None, default=None,
title="Vendor Name", title="Vendor Name",
description="Partial case-insensitive search term for the filament vendor name.", description=(
"Partial case-insensitive search term for the filament vendor name. "
"Separate multiple terms with a comma."
), ),
vendor_id: Optional[int] = Query( deprecated=True,
),
vendor_id_old: Optional[str] = Query(
alias="vendor_id",
default=None, default=None,
title="Vendor ID", title="Vendor ID",
description="Match an exact vendor ID.", description="Match an exact vendor ID. Separate multiple IDs with a comma.",
deprecated=True,
examples=["1", "1,2"],
),
vendor_name: Optional[str] = Query(
alias="vendor.name",
default=None,
title="Vendor Name",
description=(
"Partial case-insensitive search term for the filament vendor name. "
"Separate multiple terms with a comma."
),
),
vendor_id: Optional[str] = Query(
alias="vendor.id",
default=None,
title="Vendor ID",
description="Match an exact vendor ID. Separate multiple IDs with a comma.",
examples=["1", "1,2"],
), ),
name: Optional[str] = Query( name: Optional[str] = Query(
default=None, default=None,
title="Filament Name", title="Filament Name",
description="Partial case-insensitive search term for the filament name.", description="Partial case-insensitive search term for the filament name. Separate multiple terms with a comma.",
), ),
material: Optional[str] = Query( material: Optional[str] = Query(
default=None, default=None,
title="Filament Material", title="Filament Material",
description="Partial case-insensitive search term for the filament material.", description=(
"Partial case-insensitive search term for the filament material. Separate multiple terms with a comma."
),
), ),
article_number: Optional[str] = Query( article_number: Optional[str] = Query(
default=None, default=None,
title="Filament Article Number", title="Filament Article Number",
description="Partial case-insensitive search term for the filament article number.", description=(
"Partial case-insensitive search term for the filament article number. "
"Separate multiple terms with a comma."
), ),
) -> list[Filament]: ),
db_items = await filament.find( sort: Optional[str] = Query(
default=None,
title="Sort",
description=(
'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.'
),
example="vendor.name:asc,spool_weight:desc",
),
limit: Optional[int] = Query(
default=None,
title="Limit",
description="Maximum number of items in the response.",
),
offset: int = Query(
default=0,
title="Offset",
description="Offset in the full result set if a limit is set.",
),
) -> JSONResponse:
sort_by: dict[str, SortOrder] = {}
if sort is not None:
for sort_item in sort.split(","):
field, direction = sort_item.split(":")
sort_by[field] = SortOrder[direction.upper()]
vendor_id = vendor_id if vendor_id is not None else vendor_id_old
if vendor_id is not None:
try:
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
except ValueError as e:
raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e
else:
vendor_ids = None
db_items, total_count = await filament.find(
db=db, db=db,
vendor_name=vendor_name, vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
vendor_id=vendor_id, vendor_id=vendor_ids,
name=name, name=name,
material=material, material=material,
article_number=article_number, article_number=article_number,
sort_by=sort_by,
limit=limit,
offset=offset,
)
# Set x-total-count header for pagination
return JSONResponse(
content=jsonable_encoder(
(Filament.from_db(db_item) for db_item in db_items),
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
) )
return [Filament.from_db(db_item) for db_item in db_items]
@router.get( @router.get(

125
spoolman/api/v1/other.py Normal file
View File

@@ -0,0 +1,125 @@
"""Filament related endpoints."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="",
tags=["other"],
)
# ruff: noqa: D103, B008
@router.get(
"/material",
name="Find materials",
description="Get a list of all filament materials.",
response_model_exclude_none=True,
responses={
200: {
"description": "A list of all filament materials.",
"content": {
"application/json": {
"example": [
"PLA",
"ABS",
"PETG",
],
},
},
},
},
)
async def find_materials(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[str]:
return await filament.find_materials(db=db)
@router.get(
"/article-number",
name="Find article numbers",
description="Get a list of all article numbers.",
response_model_exclude_none=True,
responses={
200: {
"description": "A list of all article numbers.",
"content": {
"application/json": {
"example": [
"123456",
"987654",
],
},
},
},
},
)
async def find_article_numbers(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[str]:
return await filament.find_article_numbers(db=db)
@router.get(
"/lot-number",
name="Find lot numbers",
description="Get a list of all lot numbers.",
response_model_exclude_none=True,
responses={
200: {
"description": "A list of all lot numbers.",
"content": {
"application/json": {
"example": [
"123456",
"987654",
],
},
},
},
},
)
async def find_lot_numbers(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[str]:
return await spool.find_lot_numbers(db=db)
@router.get(
"/location",
name="Find locations",
description="Get a list of all spool locations.",
response_model_exclude_none=True,
responses={
200: {
"description": "A list of all spool locations.",
"content": {
"application/json": {
"example": [
"Printer 1",
"Printer 2",
"Storage Shelf A",
],
},
},
},
},
)
async def find_locations(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[str]:
return await spool.find_locations(db=db)

View File

@@ -13,14 +13,13 @@ from spoolman import env
from spoolman.database.database import backup_global_db from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
from . import filament, models, spool, vendor from . import filament, models, other, spool, vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
app = FastAPI( app = FastAPI(
title="Spoolman REST API v1", title="Spoolman REST API v1",
version="1.0.0", version="1.0.0",
root_path_in_servers=False,
) )
@@ -76,3 +75,4 @@ async def backup(): # noqa: ANN201
app.include_router(filament.router) app.include_router(filament.router)
app.include_router(spool.router) app.include_router(spool.router)
app.include_router(vendor.router) app.include_router(vendor.router)
app.include_router(other.router)

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from typing import Annotated, Optional from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -14,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Spool from spoolman.api.v1.models import Message, Spool
from spoolman.database import spool from spoolman.database import spool
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemCreateError from spoolman.exceptions import ItemCreateError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,63 +68,175 @@ class SpoolUseParameters(BaseModel):
name="Find spool", name="Find spool",
description="Get a list of spools that matches the search query.", description="Get a list of spools that matches the search query.",
response_model_exclude_none=True, response_model_exclude_none=True,
response_model=list[Spool],
) )
async def find( async def find(
*, *,
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
filament_name: Optional[str] = Query( filament_name_old: Optional[str] = Query(
alias="filament_name",
default=None, default=None,
title="Filament Name", title="Filament Name",
description="Partial case-insensitive search term for the filament name.", description="Partial case-insensitive search term for the filament name. Separate multiple terms with a comma.",
deprecated=True,
), ),
filament_id: Optional[int] = Query( filament_id_old: Optional[str] = Query(
alias="filament_id",
default=None, default=None,
title="Filament ID", title="Filament ID",
description="Match an exact filament ID.", description="Match an exact filament ID. Separate multiple IDs with a comma.",
deprecated=True,
examples=["1", "1,2"],
), ),
filament_material: Optional[str] = Query( filament_material_old: Optional[str] = Query(
alias="filament_material",
default=None, default=None,
title="Filament Material", title="Filament Material",
description="Partial case-insensitive search term for the filament material.", description=(
"Partial case-insensitive search term for the filament material. Separate multiple terms with a comma."
), ),
vendor_name: Optional[str] = Query( deprecated=True,
),
vendor_name_old: Optional[str] = Query(
alias="vendor_name",
default=None, default=None,
title="Vendor Name", title="Vendor Name",
description="Partial case-insensitive search term for the filament vendor name.", description=(
"Partial case-insensitive search term for the filament vendor name. Separate multiple terms with a comma."
), ),
vendor_id: Optional[int] = Query( deprecated=True,
),
vendor_id_old: Optional[str] = Query(
alias="vendor_id",
default=None, default=None,
title="Vendor ID", title="Vendor ID",
description="Match an exact vendor ID.", description="Match an exact vendor ID. Separate multiple IDs with a comma.",
deprecated=True,
examples=["1", "1,2"],
),
filament_name: Optional[str] = Query(
alias="filament.name",
default=None,
title="Filament Name",
description="Partial case-insensitive search term for the filament name. Separate multiple terms with a comma.",
),
filament_id: Optional[str] = Query(
alias="filament.id",
default=None,
title="Filament ID",
description="Match an exact filament ID. Separate multiple IDs with a comma.",
examples=["1", "1,2"],
),
filament_material: Optional[str] = Query(
alias="filament.material",
default=None,
title="Filament Material",
description=(
"Partial case-insensitive search term for the filament material. Separate multiple terms with a comma."
),
),
filament_vendor_name: Optional[str] = Query(
alias="filament.vendor.name",
default=None,
title="Vendor Name",
description=(
"Partial case-insensitive search term for the filament vendor name. Separate multiple terms with a comma."
),
),
filament_vendor_id: Optional[str] = Query(
alias="filament.vendor.id",
default=None,
title="Vendor ID",
description="Match an exact vendor ID. Separate multiple IDs with a comma.",
examples=["1", "1,2"],
), ),
location: Optional[str] = Query( location: Optional[str] = Query(
default=None, default=None,
title="Location", title="Location",
description="Partial case-insensitive search term for the spool location.", description=(
"Partial case-insensitive search term for the spool location. Separate multiple terms with a comma."
),
), ),
lot_nr: Optional[str] = Query( lot_nr: Optional[str] = Query(
default=None, default=None,
title="Lot/Batch Number", title="Lot/Batch Number",
description="Partial case-insensitive search term for the spool lot number.", description=(
"Partial case-insensitive search term for the spool lot number. Separate multiple terms with a comma."
),
), ),
allow_archived: bool = Query( allow_archived: bool = Query(
default=False, default=False,
title="Allow Archived", title="Allow Archived",
description="Whether to include archived spools in the search results.", description="Whether to include archived spools in the search results.",
), ),
) -> list[Spool]: sort: Optional[str] = Query(
db_items = await spool.find( default=None,
title="Sort",
description=(
'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.'
),
example="filament.name:asc,filament.vendor.id:asc,location:desc",
),
limit: Optional[int] = Query(
default=None,
title="Limit",
description="Maximum number of items in the response.",
),
offset: int = Query(
default=0,
title="Offset",
description="Offset in the full result set if a limit is set.",
),
) -> JSONResponse:
sort_by: dict[str, SortOrder] = {}
if sort is not None:
for sort_item in sort.split(","):
field, direction = sort_item.split(":")
sort_by[field] = SortOrder[direction.upper()]
filament_id = filament_id if filament_id is not None else filament_id_old
if filament_id is not None:
try:
filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")]
except ValueError as e:
raise RequestValidationError(
[ErrorWrapper(ValueError("Invalid filament_id"), ("query", "filament_id"))],
) from e
else:
filament_ids = None
filament_vendor_id = filament_vendor_id if filament_vendor_id is not None else vendor_id_old
if filament_vendor_id is not None:
try:
filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")]
except ValueError as e:
raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e
else:
filament_vendor_ids = None
db_items, total_count = await spool.find(
db=db, db=db,
filament_name=filament_name, filament_name=filament_name if filament_name is not None else filament_name_old,
filament_id=filament_id, filament_id=filament_ids,
filament_material=filament_material, filament_material=filament_material if filament_material is not None else filament_material_old,
vendor_name=vendor_name, vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
vendor_id=vendor_id, vendor_id=filament_vendor_ids,
location=location, location=location,
lot_nr=lot_nr, lot_nr=lot_nr,
allow_archived=allow_archived, allow_archived=allow_archived,
sort_by=sort_by,
limit=limit,
offset=offset,
)
# Set x-total-count header for pagination
return JSONResponse(
content=jsonable_encoder(
(Spool.from_db(db_item) for db_item in db_items),
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
) )
return [Spool.from_db(db_item) for db_item in db_items]
@router.get( @router.get(

View File

@@ -3,7 +3,9 @@
from typing import Annotated, Optional from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic.error_wrappers import ErrorWrapper from pydantic.error_wrappers import ErrorWrapper
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Vendor from spoolman.api.v1.models import Message, Vendor
from spoolman.database import vendor from spoolman.database import vendor
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
router = APIRouter( router = APIRouter(
prefix="/vendor", prefix="/vendor",
@@ -43,20 +46,55 @@ class VendorUpdateParameters(VendorParameters):
name="Find vendor", name="Find vendor",
description="Get a list of vendors that matches the search query.", description="Get a list of vendors that matches the search query.",
response_model_exclude_none=True, response_model_exclude_none=True,
response_model=list[Vendor],
) )
async def find( async def find(
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
name: Optional[str] = Query( name: Optional[str] = Query(
default=None, default=None,
title="Vendor Name", title="Vendor Name",
description="Partial case-insensitive search term for the vendor name.", description="Partial case-insensitive search term for the vendor name. Separate multiple terms with a comma.",
), ),
) -> list[Vendor]: sort: Optional[str] = Query(
db_items = await vendor.find( default=None,
title="Sort",
description=(
'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.'
),
example="name:asc,id:desc",
),
limit: Optional[int] = Query(
default=None,
title="Limit",
description="Maximum number of items in the response.",
),
offset: int = Query(
default=0,
title="Offset",
description="Offset in the full result set if a limit is set.",
),
) -> JSONResponse:
sort_by: dict[str, SortOrder] = {}
if sort is not None:
for sort_item in sort.split(","):
field, direction = sort_item.split(":")
sort_by[field] = SortOrder[direction.upper()]
db_items, total_count = await vendor.find(
db=db, db=db,
name=name, name=name,
sort_by=sort_by,
limit=limit,
offset=offset,
)
# Set x-total-count header for pagination
return JSONResponse(
content=jsonable_encoder(
(Vendor.from_db(db_item) for db_item in db_items),
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
) )
return [Vendor.from_db(db_item) for db_item in db_items]
@router.get( @router.get(

View File

@@ -1,13 +1,22 @@
"""Helper functions for interacting with filament database objects.""" """Helper functions for interacting with filament database objects."""
from typing import Optional import logging
from collections.abc import Sequence
from typing import Optional, Union
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager, joinedload from sqlalchemy.orm import contains_eager, joinedload
from spoolman.database import models, vendor from spoolman.database import models, vendor
from spoolman.database.utils import (
SortOrder,
add_where_clause_int_opt,
add_where_clause_str,
add_where_clause_str_opt,
parse_nested_field,
)
from spoolman.exceptions import ItemDeleteError, ItemNotFoundError from spoolman.exceptions import ItemDeleteError, ItemNotFoundError
@@ -69,30 +78,55 @@ async def find(
*, *,
db: AsyncSession, db: AsyncSession,
vendor_name: Optional[str] = None, vendor_name: Optional[str] = None,
vendor_id: Optional[int] = None, vendor_id: Optional[Union[int, Sequence[int]]] = None,
name: Optional[str] = None, name: Optional[str] = None,
material: Optional[str] = None, material: Optional[str] = None,
article_number: Optional[str] = None, article_number: Optional[str] = None,
) -> list[models.Filament]: sort_by: Optional[dict[str, SortOrder]] = None,
"""Find a list of filament objects by search criteria.""" limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.Filament], int]:
"""Find a list of filament objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. vendor.name.
Returns a tuple containing the list of items and the total count of matching items.
"""
stmt = ( stmt = (
select(models.Filament) select(models.Filament)
.options(contains_eager(models.Filament.vendor)) .options(contains_eager(models.Filament.vendor))
.join(models.Filament.vendor, isouter=True) .join(models.Filament.vendor, isouter=True)
) )
if vendor_name is not None:
stmt = stmt.where(models.Vendor.name.ilike(f"%{vendor_name}%")) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
if vendor_id is not None: stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
stmt = stmt.where(models.Filament.vendor_id == vendor_id) stmt = add_where_clause_str_opt(stmt, models.Filament.name, name)
if name is not None: stmt = add_where_clause_str_opt(stmt, models.Filament.material, material)
stmt = stmt.where(models.Filament.name.ilike(f"%{name}%")) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number)
if material is not None:
stmt = stmt.where(models.Filament.material.ilike(f"%{material}%")) total_count = None
if article_number is not None:
stmt = stmt.where(models.Filament.article_number.ilike(f"%{article_number}%")) if limit is not None:
total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True)
total_count = (await db.execute(total_count_stmt)).scalar()
stmt = stmt.offset(offset).limit(limit)
if sort_by is not None:
for fieldstr, order in sort_by.items():
field = parse_nested_field(models.Filament, fieldstr)
if order == SortOrder.ASC:
stmt = stmt.order_by(field.asc())
elif order == SortOrder.DESC:
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
return list(rows.scalars().all()) result = list(rows.scalars().all())
if total_count is None:
total_count = len(result)
return result, total_count
async def update( async def update(
@@ -124,3 +158,26 @@ async def delete(db: AsyncSession, filament_id: int) -> None:
except IntegrityError as exc: except IntegrityError as exc:
await db.rollback() await db.rollback()
raise ItemDeleteError("Failed to delete filament.") from exc raise ItemDeleteError("Failed to delete filament.") from exc
logger = logging.getLogger(__name__)
async def find_materials(
*,
db: AsyncSession,
) -> list[str]:
"""Find a list of filament materials by searching for distinct values in the filament table."""
stmt = select(models.Filament.material).distinct()
rows = await db.execute(stmt)
return [row[0] for row in rows.all() if row[0] is not None]
async def find_article_numbers(
*,
db: AsyncSession,
) -> list[str]:
"""Find a list of filament article numbers by searching for distinct values in the filament table."""
stmt = select(models.Filament.article_number).distinct()
rows = await db.execute(stmt)
return [row[0] for row in rows.all() if row[0] is not None]

View File

@@ -1,15 +1,24 @@
"""Helper functions for interacting with spool database objects.""" """Helper functions for interacting with spool database objects."""
from collections.abc import Sequence
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional, Union
import sqlalchemy import sqlalchemy
from sqlalchemy import case from sqlalchemy import case, func
from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager, joinedload from sqlalchemy.orm import contains_eager, joinedload
from spoolman.database import filament, models from spoolman.database import filament, models
from spoolman.database.utils import (
SortOrder,
add_where_clause_int,
add_where_clause_int_opt,
add_where_clause_str,
add_where_clause_str_opt,
parse_nested_field,
)
from spoolman.exceptions import ItemCreateError, ItemNotFoundError from spoolman.exceptions import ItemCreateError, ItemNotFoundError
from spoolman.math import weight_from_length from spoolman.math import weight_from_length
@@ -79,35 +88,39 @@ async def find(
*, *,
db: AsyncSession, db: AsyncSession,
filament_name: Optional[str] = None, filament_name: Optional[str] = None,
filament_id: Optional[int] = None, filament_id: Optional[Union[int, Sequence[int]]] = None,
filament_material: Optional[str] = None, filament_material: Optional[str] = None,
vendor_name: Optional[str] = None, vendor_name: Optional[str] = None,
vendor_id: Optional[int] = None, vendor_id: Optional[Union[int, Sequence[int]]] = None,
location: Optional[str] = None, location: Optional[str] = None,
lot_nr: Optional[str] = None, lot_nr: Optional[str] = None,
allow_archived: bool = False, allow_archived: bool = False,
) -> list[models.Spool]: sort_by: Optional[dict[str, SortOrder]] = None,
"""Find a list of spool objects by search criteria.""" limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.Spool], int]:
"""Find a list of spool objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. filament.name.
Returns a tuple containing the list of items and the total count of matching items.
"""
stmt = ( stmt = (
sqlalchemy.select(models.Spool) sqlalchemy.select(models.Spool)
.join(models.Spool.filament, isouter=True) .join(models.Spool.filament, isouter=True)
.join(models.Filament.vendor, isouter=True) .join(models.Filament.vendor, isouter=True)
.options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor)) .options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor))
) )
if filament_name is not None:
stmt = stmt.where(models.Filament.name.ilike(f"%{filament_name}%")) stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id)
if filament_id is not None: stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
stmt = stmt.where(models.Spool.filament_id == filament_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
if filament_material is not None: stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name)
stmt = stmt.where(models.Filament.material.ilike(f"%{filament_material}%")) stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material)
if vendor_name is not None: stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
stmt = stmt.where(models.Vendor.name.ilike(f"%{vendor_name}%")) stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
if vendor_id is not None:
stmt = stmt.where(models.Filament.vendor_id == vendor_id)
if location is not None:
stmt = stmt.where(models.Spool.location.ilike(f"%{location}%"))
if lot_nr is not None:
stmt = stmt.where(models.Spool.lot_nr.ilike(f"%{lot_nr}%"))
if not allow_archived: if not allow_archived:
# Since the archived field is nullable, and default is false, we need to check for both false or null # Since the archived field is nullable, and default is false, we need to check for both false or null
stmt = stmt.where( stmt = stmt.where(
@@ -117,8 +130,33 @@ async def find(
), ),
) )
total_count = None
if limit is not None:
total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True)
total_count = (await db.execute(total_count_stmt)).scalar()
stmt = stmt.offset(offset).limit(limit)
if sort_by is not None:
for fieldstr, order in sort_by.items():
if fieldstr == "remaining_weight":
stmt = stmt.add_columns((models.Filament.weight - models.Spool.used_weight).label("remaining_weight"))
field = sqlalchemy.column("remaining_weight")
else:
field = parse_nested_field(models.Spool, fieldstr)
if order == SortOrder.ASC:
stmt = stmt.order_by(field.asc())
elif order == SortOrder.DESC:
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
return list(rows.scalars().all()) result = list(rows.scalars().all())
if total_count is None:
total_count = len(result)
return result, total_count
async def update( async def update(
@@ -238,3 +276,23 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
await db.commit() await db.commit()
return spool return spool
async def find_locations(
*,
db: AsyncSession,
) -> list[str]:
"""Find a list of spool locations by searching for distinct values in the spool table."""
stmt = sqlalchemy.select(models.Spool.location).distinct()
rows = await db.execute(stmt)
return [row[0] for row in rows.all() if row[0] is not None]
async def find_lot_numbers(
*,
db: AsyncSession,
) -> list[str]:
"""Find a list of spool lot numbers by searching for distinct values in the spool table."""
stmt = sqlalchemy.select(models.Spool.lot_nr).distinct()
rows = await db.execute(stmt)
return [row[0] for row in rows.all() if row[0] is not None]

View File

@@ -0,0 +1,98 @@
"""Utility functions for the database module."""
from collections.abc import Sequence
from enum import Enum
from typing import Any, Optional, Union
import sqlalchemy
from sqlalchemy import Select
from sqlalchemy.orm import attributes
from spoolman.database import models
class SortOrder(Enum):
ASC = 1
DESC = 2
def parse_nested_field(base_obj: type[models.Base], field: str) -> attributes.InstrumentedAttribute[Any]:
"""Parse a nested field string into a sqlalchemy field object."""
fields = field.split(".")
if not hasattr(base_obj, fields[0]):
raise ValueError(f"Invalid field name '{field}', '{fields[0]}' is not a valid field on '{base_obj.__name__}'.")
if fields[0] == "filament" and len(fields) == 1:
raise ValueError("No field specified for filament")
if fields[0] == "filament":
return parse_nested_field(models.Filament, ".".join(fields[1:]))
if fields[0] == "vendor" and len(fields) == 1:
raise ValueError("No field specified for vendor")
if fields[0] == "vendor":
return parse_nested_field(models.Vendor, ".".join(fields[1:]))
if len(fields) > 1:
raise ValueError(f"Field '{fields[0]}' does not have any nested fields")
return getattr(base_obj, fields[0])
def add_where_clause_str_opt(
stmt: Select,
field: attributes.InstrumentedAttribute[Optional[str]],
value: Optional[str],
) -> Select:
"""Add a where clause to a select statement for an optional string field."""
if value is not None:
if len(value) == 0:
stmt = stmt.where(sqlalchemy.or_(field.is_(None), field == ""))
else:
stmt = stmt.where(sqlalchemy.or_(*(field.ilike(f"%{value}%") for value in value.split(","))))
return stmt
def add_where_clause_str(
stmt: Select,
field: attributes.InstrumentedAttribute[str],
value: Optional[str],
) -> Select:
"""Add a where clause to a select statement for a string field."""
if value is not None:
if len(value) == 0:
stmt = stmt.where(field == "")
else:
stmt = stmt.where(sqlalchemy.or_(*(field.ilike(f"%{value}%") for value in value.split(","))))
return stmt
def add_where_clause_int(
stmt: Select,
field: attributes.InstrumentedAttribute[int],
value: Optional[Union[int, Sequence[int]]],
) -> Select:
"""Add a where clause to a select statement for a field."""
if value is not None:
if isinstance(value, int):
value = [value]
stmt = stmt.where(field.in_(value))
return stmt
def add_where_clause_int_opt(
stmt: Select,
field: attributes.InstrumentedAttribute[Optional[int]],
value: Optional[Union[int, Sequence[int]]],
) -> Select:
"""Add a where clause to a select statement for a field."""
if value is not None:
if isinstance(value, int):
value = [value]
statements = []
for value_part in value:
if value_part == -1:
statements.append(field.is_(None))
else:
statements.append(field == value_part)
stmt = stmt.where(sqlalchemy.or_(*statements))
return stmt

View File

@@ -2,10 +2,11 @@
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import models from spoolman.database import models
from spoolman.database.utils import SortOrder, add_where_clause_str
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
@@ -37,14 +38,40 @@ async def find(
*, *,
db: AsyncSession, db: AsyncSession,
name: Optional[str] = None, name: Optional[str] = None,
) -> list[models.Vendor]: sort_by: Optional[dict[str, SortOrder]] = None,
"""Find a list of vendor objects by search criteria.""" limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.Vendor], int]:
"""Find a list of vendor objects by search criteria.
Returns a tuple containing the list of items and the total count of matching items.
"""
stmt = select(models.Vendor) stmt = select(models.Vendor)
if name is not None:
stmt = stmt.where(models.Vendor.name.ilike(f"%{name}%")) stmt = add_where_clause_str(stmt, models.Vendor.name, name)
total_count = None
if limit is not None:
total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True)
total_count = (await db.execute(total_count_stmt)).scalar()
stmt = stmt.offset(offset).limit(limit)
if sort_by is not None:
for fieldstr, order in sort_by.items():
field = getattr(models.Vendor, fieldstr)
if order == SortOrder.ASC:
stmt = stmt.order_by(field.asc())
elif order == SortOrder.DESC:
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
return list(rows.scalars().all()) result = list(rows.scalars().all())
if total_count is None:
total_count = len(result)
return result, total_count
async def update( async def update(

View File

@@ -56,6 +56,7 @@ if env.is_debug_mode():
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["X-Total-Count"],
) )

View File

@@ -3,6 +3,7 @@
import math import math
import os import os
import time import time
from contextlib import contextmanager
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
@@ -50,8 +51,8 @@ def _wait_for_server(): # noqa: ANN202
break break
@pytest.fixture() @contextmanager
def random_vendor(): def random_vendor_impl():
"""Return a random vendor.""" """Return a random vendor."""
# Add vendor # Add vendor
result = httpx.post( result = httpx.post(
@@ -60,16 +61,34 @@ def random_vendor():
) )
result.raise_for_status() result.raise_for_status()
vendor = result.json() vendor: dict[str, Any] = result.json()
yield vendor yield vendor
# Delete vendor # Delete vendor
httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status()
@pytest.fixture() @contextmanager
def random_filament(random_vendor: dict[str, Any]): def random_empty_vendor_impl():
"""Return a random vendor with only required fields specified."""
# Add vendor
result = httpx.post(
f"{URL}/api/v1/vendor",
json={"name": ""},
)
result.raise_for_status()
vendor: dict[str, Any] = result.json()
yield vendor
# Delete vendor
httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status()
@contextmanager
def random_filament_impl():
"""Return a random filament.""" """Return a random filament."""
with random_vendor_impl() as random_vendor:
# Add filament # Add filament
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
@@ -88,13 +107,125 @@ def random_filament(random_vendor: dict[str, Any]):
) )
result.raise_for_status() result.raise_for_status()
filament = result.json() filament: dict[str, Any] = result.json()
yield filament yield filament
# Delete filament # Delete filament
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
@contextmanager
def random_empty_filament_impl():
"""Return a random filament with only required fields specified."""
# Add filament
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
},
)
result.raise_for_status()
filament: dict[str, Any] = result.json()
yield filament
# Delete filament
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
@contextmanager
def random_empty_filament_empty_vendor_impl():
"""Return a random filament with only required fields specified and a vendor with only required fields specified."""
with random_empty_vendor_impl() as random_empty_vendor:
# Add filament
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"vendor_id": random_empty_vendor["id"],
"density": 1.25,
"diameter": 1.75,
},
)
result.raise_for_status()
filament: dict[str, Any] = result.json()
yield filament
# Delete filament
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
@pytest.fixture()
def random_vendor():
"""Return a random vendor."""
with random_vendor_impl() as random_vendor:
yield random_vendor
@pytest.fixture()
def random_empty_vendor():
"""Return a random vendor with only required fields specified."""
with random_empty_vendor_impl() as random_empty_vendor:
yield random_empty_vendor
@pytest.fixture()
def random_filament():
"""Return a random filament."""
with random_filament_impl() as random_filament:
yield random_filament
@pytest.fixture()
def random_empty_filament():
"""Return a random filament with only required fields specified."""
with random_empty_filament_impl() as random_empty_filament:
yield random_empty_filament
@pytest.fixture()
def random_empty_filament_empty_vendor():
"""Return a random filament with only required fields specified and a vendor with only required fields specified."""
with random_empty_filament_empty_vendor_impl() as random_empty_filament_empty_vendor:
yield random_empty_filament_empty_vendor
@pytest.fixture(scope="module")
def random_vendor_mod():
"""Return a random vendor."""
with random_vendor_impl() as random_vendor:
yield random_vendor
@pytest.fixture(scope="module")
def random_empty_vendor_mod():
"""Return a random vendor with only required fields specified."""
with random_empty_vendor_impl() as random_empty_vendor:
yield random_empty_vendor
@pytest.fixture(scope="module")
def random_filament_mod():
"""Return a random filament."""
with random_filament_impl() as random_filament:
yield random_filament
@pytest.fixture(scope="module")
def random_empty_filament_mod():
"""Return a random filament with only required fields specified."""
with random_empty_filament_impl() as random_empty_filament:
yield random_empty_filament
@pytest.fixture(scope="module")
def random_empty_filament_empty_vendor_mod():
"""Return a random filament with only required fields specified and a vendor with only required fields specified."""
with random_empty_filament_empty_vendor_impl() as random_empty_filament_empty_vendor:
yield random_empty_filament_empty_vendor
def length_from_weight(*, weight: float, diameter: float, density: float) -> float: def length_from_weight(*, weight: float, diameter: float, density: float) -> float:
"""Calculate the length of a piece of filament. """Calculate the length of a piece of filament.

View File

@@ -1,20 +1,33 @@
"""Integration tests for the Filament API endpoint.""" """Integration tests for the Filament API endpoint."""
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any from typing import Any
import httpx import httpx
import pytest
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
def test_find_filaments(random_vendor: dict[str, Any]): def filament_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
"""Test finding filaments from the database.""" """Compare two lists of filaments where the order of the filaments is not guaranteed."""
# Setup return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
@dataclass
class Fixture:
filaments: list[dict[str, Any]]
@pytest.fixture(scope="module")
def filaments(random_vendor_mod: dict[str, Any], random_empty_vendor_mod: dict[str, Any]) -> Iterable[Fixture]:
"""Add some filaments to the database."""
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
json={ json={
"name": "Filament X", "name": "Filament X",
"vendor_id": random_vendor["id"], "vendor_id": random_vendor_mod["id"],
"material": "PLA", "material": "PLA",
"price": 100, "price": 100,
"density": 1.25, "density": 1.25,
@@ -32,7 +45,7 @@ def test_find_filaments(random_vendor: dict[str, Any]):
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
json={ json={
"name": "Filament Y", "name": "Filament Y",
"vendor_id": random_vendor["id"], "vendor_id": random_vendor_mod["id"],
"material": "ABS", "material": "ABS",
"price": 200, "price": 200,
"density": 1.25, "density": 1.25,
@@ -46,24 +59,202 @@ def test_find_filaments(random_vendor: dict[str, Any]):
result.raise_for_status() result.raise_for_status()
filament_2 = result.json() filament_2 = result.json()
added_filaments_by_id = { result = httpx.post(
filament_1["id"]: filament_1, f"{URL}/api/v1/filament",
filament_2["id"]: filament_2, json={
} "name": "Filament Z",
"material": "PETG",
"price": 200,
"density": 1.25,
"diameter": 1.75,
"weight": 1000,
"spool_weight": 250,
"article_number": "abc",
"comment": "abcdefghåäö",
},
)
result.raise_for_status()
filament_3 = result.json()
# Execute - find all filaments result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
},
)
result.raise_for_status()
filament_4 = result.json()
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"vendor_id": random_empty_vendor_mod["id"],
"density": 1.5,
"diameter": 1.75,
},
)
result.raise_for_status()
filament_5 = result.json()
yield Fixture(
filaments=[filament_1, filament_2, filament_3, filament_4, filament_5],
)
httpx.delete(f"{URL}/api/v1/filament/{filament_1['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/filament/{filament_2['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/filament/{filament_3['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/filament/{filament_4['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/filament/{filament_5['id']}").raise_for_status()
def test_find_all_filaments(filaments: Fixture):
# Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
filaments = result.json() filaments_result = result.json()
assert len(filaments) == 2 assert filament_lists_equal(filaments_result, filaments.filaments)
for filament in filaments:
assert filament == added_filaments_by_id[filament["id"]]
# Execute - find filaments by name
def test_find_all_filaments_sort_asc(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc")
result.raise_for_status()
# Verify
filaments_result = result.json()
assert len(filaments_result) == len(filaments.filaments)
assert filaments_result[0] == filaments.filaments[0]
def test_find_all_filaments_sort_desc(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:desc")
result.raise_for_status()
# Verify
filaments_result = result.json()
assert len(filaments_result) == len(filaments.filaments)
assert filaments_result[-1] == filaments.filaments[0]
def test_find_all_filaments_sort_multiple(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=density:desc,id:asc")
result.raise_for_status()
# Verify
filaments_result = result.json()
assert len(filaments_result) == len(filaments.filaments)
assert filaments_result == [
filaments.filaments[4],
filaments.filaments[0],
filaments.filaments[1],
filaments.filaments[2],
filaments.filaments[3],
]
def test_find_all_filaments_limit_asc(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc&limit=2")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
filaments_result = result.json()
assert len(filaments_result) == 2
assert filaments_result == [filaments.filaments[0], filaments.filaments[1]]
def test_find_all_filaments_limit_desc(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:desc&limit=2")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
filaments_result = result.json()
assert len(filaments_result) == 2
assert filaments_result == [filaments.filaments[-1], filaments.filaments[-2]]
def test_find_all_filaments_limit_asc_offset(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc&limit=2&offset=1")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
filaments_result = result.json()
assert len(filaments_result) == 2
assert filaments_result == [filaments.filaments[1], filaments.filaments[2]]
def test_find_all_filaments_limit_desc_offset(filaments: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:desc&limit=2&offset=1")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
filaments_result = result.json()
assert len(filaments_result) == 2
assert filaments_result == [filaments.filaments[-2], filaments.filaments[-3]]
def test_find_all_filaments_limit_asc_offset_outside_range(filaments: Fixture): # noqa: ARG001
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc&limit=2&offset=100")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
filaments_result = result.json()
assert len(filaments_result) == 0
@pytest.mark.parametrize(
"field_name",
[
"id",
"registered",
"name",
"vendor_id",
"material",
"price",
"density",
"diameter",
"weight",
"spool_weight",
"article_number",
"comment",
"settings_extruder_temp",
"settings_bed_temp",
"color_hex",
"vendor.id",
"vendor.registered",
"vendor.name",
"vendor.comment",
],
)
def test_find_all_filaments_sort_fields(filaments: Fixture, field_name: str):
"""Test sorting by all fields."""
# Execute
result = httpx.get(f"{URL}/api/v1/filament?sort={field_name}:asc")
result.raise_for_status()
# Verify
filaments_result = result.json()
assert len(filaments_result) == len(filaments.filaments)
def test_find_filaments_by_name(filaments: Fixture):
# Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
params={"name": "Filament X"}, params={"name": "Filament X"},
@@ -71,11 +262,25 @@ def test_find_filaments(random_vendor: dict[str, Any]):
result.raise_for_status() result.raise_for_status()
# Verify # Verify
filaments = result.json() filaments_result = result.json()
assert len(filaments) == 1 assert filaments_result[0] == filaments.filaments[0]
assert filaments[0] == added_filaments_by_id[filament_1["id"]]
# Execute - find filaments by material
def test_find_filaments_by_empty_name(filaments: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/filament",
params={"name": ""},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[3:])
def test_find_filaments_by_material(filaments: Fixture):
# Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
params={"material": "abs"}, params={"material": "abs"},
@@ -83,37 +288,114 @@ def test_find_filaments(random_vendor: dict[str, Any]):
result.raise_for_status() result.raise_for_status()
# Verify # Verify
filaments = result.json() filaments_result = result.json()
assert len(filaments) == 1 assert filaments_result == [filaments.filaments[1]]
assert filaments[0] == added_filaments_by_id[filament_2["id"]]
# Execute - find filaments by vendor id
def test_find_filaments_by_multiple_materials(filaments: Fixture):
# Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
params={"vendor_id": random_vendor["id"]}, params={"material": "abs,pla"},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
filaments = result.json() filaments_result = result.json()
assert len(filaments) == 2 assert filament_lists_equal(filaments_result, filaments.filaments[:2])
for filament in filaments:
assert filament == added_filaments_by_id[filament["id"]]
# Execute - find filaments by vendor name
def test_find_filaments_by_empty_material(filaments: Fixture):
# Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
params={"vendor_name": random_vendor["name"]}, params={"material": ""},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
filaments = result.json() filaments_result = result.json()
assert len(filaments) == 2 assert filament_lists_equal(filaments_result, filaments.filaments[3:])
for filament in filaments:
assert filament == added_filaments_by_id[filament["id"]]
# Execute - find filaments by article number
@pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"])
def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str):
# Execute
result = httpx.get(
f"{URL}/api/v1/filament",
params={field_name: filaments.filaments[0]["vendor"]["id"]},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture):
# Execute
vendor_1 = filaments.filaments[0]["vendor"]["id"]
vendor_2 = filaments.filaments[4]["vendor"]["id"]
result = httpx.get(
f"{URL}/api/v1/filament",
params={"vendor.id": f"{vendor_1},{vendor_2}"},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filament_lists_equal(
filaments_result,
[
filaments.filaments[0],
filaments.filaments[1],
filaments.filaments[4],
],
)
def test_find_filaments_by_empty_vendor_id(filaments: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/filament",
params={"vendor.id": -1},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[2:4])
@pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"])
def test_find_filaments_by_vendor_name(filaments: Fixture, field_name: str):
# Execute
result = httpx.get(
f"{URL}/api/v1/filament",
params={field_name: filaments.filaments[0]["vendor"]["name"]},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_empty_vendor_name(filaments: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/filament",
params={"vendor.name": ""},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filaments_result == [filaments.filaments[4]]
def test_find_filaments_by_article_number(filaments: Fixture):
# Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/filament", f"{URL}/api/v1/filament",
params={"article_number": "321"}, params={"article_number": "321"},
@@ -121,10 +403,18 @@ def test_find_filaments(random_vendor: dict[str, Any]):
result.raise_for_status() result.raise_for_status()
# Verify # Verify
filaments = result.json() filaments_result = result.json()
assert len(filaments) == 1 assert filaments_result[0] == filaments.filaments[1]
assert filaments[0] == added_filaments_by_id[filament_2["id"]]
# Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament_1['id']}").raise_for_status() def test_find_filaments_by_empty_article_number(filaments: Fixture):
httpx.delete(f"{URL}/api/v1/filament/{filament_2['id']}").raise_for_status() # Execute
result = httpx.get(
f"{URL}/api/v1/filament",
params={"article_number": ""},
)
result.raise_for_status()
# Verify
filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[3:])

View File

@@ -1,6 +1,6 @@
"""Integration tests for the Spool API endpoint.""" """Integration tests for the Spool API endpoint."""
from collections.abc import Iterator from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -10,20 +10,28 @@ import pytest
URL = "http://spoolman:8000" 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 @dataclass
class Fixture: class Fixture:
spools: list[dict[str, Any]] spools: list[dict[str, Any]]
spools_by_id: dict[str, dict[str, Any]]
filament: dict[str, Any] filament: dict[str, Any]
@pytest.fixture() @pytest.fixture(scope="module")
def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]: def spools(
random_filament_mod: dict[str, Any],
random_empty_filament_mod: dict[str, Any],
random_empty_filament_empty_vendor_mod: dict[str, Any],
) -> Iterable[Fixture]:
"""Add some spools to the database.""" """Add some spools to the database."""
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
json={ json={
"filament_id": random_filament["id"], "filament_id": random_filament_mod["id"],
"remaining_weight": 1000, "remaining_weight": 1000,
"location": "The Pantry", "location": "The Pantry",
"lot_nr": "123456789", "lot_nr": "123456789",
@@ -35,7 +43,7 @@ def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
json={ json={
"filament_id": random_filament["id"], "filament_id": random_filament_mod["id"],
"remaining_weight": 1000, "remaining_weight": 1000,
"location": "Living Room", "location": "Living Room",
"lot_nr": "987654321", "lot_nr": "987654321",
@@ -47,7 +55,7 @@ def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
json={ json={
"filament_id": random_filament["id"], "filament_id": random_filament_mod["id"],
"remaining_weight": 1000, "remaining_weight": 1000,
"archived": True, "archived": True,
}, },
@@ -55,132 +63,363 @@ def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
result.raise_for_status() result.raise_for_status()
spool_3 = result.json() spool_3 = result.json()
added_spools_by_id = { result = httpx.post(
spool_1["id"]: spool_1, f"{URL}/api/v1/spool",
spool_2["id"]: spool_2, json={
spool_3["id"]: spool_3, "filament_id": random_empty_filament_mod["id"],
} "used_weight": 1000,
},
)
result.raise_for_status()
spool_4 = result.json()
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_empty_filament_empty_vendor_mod["id"],
},
)
result.raise_for_status()
spool_5 = result.json()
yield Fixture( yield Fixture(
spools=[spool_1, spool_2, spool_3], spools=[spool_1, spool_2, spool_3, spool_4, spool_5],
spools_by_id=added_spools_by_id, filament=random_filament_mod,
filament=random_filament,
) )
httpx.delete(f"{URL}/api/v1/spool/{spool_1['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_1['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/spool/{spool_2['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_2['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/spool/{spool_3['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_3['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/spool/{spool_4['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/spool/{spool_5['id']}").raise_for_status()
def test_find_all_spools(spools: Fixture): def test_find_all_spools(spools: Fixture):
"""Test finding all non-archived spools in the database."""
# Execute # Execute
result = httpx.get(f"{URL}/api/v1/spool") result = httpx.get(f"{URL}/api/v1/spool")
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 2 assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4]))
for spool in spools_result:
assert spool == spools.spools_by_id[spool["id"]]
assert spool["archived"] is False
def test_find_all_spools_including_archived(spools: Fixture): def test_find_all_spools_including_archived(spools: Fixture):
"""Test finding all spools in the database, including archived ones."""
# Execute # Execute
result = httpx.get(f"{URL}/api/v1/spool?allow_archived=true") result = httpx.get(f"{URL}/api/v1/spool?allow_archived=true")
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 3 assert spool_lists_equal(
for spool in spools_result: spools_result,
assert spool == spools.spools_by_id[spool["id"]] (
spools.spools[0],
spools.spools[1],
spools.spools[2],
spools.spools[3],
spools.spools[4],
),
)
def test_find_spools_by_filament_name(spools: Fixture): def test_find_all_spools_sort_asc(spools: Fixture):
"""Test finding spools by filament name.""" # Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&allow_archived=true")
result.raise_for_status()
# Verify
spools_result = result.json()
assert len(spools_result) == len(spools.spools)
assert spools_result[0] == spools.spools[0]
def test_find_all_spools_sort_desc(spools: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc&allow_archived=true")
result.raise_for_status()
# Verify
spools_result = result.json()
assert len(spools_result) == len(spools.spools)
assert spools_result[-1] == spools.spools[0]
def test_find_all_spools_sort_multiple(spools: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=used_weight:desc,id:asc&allow_archived=true")
result.raise_for_status()
# Verify
spools_result = result.json()
assert len(spools_result) == len(spools.spools)
assert spools_result == [spools.spools[3], spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4]]
def test_find_all_spools_limit_asc(spools: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "4"
spools_result = result.json()
assert len(spools_result) == 2
assert spools_result == [spools.spools[0], spools.spools[1]]
def test_find_all_spools_limit_desc(spools: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc&limit=2")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "4"
spools_result = result.json()
assert len(spools_result) == 2
assert spools_result == [spools.spools[-1], spools.spools[-2]]
def test_find_all_spools_limit_asc_offset(spools: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2&offset=1&allow_archived=true")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
spools_result = result.json()
assert len(spools_result) == 2
assert spools_result == [spools.spools[1], spools.spools[2]]
def test_find_all_spools_limit_desc_offset(spools: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc&limit=2&offset=1&allow_archived=true")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "5"
spools_result = result.json()
assert len(spools_result) == 2
assert spools_result == [spools.spools[-2], spools.spools[-3]]
def test_find_all_spools_limit_asc_offset_outside_range(spools: Fixture): # noqa: ARG001
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2&offset=100")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "4"
spools_result = result.json()
assert len(spools_result) == 0
@pytest.mark.parametrize(
"field_name",
[
"id",
"registered",
"first_used",
"last_used",
"filament_id",
"used_weight",
"remaining_weight",
"location",
"lot_nr",
"comment",
"archived",
"filament.id",
"filament.registered",
"filament.name",
"filament.vendor_id",
"filament.material",
"filament.price",
"filament.density",
"filament.diameter",
"filament.weight",
"filament.spool_weight",
"filament.article_number",
"filament.comment",
"filament.settings_extruder_temp",
"filament.settings_bed_temp",
"filament.color_hex",
"filament.vendor.id",
"filament.vendor.registered",
"filament.vendor.name",
"filament.vendor.comment",
],
)
def test_find_all_spools_sort_fields(spools: Fixture, field_name: str):
"""Test sorting by all fields."""
# Execute
result = httpx.get(f"{URL}/api/v1/spool?sort={field_name}:asc&allow_archived=true")
result.raise_for_status()
# Verify
spools_result = result.json()
assert len(spools_result) == len(spools.spools)
@pytest.mark.parametrize("field_name", ["filament_name", "filament.name"])
def test_find_spools_by_filament_name(spools: Fixture, field_name: str):
# Execute # Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
params={"filament_name": spools.filament["name"]}, params={field_name: spools.filament["name"]},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 2 assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
for spool in spools_result:
assert spool == spools.spools_by_id[spool["id"]]
def test_find_spools_by_filament_id(spools: Fixture): def test_find_spools_by_empty_filament_name(spools: Fixture):
"""Test finding spools by filament id."""
# Execute # Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
params={"filament_id": spools.filament["id"]}, params={"filament.name": ""},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 2 assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
for spool in spools_result:
assert spool == spools.spools_by_id[spool["id"]]
def test_find_spools_by_filament_material(spools: Fixture): @pytest.mark.parametrize("field_name", ["filament_id", "filament.id"])
"""Test finding spools by filament material.""" def test_find_spools_by_filament_id(spools: Fixture, field_name: str):
# Execute # Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
params={"filament_material": spools.filament["material"]}, params={field_name: spools.filament["id"]},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 2 assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
for spool in spools_result:
assert spool == spools.spools_by_id[spool["id"]]
def test_find_spools_by_filament_vendor_name(spools: Fixture): def test_find_spools_by_multiple_filament_ids(spools: Fixture):
"""Test finding spools by filament vendor name."""
# Execute # Execute
filament_1 = spools.spools[0]["filament"]["id"]
filament_2 = spools.spools[3]["filament"]["id"]
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
params={"vendor_name": spools.filament["vendor"]["name"]}, params={"filament.id": f"{filament_1},{filament_2}"},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 2 assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3]))
for spool in spools_result:
assert spool == spools.spools_by_id[spool["id"]]
def test_find_spools_by_filament_vendor_id(spools: Fixture): @pytest.mark.parametrize("field_name", ["filament_material", "filament.material"])
"""Test finding spools by filament vendor id.""" def test_find_spools_by_filament_material(spools: Fixture, field_name: str):
# Execute # Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
params={"vendor_id": spools.filament["vendor"]["id"]}, params={field_name: spools.filament["material"]},
) )
result.raise_for_status() result.raise_for_status()
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 2 assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
for spool in spools_result:
assert spool == spools.spools_by_id[spool["id"]]
def test_find_spools_by_empty_filament_material(spools: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={"filament.material": ""},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
@pytest.mark.parametrize("field_name", ["vendor_name", "filament.vendor.name"])
def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={field_name: spools.filament["vendor"]["name"]},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_vendor_name(spools: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={"filament.vendor.name": ""},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spools_result == [spools.spools[4]]
@pytest.mark.parametrize("field_name", ["vendor_id", "filament.vendor.id"])
def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={field_name: spools.filament["vendor"]["id"]},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_multiple_vendor_ids(spools: Fixture):
# Execute
vendor_1 = spools.spools[0]["filament"]["vendor"]["id"]
vendor_2 = spools.spools[4]["filament"]["vendor"]["id"]
result = httpx.get(
f"{URL}/api/v1/spool",
params={
"filament.vendor.id": f"{vendor_1},{vendor_2}",
"allow_archived": True,
},
)
result.raise_for_status()
# 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]))
def test_find_spools_by_empty_filament_vendor_id(spools: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={"filament.vendor.id": -1},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spools_result == [spools.spools[3]]
def test_find_spools_by_location(spools: Fixture): def test_find_spools_by_location(spools: Fixture):
"""Test finding spools by location."""
# Execute # Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
@@ -190,12 +429,23 @@ def test_find_spools_by_location(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 1 assert spools_result == [spools.spools[0]]
assert spools_result[0] == spools.spools[0]
def test_find_spools_by_empty_location(spools: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={"location": ""},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
def test_find_spools_by_lot_nr(spools: Fixture): def test_find_spools_by_lot_nr(spools: Fixture):
"""Test finding spools by lot nr."""
# Execute # Execute
result = httpx.get( result = httpx.get(
f"{URL}/api/v1/spool", f"{URL}/api/v1/spool",
@@ -205,5 +455,17 @@ def test_find_spools_by_lot_nr(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 1 assert spools_result == [spools.spools[0]]
assert spools_result[0] == spools.spools[0]
def test_find_spools_by_empty_lot_nr(spools: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/spool",
params={"lot_nr": ""},
)
result.raise_for_status()
# Verify
spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))

View File

@@ -1,67 +1,192 @@
"""Integration tests for the Vendor API endpoint.""" """Integration tests for the Vendor API endpoint."""
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import httpx import httpx
import pytest
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
def test_find_vendors(): def vendor_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
"""Test finding vendors from the database.""" """Compare two lists of vendors where the order of the vendors is not guaranteed."""
# Setup return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
name_1 = "John"
comment_1 = "abcdefghåäö"
@dataclass
class Fixture:
vendors: list[dict[str, Any]]
@pytest.fixture(scope="module")
def vendors() -> Iterable[Fixture]:
"""Add some vendors to the database."""
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/vendor", f"{URL}/api/v1/vendor",
json={"name": name_1, "comment": comment_1}, json={"name": "John", "comment": "abcdefghåäö"},
) )
result.raise_for_status() result.raise_for_status()
vendor_1 = result.json() vendor_1 = result.json()
name_2 = "Stan"
comment_2 = "gfdadfg"
result = httpx.post( result = httpx.post(
f"{URL}/api/v1/vendor", f"{URL}/api/v1/vendor",
json={"name": name_2, "comment": comment_2}, json={"name": "Stan", "comment": "gfdadfg"},
) )
result.raise_for_status() result.raise_for_status()
vendor_2 = result.json() vendor_2 = result.json()
added_vendors_by_id = { result = httpx.post(
vendor_1["id"]: vendor_1,
vendor_2["id"]: vendor_2,
}
# Execute - Find all vendors
result = httpx.get(
f"{URL}/api/v1/vendor", f"{URL}/api/v1/vendor",
json={"name": ""},
) )
result.raise_for_status() result.raise_for_status()
vendor_3 = result.json()
# Verify yield Fixture(
vendors = result.json() vendors=[vendor_1, vendor_2, vendor_3],
assert len(vendors) == 2
for vendor in vendors:
assert vendor == added_vendors_by_id[vendor["id"]]
# Execute - Find a specific vendor
result = httpx.get(
f"{URL}/api/v1/vendor",
params={"name": name_1},
) )
result.raise_for_status()
# Verify
vendors = result.json()
assert len(vendors) == 1
vendor = vendors[0]
assert vendor["name"] == name_1
assert vendor["comment"] == comment_1
assert vendor["id"] == vendor_1["id"]
assert vendor["registered"] == vendor_1["registered"]
# Clean up
httpx.delete(f"{URL}/api/v1/vendor/{vendor_1['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor_1['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/vendor/{vendor_2['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor_2['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/vendor/{vendor_3['id']}").raise_for_status()
def test_find_all_vendors(vendors: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/vendor",
)
result.raise_for_status()
# Verify
vendors_result = result.json()
assert vendor_lists_equal(vendors_result, vendors.vendors)
def test_find_all_vendors_sort_asc(vendors: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc")
result.raise_for_status()
# Verify
vendors_result = result.json()
assert len(vendors_result) == len(vendors.vendors)
assert vendors_result[0] == vendors.vendors[0]
def test_find_all_vendors_sort_desc(vendors: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:desc")
result.raise_for_status()
# Verify
vendors_result = result.json()
assert len(vendors_result) == len(vendors.vendors)
assert vendors_result[-1] == vendors.vendors[0]
def test_find_all_vendors_limit_asc(vendors: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc&limit=2")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "3"
vendors_result = result.json()
assert len(vendors_result) == 2
assert vendors_result == [vendors.vendors[0], vendors.vendors[1]]
def test_find_all_vendors_limit_desc(vendors: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:desc&limit=2")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "3"
vendors_result = result.json()
assert len(vendors_result) == 2
assert vendors_result == [vendors.vendors[-1], vendors.vendors[-2]]
def test_find_all_vendors_limit_asc_offset(vendors: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc&limit=2&offset=1")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "3"
vendors_result = result.json()
assert len(vendors_result) == 2
assert vendors_result == [vendors.vendors[1], vendors.vendors[2]]
def test_find_all_vendors_limit_desc_offset(vendors: Fixture):
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:desc&limit=2&offset=1")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "3"
vendors_result = result.json()
assert len(vendors_result) == 2
assert vendors_result == [vendors.vendors[-2], vendors.vendors[-3]]
def test_find_all_vendors_limit_asc_offset_outside_range(vendors: Fixture): # noqa: ARG001
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc&limit=2&offset=100")
result.raise_for_status()
# Verify
assert result.headers["X-Total-Count"] == "3"
vendors_result = result.json()
assert len(vendors_result) == 0
@pytest.mark.parametrize(
"field_name",
[
"id",
"registered",
"name",
"comment",
],
)
def test_find_all_vendors_sort_fields(vendors: Fixture, field_name: str):
"""Test sorting by all fields."""
# Execute
result = httpx.get(f"{URL}/api/v1/vendor?sort={field_name}:asc")
result.raise_for_status()
# Verify
vendors_result = result.json()
assert len(vendors_result) == len(vendors.vendors)
def test_find_vendors_by_name(vendors: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/vendor",
params={"name": vendors.vendors[0]["name"]},
)
result.raise_for_status()
# Verify
vendors_result = result.json()
assert vendors_result == [vendors.vendors[0]]
def test_find_vendors_by_empty_name(vendors: Fixture):
# Execute
result = httpx.get(
f"{URL}/api/v1/vendor",
params={"name": ""},
)
result.raise_for_status()
# Verify
vendors_result = result.json()
assert vendors_result == [vendors.vendors[2]]