diff --git a/client/package-lock.json b/client/package-lock.json
index 6f76470..2fb556b 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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",
diff --git a/client/package.json b/client/package.json
index 2f11f6b..b13dc18 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index dccb6c5..a0fdea8 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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() {
+
diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx
index e9252a6..7b4ef16 100644
--- a/client/src/components/column.tsx
+++ b/client/src/components/column.tsx
@@ -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 (
+
+
+ Loading...
+
+
+
+ );
+};
+
interface BaseColumnProps {
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 {
@@ -45,13 +63,28 @@ function Column(props: BaseColumnProps & FilteredColumnProps & CustomC
const typedSorters = typeSorters(props.tableState.sorters);
const columnProps: AntdColumnProps = {
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 = ;
+ }
+ 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(props: BaseColumnProps) {
});
}
-export function FilteredColumn(props: BaseColumnProps) {
+interface FilteredQueryColumnProps extends BaseColumnProps {
+ filterValueQuery: UseQueryResult;
+ allowMultipleFilters?: boolean;
+}
+
+export function FilteredQueryColumn(props: FilteredQueryColumnProps) {
+ 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: "",
+ value: "",
+ });
+
const typedFilters = typeFilters(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 extends BaseColumnProps {
@@ -127,20 +186,43 @@ export function DateColumn(props: BaseColumnProps) {
});
}
-interface SpoolIconColumnProps extends BaseColumnProps {
+interface SpoolIconColumnProps extends FilteredQueryColumnProps {
color: (record: Obj) => string | undefined;
}
export function SpoolIconColumn(props: SpoolIconColumnProps) {
- const typedFilters = typeFilters(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: "",
+ value: "",
+ });
+
+ const typedFilters = typeFilters(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: {
diff --git a/client/src/components/dataProvider.ts b/client/src/components/dataProvider.ts
index c88ed91..e532513 100644
--- a/client/src/components/dataProvider.ts
+++ b/client/src/components/dataProvider.ts
@@ -13,13 +13,76 @@ const dataProvider = (
Required,
"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 = 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,
};
},
diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx
new file mode 100644
index 0000000..02feebb
--- /dev/null
+++ b/client/src/components/otherModels.tsx
@@ -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({
+ 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 ?? ""}`;
+ } else {
+ name = `${filament.name ?? ""}`;
+ }
+ 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({
+ 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 ?? "";
+ })
+ .sort();
+ // Remove duplicates
+ names = [...new Set(names)];
+ return names;
+ },
+ });
+}
+
+export function useSpoolmanVendors() {
+ const apiEndpoint = import.meta.env.VITE_APIURL;
+ return useQuery({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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();
+ },
+ });
+}
diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx
index 1f935c5..5740313 100644
--- a/client/src/components/spoolSelectModal.tsx
+++ b/client/src/components/spoolSelectModal.tsx
@@ -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 = ({ visible, description, onCancel, onContinue }) => {
@@ -38,10 +38,10 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC
pageSize: 10,
},
sorters: {
- mode: "off",
+ mode: "server",
},
filters: {
- mode: "off",
+ mode: "server",
},
});
@@ -64,28 +64,18 @@ const SpoolSelectModal: React.FC = ({ 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(sorters);
- const typedFilters = typeFilters(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 = ({ 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 (
= ({ visible, description, onCancel, onC
{contextHolder}
{description && {description}
}
-
+
(
@@ -138,25 +124,30 @@ const SpoolSelectModal: React.FC = ({ 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(),
})}
{
selectUnselectFiltered(e.target.checked);
}}
diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx
index 65d739c..7be2c0c 100644
--- a/client/src/pages/filaments/list.tsx
+++ b/client/src/pages/filaments/list.tsx
@@ -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 {
- vendor_name: string | null;
+ "vendor.name": string | null;
}
+const namespace = "filamentList-v2";
+
export const FilamentList: React.FC = () => {
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({
- 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({
+ 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 = () => {
const [showColumns, setShowColumns] = React.useState(initialState.showColumns ?? defaultColumns);
- // Type the sorters and filters
- const typedSorters = typeSorters(sorters);
- const typedFilters = typeFilters(filters);
-
// Store state in local storage
const tableState: TableState = {
sorters,
@@ -83,7 +84,7 @@ export const FilamentList: React.FC = () => {
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 = () => {
} 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 (
= () => {
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 = () => {
>
)}
>
- {
- setCurrent(page);
- setPageSize(pageSize);
- },
- }}
- 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 = () => {
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 = () => {
dataSource,
tableState,
})}
- {FilteredColumn({
+ {FilteredQueryColumn({
id: "article_number",
i18ncat: "filament",
dataSource,
tableState,
+ filterValueQuery: useSpoolmanArticleNumbers(),
})}
{NumberColumn({
id: "settings_extruder_temp",
diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx
index e7bae19..5fce1b9 100644
--- a/client/src/pages/spools/list.tsx
+++ b/client/src/pages/spools/list.tsx
@@ -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 = () => {
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({
- meta: {
- queryParams: {
- ["allow_archived"]: showArchived,
- },
+ const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable({
+ 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 = () => {
);
const [showColumns, setShowColumns] = React.useState(initialState.showColumns ?? defaultColumns);
- // Type the sorters and filters
- const typedSorters = typeSorters(sorters);
- const typedFilters = typeFilters(filters);
-
// Store state in local storage
const tableState: TableState = {
sorters,
@@ -95,7 +97,7 @@ export const SpoolList: React.FC = () => {
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 = () => {
}
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 = () => {
}
};
+ if (tableProps.pagination) {
+ tableProps.pagination.showSizeChanger = true;
+ }
+
return (
(
@@ -181,7 +181,7 @@ export const SpoolList: React.FC = () => {
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 = () => {
>
{
- setCurrent(page);
- setPageSize(pageSize);
- },
- }}
+ dataSource={dataSource}
rowKey="id"
// Make archived rows greyed out
onRow={(record) => {
@@ -236,17 +227,20 @@ export const SpoolList: React.FC = () => {
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 = () => {
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",
diff --git a/client/src/pages/vendors/list.tsx b/client/src/pages/vendors/list.tsx
index 345e5d5..36739da 100644
--- a/client/src/pages/vendors/list.tsx
+++ b/client/src/pages/vendors/list.tsx
@@ -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 = () => {
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({
- 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({
+ 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(initialState.showColumns ?? allColumns);
- // Type the sorters and filters
- const typedSorters = typeSorters(sorters);
- const typedFilters = typeFilters(filters);
-
// Store state in local storage
const tableState: TableState = {
sorters,
@@ -53,7 +48,7 @@ export const VendorList: React.FC = () => {
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 = () => {
[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 (
= () => {
>
)}
>
- {
- setCurrent(page);
- setPageSize(pageSize);
- },
- }}
- rowKey="id"
- >
+
{SortedColumn({
id: "id",
i18ncat: "vendor",
diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts
index 893139a..1ce7e8b 100644
--- a/client/src/utils/filtering.ts
+++ b/client/src/utils/filtering.ts
@@ -1,6 +1,4 @@
import { CrudFilter, CrudOperators } from "@refinedev/core";
-import { ColumnFilterItem } from "antd/es/table/interface";
-import React from "react";
interface TypedCrudFilter {
field: keyof Obj;
@@ -12,92 +10,6 @@ export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[]
return filters as TypedCrudFilter[]; // <-- 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(filters: TypedCrudFilter[]) {
- 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(
- 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: "",
- 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.
diff --git a/client/src/utils/sorting.ts b/client/src/utils/sorting.ts
index 3855d68..c43b525 100644
--- a/client/src/utils/sorting.ts
+++ b/client/src/utils/sorting.ts
@@ -7,62 +7,6 @@ interface TypedCrudSort {
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(sorters: TypedCrudSort[]) {
- 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.
diff --git a/pyproject.toml b/pyproject.toml
index dc5ab2f..169bfd2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py
index 6d94d74..0203240 100644
--- a/spoolman/api/v1/filament.py
+++ b/spoolman/api/v1/filament.py
@@ -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(
diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py
new file mode 100644
index 0000000..345ea60
--- /dev/null
+++ b/spoolman/api/v1/other.py
@@ -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)
diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py
index cbeeb8c..00e8f05 100644
--- a/spoolman/api/v1/router.py
+++ b/spoolman/api/v1/router.py
@@ -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)
diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py
index db7b91c..269c301 100644
--- a/spoolman/api/v1/spool.py
+++ b/spoolman/api/v1/spool.py
@@ -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(
diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py
index c09675f..4a69640 100644
--- a/spoolman/api/v1/vendor.py
+++ b/spoolman/api/v1/vendor.py
@@ -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(
diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py
index 5e36827..59ef66b 100644
--- a/spoolman/database/filament.py
+++ b/spoolman/database/filament.py
@@ -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]
diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py
index 660c71c..6eb4ae7 100644
--- a/spoolman/database/spool.py
+++ b/spoolman/database/spool.py
@@ -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]
diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py
new file mode 100644
index 0000000..93d0525
--- /dev/null
+++ b/spoolman/database/utils.py
@@ -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
diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py
index 7f9459c..d0e6039 100644
--- a/spoolman/database/vendor.py
+++ b/spoolman/database/vendor.py
@@ -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(
diff --git a/spoolman/main.py b/spoolman/main.py
index 1e16948..b04dac4 100644
--- a/spoolman/main.py
+++ b/spoolman/main.py
@@ -56,6 +56,7 @@ if env.is_debug_mode():
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+ expose_headers=["X-Total-Count"],
)
diff --git a/tests_integration/tests/conftest.py b/tests_integration/tests/conftest.py
index 640363d..a87de9f 100644
--- a/tests_integration/tests/conftest.py
+++ b/tests_integration/tests/conftest.py
@@ -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.
diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py
index a833add..a5920fd 100644
--- a/tests_integration/tests/filament/test_find.py
+++ b/tests_integration/tests/filament/test_find.py
@@ -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:])
diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py
index 2126dcd..7ddf553 100644
--- a/tests_integration/tests/spool/test_find.py
+++ b/tests_integration/tests/spool/test_find.py
@@ -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]))
diff --git a/tests_integration/tests/vendor/test_find.py b/tests_integration/tests/vendor/test_find.py
index e8aac2e..49dff8a 100644
--- a/tests_integration/tests/vendor/test_find.py
+++ b/tests_integration/tests/vendor/test_find.py
@@ -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]]