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]]