76
client/package-lock.json
generated
76
client/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@refinedev/react-router-v6": "^4.5.0",
|
||||
"@refinedev/simple-rest": "^4.5.0",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@tanstack/react-query-devtools": "^4.33.0",
|
||||
"@types/loadable__component": "^5.13.4",
|
||||
"@yudiel/react-qr-scanner": "^1.1.10",
|
||||
"antd": "^5.8.3",
|
||||
@@ -2408,6 +2409,21 @@
|
||||
"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": {
|
||||
"version": "4.32.6",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
|
||||
@@ -3920,6 +3955,20 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"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": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
|
||||
@@ -6107,6 +6156,17 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
|
||||
@@ -8855,6 +8915,11 @@
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
|
||||
@@ -9829,6 +9894,17 @@
|
||||
"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": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@refinedev/react-router-v6": "^4.5.0",
|
||||
"@refinedev/simple-rest": "^4.5.0",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@tanstack/react-query-devtools": "^4.33.0",
|
||||
"@types/loadable__component": "^5.13.4",
|
||||
"@yudiel/react-qr-scanner": "^1.1.10",
|
||||
"antd": "^5.8.3",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Refine } from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import { ErrorComponent, ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd";
|
||||
import "@refinedev/antd/dist/reset.css";
|
||||
@@ -185,6 +186,7 @@ function App() {
|
||||
<RefineKbar />
|
||||
<UnsavedChangesNotifier />
|
||||
<DocumentTitleHandler />
|
||||
<ReactQueryDevtools />
|
||||
</Refine>
|
||||
</ConfigProvider>
|
||||
</ColorModeContextProvider>
|
||||
|
||||
@@ -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 { 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 { getSortOrderForField, typeSorters } from "../utils/sorting";
|
||||
import { NumberFieldUnit } from "./numberField";
|
||||
@@ -12,19 +12,37 @@ import Icon from "@ant-design/icons";
|
||||
import { ReactComponent as SpoolIcon } from "../icon_spool.svg";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { enrichText } from "../utils/parsing";
|
||||
import { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const FilterDropdownLoading = () => {
|
||||
return (
|
||||
<Row justify="center">
|
||||
<Col>
|
||||
Loading...
|
||||
<Spin style={{ margin: 10 }} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
interface BaseColumnProps<Obj> {
|
||||
id: keyof Obj & string;
|
||||
i18ncat: string;
|
||||
dataId?: keyof Obj & string;
|
||||
i18ncat?: string;
|
||||
i18nkey?: string;
|
||||
dataSource: Obj[];
|
||||
tableState: TableState;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface FilteredColumnProps {
|
||||
filters?: ColumnFilterItem[];
|
||||
filteredValue?: string[];
|
||||
allowMultipleFilters?: boolean;
|
||||
onFilterDropdownOpen?: () => void;
|
||||
loadingFilters?: boolean;
|
||||
}
|
||||
|
||||
interface CustomColumnProps<Obj> {
|
||||
@@ -45,13 +63,28 @@ function Column<Obj>(props: BaseColumnProps<Obj> & FilteredColumnProps & CustomC
|
||||
const typedSorters = typeSorters<Obj>(props.tableState.sorters);
|
||||
const columnProps: AntdColumnProps<Obj> = {
|
||||
dataIndex: props.id,
|
||||
title: t(`${props.i18ncat}.fields.${props.id}`),
|
||||
title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
|
||||
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) {
|
||||
columnProps.filters = props.filters;
|
||||
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) {
|
||||
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 filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
|
||||
|
||||
const filters = useListFiltersForField(props.dataSource, props.id);
|
||||
const filteredValue = getFiltersForField(typedFilters, props.id);
|
||||
const onFilterDropdownOpen = () => {
|
||||
query.refetch();
|
||||
};
|
||||
|
||||
return Column({ ...props, filters, filteredValue });
|
||||
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
const filteredValue = getFiltersForField(typedFilters, props.id);
|
||||
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 filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
|
||||
|
||||
const onFilterDropdownOpen = () => {
|
||||
query.refetch();
|
||||
};
|
||||
|
||||
return Column({
|
||||
...props,
|
||||
filters,
|
||||
filteredValue,
|
||||
onFilterDropdownOpen,
|
||||
loadingFilters: query.isLoading,
|
||||
onCell: () => {
|
||||
return {
|
||||
style: {
|
||||
|
||||
@@ -13,13 +13,76 @@ const dataProvider = (
|
||||
Required<DataProvider>,
|
||||
"createMany" | "updateMany" | "deleteMany"
|
||||
> => ({
|
||||
getList: async ({ resource, meta }) => {
|
||||
getList: async ({ resource, meta, pagination, sorters, filters }) => {
|
||||
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 { 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}`,
|
||||
{
|
||||
headers: headersFromMeta,
|
||||
@@ -27,9 +90,11 @@ const dataProvider = (
|
||||
},
|
||||
);
|
||||
|
||||
// console.log(url, requestMethod, queryParams, data, headers)
|
||||
|
||||
return {
|
||||
data,
|
||||
total: data.length,
|
||||
total: parseInt(headers["x-total-count"]) ?? 100,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
168
client/src/components/otherModels.tsx
Normal file
168
client/src/components/otherModels.tsx
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Table, Checkbox, Space, Row, Col, message } from "antd";
|
||||
import { ISpool } from "../pages/spools/model";
|
||||
import { FilteredColumn, SortedColumn, SpoolIconColumn } from "./column";
|
||||
import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "./column";
|
||||
import { TableState } from "../utils/saveload";
|
||||
import { useTable } from "@refinedev/antd";
|
||||
import { genericSorter, typeSorters } from "../utils/sorting";
|
||||
import { genericFilterer, typeFilters } from "../utils/filtering";
|
||||
import { t } from "i18next";
|
||||
import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -16,8 +15,9 @@ interface Props {
|
||||
}
|
||||
|
||||
interface ISpoolCollapsed extends ISpool {
|
||||
filament_name: string;
|
||||
material?: string;
|
||||
combined_name: string;
|
||||
"filament.id": number;
|
||||
"filament.material"?: string;
|
||||
}
|
||||
|
||||
const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onContinue }) => {
|
||||
@@ -38,10 +38,10 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
pageSize: 10,
|
||||
},
|
||||
sorters: {
|
||||
mode: "off",
|
||||
mode: "server",
|
||||
},
|
||||
filters: {
|
||||
mode: "off",
|
||||
mode: "server",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,28 +64,18 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
}
|
||||
return {
|
||||
...element,
|
||||
filament_name,
|
||||
material: element.filament.material,
|
||||
combined_name: filament_name,
|
||||
"filament.id": element.filament.id,
|
||||
"filament.material": element.filament.material,
|
||||
};
|
||||
}),
|
||||
[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
|
||||
const selectUnselectFiltered = (select: boolean) => {
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -98,7 +88,9 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Modal
|
||||
@@ -120,13 +112,7 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
{contextHolder}
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{description && <div>{description}</div>}
|
||||
<Table
|
||||
{...tableProps}
|
||||
rowKey="id"
|
||||
dataSource={filteredDataSource}
|
||||
pagination={false}
|
||||
scroll={{ x: "max-content", y: 200 }}
|
||||
>
|
||||
<Table {...tableProps} rowKey="id" dataSource={dataSource} pagination={false} scroll={{ y: 200 }}>
|
||||
<Table.Column
|
||||
width={50}
|
||||
render={(_, item: ISpool) => (
|
||||
@@ -138,25 +124,30 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
i18ncat: "spool",
|
||||
dataSource,
|
||||
tableState,
|
||||
width: 80,
|
||||
})}
|
||||
{SpoolIconColumn({
|
||||
id: "filament_name",
|
||||
i18ncat: "spool",
|
||||
id: "combined_name",
|
||||
dataId: "filament.id",
|
||||
i18nkey: "spool.fields.filament_name",
|
||||
color: (record: ISpoolCollapsed) => record.filament.color_hex,
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanFilamentFilter(),
|
||||
})}
|
||||
{FilteredColumn({
|
||||
id: "material",
|
||||
i18ncat: "spool",
|
||||
{FilteredQueryColumn({
|
||||
id: "filament.material",
|
||||
i18nkey: "spool.fields.material",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanMaterials(),
|
||||
})}
|
||||
</Table>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Checkbox
|
||||
checked={isAllFilteredSelected}
|
||||
indeterminate={isSomeButNotAllFilteredSelected}
|
||||
onChange={(e) => {
|
||||
selectUnselectFiltered(e.target.checked);
|
||||
}}
|
||||
|
||||
@@ -5,54 +5,59 @@ import { Table, Space, Button, Dropdown } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { IFilament } from "./model";
|
||||
import { genericSorter, typeSorters } from "../../utils/sorting";
|
||||
import { genericFilterer, typeFilters } from "../../utils/filtering";
|
||||
import { EditOutlined, FilterOutlined } from "@ant-design/icons";
|
||||
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
|
||||
import {
|
||||
DateColumn,
|
||||
FilteredColumn,
|
||||
FilteredQueryColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
SpoolIconColumn,
|
||||
} from "../../components/column";
|
||||
import {
|
||||
useSpoolmanArticleNumbers,
|
||||
useSpoolmanFilamentNames,
|
||||
useSpoolmanMaterials,
|
||||
useSpoolmanVendors,
|
||||
} from "../../components/otherModels";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
interface IFilamentCollapsed extends Omit<IFilament, "vendor"> {
|
||||
vendor_name: string | null;
|
||||
"vendor.name": string | null;
|
||||
}
|
||||
|
||||
const namespace = "filamentList-v2";
|
||||
|
||||
export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
|
||||
// Load initial state
|
||||
const initialState = useInitialTableState("filamentList");
|
||||
const initialState = useInitialTableState(namespace);
|
||||
|
||||
// Fetch data from the API
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } =
|
||||
useTable<IFilament>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work.
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "off", // Disable server-side sorting
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "off", // Disable server-side filtering
|
||||
initial: initialState.filters,
|
||||
},
|
||||
});
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<IFilament>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "server",
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "server",
|
||||
initial: initialState.filters,
|
||||
},
|
||||
});
|
||||
|
||||
// Create state for the columns to show
|
||||
const allColumns: (keyof IFilamentCollapsed & string)[] = [
|
||||
"id",
|
||||
"vendor_name",
|
||||
"vendor.name",
|
||||
"name",
|
||||
"material",
|
||||
"price",
|
||||
@@ -72,10 +77,6 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
|
||||
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
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
@@ -83,7 +84,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
pagination: { current, pageSize },
|
||||
showColumns,
|
||||
};
|
||||
useStoreInitialState("filamentList", tableState);
|
||||
useStoreInitialState(namespace, tableState);
|
||||
|
||||
// Collapse the dataSource to a mutable list and add a filament_name field
|
||||
const dataSource: IFilamentCollapsed[] = React.useMemo(
|
||||
@@ -95,17 +96,14 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
} else {
|
||||
vendor_name = null;
|
||||
}
|
||||
return { ...element, vendor_name };
|
||||
return { ...element, "vendor.name": vendor_name };
|
||||
}),
|
||||
[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]);
|
||||
if (tableProps.pagination) {
|
||||
tableProps.pagination.showSizeChanger = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
@@ -127,7 +125,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
menu={{
|
||||
items: allColumns.map((column_id) => ({
|
||||
key: column_id,
|
||||
label: t(`filament.fields.${column_id}`),
|
||||
label: t(`filament.fields.${column_id.replace(".", "_")}`),
|
||||
})),
|
||||
selectedKeys: showColumns,
|
||||
selectable: true,
|
||||
@@ -148,31 +146,19 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Table
|
||||
{...tableProps}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
current: current,
|
||||
pageSize: pageSize,
|
||||
onChange: (page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setPageSize(pageSize);
|
||||
},
|
||||
}}
|
||||
rowKey="id"
|
||||
>
|
||||
<Table {...tableProps} dataSource={dataSource} rowKey="id">
|
||||
{SortedColumn({
|
||||
id: "id",
|
||||
i18ncat: "filament",
|
||||
dataSource,
|
||||
tableState,
|
||||
})}
|
||||
{FilteredColumn({
|
||||
id: "vendor_name",
|
||||
i18ncat: "filament",
|
||||
{FilteredQueryColumn({
|
||||
id: "vendor.name",
|
||||
i18nkey: "filament.fields.vendor_name",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanVendors(),
|
||||
})}
|
||||
{SpoolIconColumn({
|
||||
id: "name",
|
||||
@@ -180,12 +166,14 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
color: (record: IFilamentCollapsed) => record.color_hex,
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanFilamentNames(),
|
||||
})}
|
||||
{FilteredColumn({
|
||||
{FilteredQueryColumn({
|
||||
id: "material",
|
||||
i18ncat: "filament",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanMaterials(),
|
||||
})}
|
||||
{SortedColumn({
|
||||
id: "price",
|
||||
@@ -225,11 +213,12 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
dataSource,
|
||||
tableState,
|
||||
})}
|
||||
{FilteredColumn({
|
||||
{FilteredQueryColumn({
|
||||
id: "article_number",
|
||||
i18ncat: "filament",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanArticleNumbers(),
|
||||
})}
|
||||
{NumberColumn({
|
||||
id: "settings_extruder_temp",
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/
|
||||
import { Table, Space, Button, Dropdown, Modal } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { genericSorter, typeSorters } from "../../utils/sorting";
|
||||
import { genericFilterer, typeFilters } from "../../utils/filtering";
|
||||
import { ISpool } from "./model";
|
||||
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
|
||||
import { EditOutlined, FilterOutlined, InboxOutlined, ToTopOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
DateColumn,
|
||||
FilteredColumn,
|
||||
FilteredQueryColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
@@ -19,55 +17,63 @@ import {
|
||||
} from "../../components/column";
|
||||
import { setSpoolArchived } from "./functions";
|
||||
import SelectAndPrint from "../../components/selectAndPrintDialog";
|
||||
import {
|
||||
useSpoolmanFilamentFilter,
|
||||
useSpoolmanLocations,
|
||||
useSpoolmanLotNumbers,
|
||||
useSpoolmanMaterials,
|
||||
} from "../../components/otherModels";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const { confirm } = Modal;
|
||||
|
||||
interface ISpoolCollapsed extends ISpool {
|
||||
filament_name: string;
|
||||
material?: string;
|
||||
combined_name: string; // Eg. "Prusa - PLA Red"
|
||||
"filament.id": number;
|
||||
"filament.material"?: string;
|
||||
}
|
||||
|
||||
const namespace = "spoolList-v2";
|
||||
|
||||
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
// Load initial state
|
||||
const initialState = useInitialTableState("spoolList");
|
||||
const initialState = useInitialTableState(namespace);
|
||||
|
||||
// State for the switch to show archived spools
|
||||
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
||||
|
||||
// Fetch data from the API
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } =
|
||||
useTable<ISpool>({
|
||||
meta: {
|
||||
queryParams: {
|
||||
["allow_archived"]: showArchived,
|
||||
},
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<ISpool>({
|
||||
meta: {
|
||||
queryParams: {
|
||||
["allow_archived"]: showArchived,
|
||||
},
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work.
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "off", // Disable server-side sorting
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "off", // Disable server-side filtering
|
||||
initial: initialState.filters,
|
||||
},
|
||||
});
|
||||
},
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "server",
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "server",
|
||||
initial: initialState.filters,
|
||||
},
|
||||
});
|
||||
|
||||
// Create state for the columns to show
|
||||
const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||
"id",
|
||||
"filament_name",
|
||||
"material",
|
||||
"combined_name",
|
||||
"filament.material",
|
||||
"used_weight",
|
||||
"remaining_weight",
|
||||
"used_length",
|
||||
@@ -84,10 +90,6 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
);
|
||||
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
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
@@ -95,7 +97,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
pagination: { current, pageSize },
|
||||
showColumns,
|
||||
};
|
||||
useStoreInitialState("spoolList", tableState);
|
||||
useStoreInitialState(namespace, tableState);
|
||||
|
||||
// Collapse the dataSource to a mutable list and add a filament_name field
|
||||
const dataSource: ISpoolCollapsed[] = React.useMemo(
|
||||
@@ -109,20 +111,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
}
|
||||
return {
|
||||
...element,
|
||||
filament_name,
|
||||
material: element.filament.material,
|
||||
combined_name: filament_name,
|
||||
"filament.id": element.filament.id,
|
||||
"filament.material": element.filament.material,
|
||||
};
|
||||
}),
|
||||
[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
|
||||
const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => {
|
||||
await setSpoolArchived(spool, archive);
|
||||
@@ -151,6 +147,10 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (tableProps.pagination) {
|
||||
tableProps.pagination.showSizeChanger = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
headerButtons={({ defaultButtons }) => (
|
||||
@@ -181,7 +181,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
menu={{
|
||||
items: allColumns.map((column) => ({
|
||||
key: column,
|
||||
label: t(`spool.fields.${column}`),
|
||||
label: t(`spool.fields.${column.replace(".", "_")}`),
|
||||
})),
|
||||
selectedKeys: showColumns,
|
||||
selectable: true,
|
||||
@@ -204,16 +204,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
>
|
||||
<Table
|
||||
{...tableProps}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
current: current,
|
||||
pageSize: pageSize,
|
||||
onChange: (page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setPageSize(pageSize);
|
||||
},
|
||||
}}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
// Make archived rows greyed out
|
||||
onRow={(record) => {
|
||||
@@ -236,17 +227,20 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
tableState,
|
||||
})}
|
||||
{SpoolIconColumn({
|
||||
id: "filament_name",
|
||||
i18ncat: "spool",
|
||||
id: "combined_name",
|
||||
i18nkey: "spool.fields.filament_name",
|
||||
color: (record: ISpoolCollapsed) => record.filament.color_hex,
|
||||
dataSource,
|
||||
tableState,
|
||||
dataId: "filament.id",
|
||||
filterValueQuery: useSpoolmanFilamentFilter(),
|
||||
})}
|
||||
{FilteredColumn({
|
||||
id: "material",
|
||||
i18ncat: "spool",
|
||||
{FilteredQueryColumn({
|
||||
id: "filament.material",
|
||||
i18nkey: "spool.fields.material",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanMaterials(),
|
||||
})}
|
||||
{NumberColumn({
|
||||
id: "used_weight",
|
||||
@@ -282,17 +276,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
dataSource,
|
||||
tableState,
|
||||
})}
|
||||
{FilteredColumn({
|
||||
{FilteredQueryColumn({
|
||||
id: "location",
|
||||
i18ncat: "spool",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanLocations(),
|
||||
})}
|
||||
{FilteredColumn({
|
||||
{FilteredQueryColumn({
|
||||
id: "lot_nr",
|
||||
i18ncat: "spool",
|
||||
dataSource,
|
||||
tableState,
|
||||
filterValueQuery: useSpoolmanLotNumbers(),
|
||||
})}
|
||||
{DateColumn({
|
||||
id: "first_used",
|
||||
|
||||
69
client/src/pages/vendors/list.tsx
vendored
69
client/src/pages/vendors/list.tsx
vendored
@@ -4,8 +4,6 @@ import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/
|
||||
import { Table, Space, Button, Dropdown } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { genericSorter, typeSorters } from "../../utils/sorting";
|
||||
import { genericFilterer, typeFilters } from "../../utils/filtering";
|
||||
import { IVendor } from "./model";
|
||||
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
|
||||
import { EditOutlined, FilterOutlined } from "@ant-design/icons";
|
||||
@@ -13,39 +11,36 @@ import { DateColumn, RichColumn, SortedColumn } from "../../components/column";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const namespace = "vendorList-v2";
|
||||
|
||||
export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
|
||||
// Load initial state
|
||||
const initialState = useInitialTableState("vendorList");
|
||||
const initialState = useInitialTableState(namespace);
|
||||
|
||||
// Fetch data from the API
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } =
|
||||
useTable<IVendor>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work.
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "off", // Disable server-side sorting
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "off", // Disable server-side filtering
|
||||
initial: initialState.filters,
|
||||
},
|
||||
});
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<IVendor>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "server",
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "server",
|
||||
initial: initialState.filters,
|
||||
},
|
||||
});
|
||||
|
||||
// Create state for the columns to show
|
||||
const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment"];
|
||||
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
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
@@ -53,7 +48,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
pagination: { current, pageSize },
|
||||
showColumns,
|
||||
};
|
||||
useStoreInitialState("vendorList", tableState);
|
||||
useStoreInitialState(namespace, tableState);
|
||||
|
||||
// Collapse the dataSource to a mutable list
|
||||
const dataSource: IVendor[] = React.useMemo(
|
||||
@@ -61,12 +56,9 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
[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]);
|
||||
if (tableProps.pagination) {
|
||||
tableProps.pagination.showSizeChanger = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
@@ -109,20 +101,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Table
|
||||
{...tableProps}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
current: current,
|
||||
pageSize: pageSize,
|
||||
onChange: (page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setPageSize(pageSize);
|
||||
},
|
||||
}}
|
||||
rowKey="id"
|
||||
>
|
||||
<Table {...tableProps} dataSource={dataSource} rowKey="id">
|
||||
{SortedColumn({
|
||||
id: "id",
|
||||
i18ncat: "vendor",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { CrudFilter, CrudOperators } from "@refinedev/core";
|
||||
import { ColumnFilterItem } from "antd/es/table/interface";
|
||||
import React from "react";
|
||||
|
||||
interface TypedCrudFilter<Obj> {
|
||||
field: keyof Obj;
|
||||
@@ -12,92 +10,6 @@ export function typeFilters<Obj>(filters: CrudFilter[]): TypedCrudFilter<Obj>[]
|
||||
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.
|
||||
* @param filters An array of `CrudFilter` objects that define the filtering criteria.
|
||||
|
||||
@@ -7,62 +7,6 @@ interface TypedCrudSort<Obj> {
|
||||
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.
|
||||
* @param sorters An array of `CrudSort` objects that define the sorting criteria.
|
||||
|
||||
@@ -39,6 +39,9 @@ call = "spoolman.docs:generate_docs"
|
||||
[tool.pdm.scripts.bump]
|
||||
call = "spoolman.bump:bump"
|
||||
|
||||
[tool.pdm.scripts.app]
|
||||
cmd = "uvicorn spoolman.main:app"
|
||||
|
||||
[tool.ruff]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
@@ -58,12 +61,13 @@ ignore = [
|
||||
"EM102",
|
||||
"DTZ003",
|
||||
"PLR0913",
|
||||
"SIM108",
|
||||
]
|
||||
line-length = 120
|
||||
target-version = "py39"
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"tests*/*" = ["ANN201", "S101", "PLR2004"]
|
||||
"tests*/*" = ["ANN201", "S101", "PLR2004", "D103"]
|
||||
"migrations/versions/*" = ["N999"]
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
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.database import filament
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
from spoolman.exceptions import ItemDeleteError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,45 +110,119 @@ class FilamentUpdateParameters(FilamentParameters):
|
||||
name="Find filaments",
|
||||
description="Get a list of filaments that matches the search query.",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[Filament],
|
||||
)
|
||||
async def find(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
vendor_name: Optional[str] = Query(
|
||||
vendor_name_old: Optional[str] = Query(
|
||||
alias="vendor_name",
|
||||
default=None,
|
||||
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."
|
||||
),
|
||||
deprecated=True,
|
||||
),
|
||||
vendor_id: Optional[int] = Query(
|
||||
vendor_id_old: Optional[str] = Query(
|
||||
alias="vendor_id",
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
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,
|
||||
vendor_name=vendor_name,
|
||||
vendor_id=vendor_id,
|
||||
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
||||
vendor_id=vendor_ids,
|
||||
name=name,
|
||||
material=material,
|
||||
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(
|
||||
|
||||
125
spoolman/api/v1/other.py
Normal file
125
spoolman/api/v1/other.py
Normal 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)
|
||||
@@ -13,14 +13,13 @@ from spoolman import env
|
||||
from spoolman.database.database import backup_global_db
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
from . import filament, models, spool, vendor
|
||||
from . import filament, models, other, spool, vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="Spoolman REST API v1",
|
||||
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(spool.router)
|
||||
app.include_router(vendor.router)
|
||||
app.include_router(other.router)
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
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.database import spool
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
from spoolman.exceptions import ItemCreateError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,63 +68,175 @@ class SpoolUseParameters(BaseModel):
|
||||
name="Find spool",
|
||||
description="Get a list of spools that matches the search query.",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[Spool],
|
||||
)
|
||||
async def find(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
filament_name: Optional[str] = Query(
|
||||
filament_name_old: Optional[str] = Query(
|
||||
alias="filament_name",
|
||||
default=None,
|
||||
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,
|
||||
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,
|
||||
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."
|
||||
),
|
||||
deprecated=True,
|
||||
),
|
||||
vendor_name: Optional[str] = Query(
|
||||
vendor_name_old: Optional[str] = Query(
|
||||
alias="vendor_name",
|
||||
default=None,
|
||||
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."
|
||||
),
|
||||
deprecated=True,
|
||||
),
|
||||
vendor_id: Optional[int] = Query(
|
||||
vendor_id_old: Optional[str] = Query(
|
||||
alias="vendor_id",
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
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(
|
||||
default=False,
|
||||
title="Allow Archived",
|
||||
description="Whether to include archived spools in the search results.",
|
||||
),
|
||||
) -> list[Spool]:
|
||||
db_items = await spool.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="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,
|
||||
filament_name=filament_name,
|
||||
filament_id=filament_id,
|
||||
filament_material=filament_material,
|
||||
vendor_name=vendor_name,
|
||||
vendor_id=vendor_id,
|
||||
filament_name=filament_name if filament_name is not None else filament_name_old,
|
||||
filament_id=filament_ids,
|
||||
filament_material=filament_material if filament_material is not None else filament_material_old,
|
||||
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
|
||||
vendor_id=filament_vendor_ids,
|
||||
location=location,
|
||||
lot_nr=lot_nr,
|
||||
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(
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.error_wrappers import ErrorWrapper
|
||||
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.database import vendor
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/vendor",
|
||||
@@ -43,20 +46,55 @@ class VendorUpdateParameters(VendorParameters):
|
||||
name="Find vendor",
|
||||
description="Get a list of vendors that matches the search query.",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[Vendor],
|
||||
)
|
||||
async def find(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
name: Optional[str] = Query(
|
||||
default=None,
|
||||
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]:
|
||||
db_items = await vendor.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="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,
|
||||
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(
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
"""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.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -69,30 +78,55 @@ async def find(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
vendor_name: Optional[str] = None,
|
||||
vendor_id: Optional[int] = None,
|
||||
vendor_id: Optional[Union[int, Sequence[int]]] = None,
|
||||
name: Optional[str] = None,
|
||||
material: Optional[str] = None,
|
||||
article_number: Optional[str] = None,
|
||||
) -> list[models.Filament]:
|
||||
"""Find a list of filament objects by search criteria."""
|
||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||
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 = (
|
||||
select(models.Filament)
|
||||
.options(contains_eager(models.Filament.vendor))
|
||||
.join(models.Filament.vendor, isouter=True)
|
||||
)
|
||||
if vendor_name is not None:
|
||||
stmt = stmt.where(models.Vendor.name.ilike(f"%{vendor_name}%"))
|
||||
if vendor_id is not None:
|
||||
stmt = stmt.where(models.Filament.vendor_id == vendor_id)
|
||||
if name is not None:
|
||||
stmt = stmt.where(models.Filament.name.ilike(f"%{name}%"))
|
||||
if material is not None:
|
||||
stmt = stmt.where(models.Filament.material.ilike(f"%{material}%"))
|
||||
if article_number is not None:
|
||||
stmt = stmt.where(models.Filament.article_number.ilike(f"%{article_number}%"))
|
||||
|
||||
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
|
||||
stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.name, name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.material, material)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number)
|
||||
|
||||
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 = 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)
|
||||
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(
|
||||
@@ -124,3 +158,26 @@ async def delete(db: AsyncSession, filament_id: int) -> None:
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
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]
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"""Helper functions for interacting with spool database objects."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import case
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
|
||||
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.math import weight_from_length
|
||||
|
||||
@@ -79,35 +88,39 @@ async def find(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
filament_name: Optional[str] = None,
|
||||
filament_id: Optional[int] = None,
|
||||
filament_id: Optional[Union[int, Sequence[int]]] = None,
|
||||
filament_material: 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,
|
||||
lot_nr: Optional[str] = None,
|
||||
allow_archived: bool = False,
|
||||
) -> list[models.Spool]:
|
||||
"""Find a list of spool objects by search criteria."""
|
||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||
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 = (
|
||||
sqlalchemy.select(models.Spool)
|
||||
.join(models.Spool.filament, isouter=True)
|
||||
.join(models.Filament.vendor, isouter=True)
|
||||
.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}%"))
|
||||
if filament_id is not None:
|
||||
stmt = stmt.where(models.Spool.filament_id == filament_id)
|
||||
if filament_material is not None:
|
||||
stmt = stmt.where(models.Filament.material.ilike(f"%{filament_material}%"))
|
||||
if vendor_name is not None:
|
||||
stmt = stmt.where(models.Vendor.name.ilike(f"%{vendor_name}%"))
|
||||
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}%"))
|
||||
|
||||
stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id)
|
||||
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
|
||||
stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
|
||||
|
||||
if not allow_archived:
|
||||
# Since the archived field is nullable, and default is false, we need to check for both false or null
|
||||
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)
|
||||
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(
|
||||
@@ -238,3 +276,23 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
|
||||
|
||||
await db.commit()
|
||||
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]
|
||||
|
||||
98
spoolman/database/utils.py
Normal file
98
spoolman/database/utils.py
Normal 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
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import models
|
||||
from spoolman.database.utils import SortOrder, add_where_clause_str
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
@@ -37,14 +38,40 @@ async def find(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
name: Optional[str] = None,
|
||||
) -> list[models.Vendor]:
|
||||
"""Find a list of vendor objects by search criteria."""
|
||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||
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)
|
||||
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)
|
||||
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(
|
||||
|
||||
@@ -56,6 +56,7 @@ if env.is_debug_mode():
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Total-Count"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
@@ -50,8 +51,8 @@ def _wait_for_server(): # noqa: ANN202
|
||||
break
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def random_vendor():
|
||||
@contextmanager
|
||||
def random_vendor_impl():
|
||||
"""Return a random vendor."""
|
||||
# Add vendor
|
||||
result = httpx.post(
|
||||
@@ -60,41 +61,171 @@ def random_vendor():
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
vendor = result.json()
|
||||
vendor: dict[str, Any] = result.json()
|
||||
yield vendor
|
||||
|
||||
# Delete vendor
|
||||
httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def random_filament(random_vendor: dict[str, Any]):
|
||||
@contextmanager
|
||||
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."""
|
||||
with random_vendor_impl() as random_vendor:
|
||||
# Add filament
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/filament",
|
||||
json={
|
||||
"name": "Filament X",
|
||||
"vendor_id": random_vendor["id"],
|
||||
"material": "PLA",
|
||||
"price": 100,
|
||||
"density": 1.25,
|
||||
"diameter": 1.75,
|
||||
"weight": 1000,
|
||||
"spool_weight": 250,
|
||||
"article_number": "123456789",
|
||||
"comment": "abcdefghåäö",
|
||||
},
|
||||
)
|
||||
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_impl():
|
||||
"""Return a random filament with only required fields specified."""
|
||||
# Add filament
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/filament",
|
||||
json={
|
||||
"name": "Filament X",
|
||||
"vendor_id": random_vendor["id"],
|
||||
"material": "PLA",
|
||||
"price": 100,
|
||||
"density": 1.25,
|
||||
"diameter": 1.75,
|
||||
"weight": 1000,
|
||||
"spool_weight": 250,
|
||||
"article_number": "123456789",
|
||||
"comment": "abcdefghåäö",
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
filament = result.json()
|
||||
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:
|
||||
"""Calculate the length of a piece of filament.
|
||||
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
"""Integration tests for the Filament API endpoint."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
URL = "http://spoolman:8000"
|
||||
|
||||
|
||||
def test_find_filaments(random_vendor: dict[str, Any]):
|
||||
"""Test finding filaments from the database."""
|
||||
# Setup
|
||||
def filament_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
|
||||
"""Compare two lists of filaments where the order of the filaments is not guaranteed."""
|
||||
return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
filaments: list[dict[str, Any]]
|
||||
|
||||
|
||||
@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(
|
||||
f"{URL}/api/v1/filament",
|
||||
json={
|
||||
"name": "Filament X",
|
||||
"vendor_id": random_vendor["id"],
|
||||
"vendor_id": random_vendor_mod["id"],
|
||||
"material": "PLA",
|
||||
"price": 100,
|
||||
"density": 1.25,
|
||||
@@ -32,7 +45,7 @@ def test_find_filaments(random_vendor: dict[str, Any]):
|
||||
f"{URL}/api/v1/filament",
|
||||
json={
|
||||
"name": "Filament Y",
|
||||
"vendor_id": random_vendor["id"],
|
||||
"vendor_id": random_vendor_mod["id"],
|
||||
"material": "ABS",
|
||||
"price": 200,
|
||||
"density": 1.25,
|
||||
@@ -46,24 +59,202 @@ def test_find_filaments(random_vendor: dict[str, Any]):
|
||||
result.raise_for_status()
|
||||
filament_2 = result.json()
|
||||
|
||||
added_filaments_by_id = {
|
||||
filament_1["id"]: filament_1,
|
||||
filament_2["id"]: filament_2,
|
||||
}
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/filament",
|
||||
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(
|
||||
f"{URL}/api/v1/filament",
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
filaments = result.json()
|
||||
assert len(filaments) == 2
|
||||
for filament in filaments:
|
||||
assert filament == added_filaments_by_id[filament["id"]]
|
||||
filaments_result = result.json()
|
||||
assert filament_lists_equal(filaments_result, filaments.filaments)
|
||||
|
||||
# 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(
|
||||
f"{URL}/api/v1/filament",
|
||||
params={"name": "Filament X"},
|
||||
@@ -71,11 +262,25 @@ def test_find_filaments(random_vendor: dict[str, Any]):
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
filaments = result.json()
|
||||
assert len(filaments) == 1
|
||||
assert filaments[0] == added_filaments_by_id[filament_1["id"]]
|
||||
filaments_result = result.json()
|
||||
assert filaments_result[0] == filaments.filaments[0]
|
||||
|
||||
# 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(
|
||||
f"{URL}/api/v1/filament",
|
||||
params={"material": "abs"},
|
||||
@@ -83,37 +288,114 @@ def test_find_filaments(random_vendor: dict[str, Any]):
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
filaments = result.json()
|
||||
assert len(filaments) == 1
|
||||
assert filaments[0] == added_filaments_by_id[filament_2["id"]]
|
||||
filaments_result = result.json()
|
||||
assert filaments_result == [filaments.filaments[1]]
|
||||
|
||||
# Execute - find filaments by vendor id
|
||||
|
||||
def test_find_filaments_by_multiple_materials(filaments: Fixture):
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/filament",
|
||||
params={"vendor_id": random_vendor["id"]},
|
||||
params={"material": "abs,pla"},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
filaments = result.json()
|
||||
assert len(filaments) == 2
|
||||
for filament in filaments:
|
||||
assert filament == added_filaments_by_id[filament["id"]]
|
||||
filaments_result = result.json()
|
||||
assert filament_lists_equal(filaments_result, filaments.filaments[:2])
|
||||
|
||||
# Execute - find filaments by vendor name
|
||||
|
||||
def test_find_filaments_by_empty_material(filaments: Fixture):
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/filament",
|
||||
params={"vendor_name": random_vendor["name"]},
|
||||
params={"material": ""},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
filaments = result.json()
|
||||
assert len(filaments) == 2
|
||||
for filament in filaments:
|
||||
assert filament == added_filaments_by_id[filament["id"]]
|
||||
filaments_result = result.json()
|
||||
assert filament_lists_equal(filaments_result, filaments.filaments[3:])
|
||||
|
||||
# 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(
|
||||
f"{URL}/api/v1/filament",
|
||||
params={"article_number": "321"},
|
||||
@@ -121,10 +403,18 @@ def test_find_filaments(random_vendor: dict[str, Any]):
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
filaments = result.json()
|
||||
assert len(filaments) == 1
|
||||
assert filaments[0] == added_filaments_by_id[filament_2["id"]]
|
||||
filaments_result = result.json()
|
||||
assert filaments_result[0] == filaments.filaments[1]
|
||||
|
||||
# Clean up
|
||||
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()
|
||||
|
||||
def test_find_filaments_by_empty_article_number(filaments: Fixture):
|
||||
# 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:])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Integration tests for the Spool API endpoint."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -10,20 +10,28 @@ import pytest
|
||||
URL = "http://spoolman:8000"
|
||||
|
||||
|
||||
def spool_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
|
||||
"""Compare two lists of spools where the order of the spools is not guaranteed."""
|
||||
return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
spools: list[dict[str, Any]]
|
||||
spools_by_id: dict[str, dict[str, Any]]
|
||||
filament: dict[str, Any]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
|
||||
@pytest.fixture(scope="module")
|
||||
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."""
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/spool",
|
||||
json={
|
||||
"filament_id": random_filament["id"],
|
||||
"filament_id": random_filament_mod["id"],
|
||||
"remaining_weight": 1000,
|
||||
"location": "The Pantry",
|
||||
"lot_nr": "123456789",
|
||||
@@ -35,7 +43,7 @@ def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/spool",
|
||||
json={
|
||||
"filament_id": random_filament["id"],
|
||||
"filament_id": random_filament_mod["id"],
|
||||
"remaining_weight": 1000,
|
||||
"location": "Living Room",
|
||||
"lot_nr": "987654321",
|
||||
@@ -47,7 +55,7 @@ def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/spool",
|
||||
json={
|
||||
"filament_id": random_filament["id"],
|
||||
"filament_id": random_filament_mod["id"],
|
||||
"remaining_weight": 1000,
|
||||
"archived": True,
|
||||
},
|
||||
@@ -55,132 +63,363 @@ def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]:
|
||||
result.raise_for_status()
|
||||
spool_3 = result.json()
|
||||
|
||||
added_spools_by_id = {
|
||||
spool_1["id"]: spool_1,
|
||||
spool_2["id"]: spool_2,
|
||||
spool_3["id"]: spool_3,
|
||||
}
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/spool",
|
||||
json={
|
||||
"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(
|
||||
spools=[spool_1, spool_2, spool_3],
|
||||
spools_by_id=added_spools_by_id,
|
||||
filament=random_filament,
|
||||
spools=[spool_1, spool_2, spool_3, spool_4, spool_5],
|
||||
filament=random_filament_mod,
|
||||
)
|
||||
|
||||
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_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):
|
||||
"""Test finding all non-archived spools in the database."""
|
||||
# Execute
|
||||
result = httpx.get(f"{URL}/api/v1/spool")
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 2
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool["archived"] is False
|
||||
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4]))
|
||||
|
||||
|
||||
def test_find_all_spools_including_archived(spools: Fixture):
|
||||
"""Test finding all spools in the database, including archived ones."""
|
||||
# Execute
|
||||
result = httpx.get(f"{URL}/api/v1/spool?allow_archived=true")
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 3
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool_lists_equal(
|
||||
spools_result,
|
||||
(
|
||||
spools.spools[0],
|
||||
spools.spools[1],
|
||||
spools.spools[2],
|
||||
spools.spools[3],
|
||||
spools.spools[4],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_find_spools_by_filament_name(spools: Fixture):
|
||||
"""Test finding spools by filament name."""
|
||||
def test_find_all_spools_sort_asc(spools: Fixture):
|
||||
# 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
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={"filament_name": spools.filament["name"]},
|
||||
params={field_name: spools.filament["name"]},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 2
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
|
||||
|
||||
|
||||
def test_find_spools_by_filament_id(spools: Fixture):
|
||||
"""Test finding spools by filament id."""
|
||||
def test_find_spools_by_empty_filament_name(spools: Fixture):
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={"filament_id": spools.filament["id"]},
|
||||
params={"filament.name": ""},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 2
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4]))
|
||||
|
||||
|
||||
def test_find_spools_by_filament_material(spools: Fixture):
|
||||
"""Test finding spools by filament material."""
|
||||
@pytest.mark.parametrize("field_name", ["filament_id", "filament.id"])
|
||||
def test_find_spools_by_filament_id(spools: Fixture, field_name: str):
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={"filament_material": spools.filament["material"]},
|
||||
params={field_name: spools.filament["id"]},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 2
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
|
||||
|
||||
|
||||
def test_find_spools_by_filament_vendor_name(spools: Fixture):
|
||||
"""Test finding spools by filament vendor name."""
|
||||
def test_find_spools_by_multiple_filament_ids(spools: Fixture):
|
||||
# Execute
|
||||
filament_1 = spools.spools[0]["filament"]["id"]
|
||||
filament_2 = spools.spools[3]["filament"]["id"]
|
||||
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={"vendor_name": spools.filament["vendor"]["name"]},
|
||||
params={"filament.id": f"{filament_1},{filament_2}"},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 2
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3]))
|
||||
|
||||
|
||||
def test_find_spools_by_filament_vendor_id(spools: Fixture):
|
||||
"""Test finding spools by filament vendor id."""
|
||||
@pytest.mark.parametrize("field_name", ["filament_material", "filament.material"])
|
||||
def test_find_spools_by_filament_material(spools: Fixture, field_name: str):
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={"vendor_id": spools.filament["vendor"]["id"]},
|
||||
params={field_name: spools.filament["material"]},
|
||||
)
|
||||
result.raise_for_status()
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 2
|
||||
for spool in spools_result:
|
||||
assert spool == spools.spools_by_id[spool["id"]]
|
||||
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1]))
|
||||
|
||||
|
||||
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):
|
||||
"""Test finding spools by location."""
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
@@ -190,12 +429,23 @@ def test_find_spools_by_location(spools: Fixture):
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 1
|
||||
assert spools_result[0] == spools.spools[0]
|
||||
assert spools_result == [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):
|
||||
"""Test finding spools by lot nr."""
|
||||
# Execute
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
@@ -205,5 +455,17 @@ def test_find_spools_by_lot_nr(spools: Fixture):
|
||||
|
||||
# Verify
|
||||
spools_result = result.json()
|
||||
assert len(spools_result) == 1
|
||||
assert spools_result[0] == spools.spools[0]
|
||||
assert spools_result == [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]))
|
||||
|
||||
205
tests_integration/tests/vendor/test_find.py
vendored
205
tests_integration/tests/vendor/test_find.py
vendored
@@ -1,67 +1,192 @@
|
||||
"""Integration tests for the Vendor API endpoint."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
URL = "http://spoolman:8000"
|
||||
|
||||
|
||||
def test_find_vendors():
|
||||
"""Test finding vendors from the database."""
|
||||
# Setup
|
||||
name_1 = "John"
|
||||
comment_1 = "abcdefghåäö"
|
||||
def vendor_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
|
||||
"""Compare two lists of vendors where the order of the vendors is not guaranteed."""
|
||||
return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
vendors: list[dict[str, Any]]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vendors() -> Iterable[Fixture]:
|
||||
"""Add some vendors to the database."""
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/vendor",
|
||||
json={"name": name_1, "comment": comment_1},
|
||||
json={"name": "John", "comment": "abcdefghåäö"},
|
||||
)
|
||||
result.raise_for_status()
|
||||
vendor_1 = result.json()
|
||||
|
||||
name_2 = "Stan"
|
||||
comment_2 = "gfdadfg"
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/vendor",
|
||||
json={"name": name_2, "comment": comment_2},
|
||||
json={"name": "Stan", "comment": "gfdadfg"},
|
||||
)
|
||||
result.raise_for_status()
|
||||
vendor_2 = result.json()
|
||||
|
||||
added_vendors_by_id = {
|
||||
vendor_1["id"]: vendor_1,
|
||||
vendor_2["id"]: vendor_2,
|
||||
}
|
||||
|
||||
# Execute - Find all vendors
|
||||
result = httpx.get(
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/vendor",
|
||||
json={"name": ""},
|
||||
)
|
||||
result.raise_for_status()
|
||||
vendor_3 = result.json()
|
||||
|
||||
# Verify
|
||||
vendors = result.json()
|
||||
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},
|
||||
yield Fixture(
|
||||
vendors=[vendor_1, vendor_2, vendor_3],
|
||||
)
|
||||
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_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]]
|
||||
|
||||
Reference in New Issue
Block a user