Merge pull request #99 from Donkie/sorting

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

View File

@@ -16,6 +16,7 @@
"@refinedev/react-router-v6": "^4.5.0",
"@refinedev/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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -13,14 +13,13 @@ from spoolman import env
from spoolman.database.database import backup_global_db
from spoolman.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)

View File

@@ -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(

View File

@@ -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(

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,11 @@
from typing import Optional
from 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(

View File

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

View File

@@ -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.

View File

@@ -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:])

View File

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

View File

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